diff --git a/Cargo.lock b/Cargo.lock index a281cad..c782a80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,137 +86,6 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "async-signal" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - [[package]] name = "atk" version = "0.18.2" @@ -341,19 +210,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "blocking" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - [[package]] name = "brotli" version = "8.0.4" @@ -563,15 +419,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "constant_time_eq" version = "0.4.2" @@ -1011,33 +858,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -1065,26 +885,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "event-listener" -version = "5.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" -dependencies = [ - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1247,19 +1047,6 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - [[package]] name = "futures-macro" version = "0.3.33" @@ -2454,12 +2241,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" - [[package]] name = "hex" version = "0.4.3" @@ -3055,14 +2836,14 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" name = "lore-app" version = "0.0.0" dependencies = [ + "lore-core", "lore-ipc", - "lore-orchestrator", + "rusqlite", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-dialog", - "tauri-plugin-notification", "tempfile", ] @@ -3093,17 +2874,6 @@ dependencies = [ "ts-rs", ] -[[package]] -name = "lore-orchestrator" -version = "0.0.0" -dependencies = [ - "lore-ipc", - "serde", - "serde_json", - "tempfile", - "thiserror 1.0.69", -] - [[package]] name = "lorectl" version = "0.0.0" @@ -3117,20 +2887,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "mac-notification-sys" -version = "0.6.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" -dependencies = [ - "cc", - "log", - "objc2", - "objc2-foundation", - "time", - "uuid", -] - [[package]] name = "markup5ever" version = "0.38.0" @@ -3269,20 +3025,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "notify-rust" -version = "4.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" -dependencies = [ - "futures-lite", - "log", - "mac-notification-sys", - "serde", - "tauri-winrt-notification", - "zbus", -] - [[package]] name = "notify-types" version = "2.1.0" @@ -3537,16 +3279,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - [[package]] name = "pango" version = "0.18.3" @@ -3572,12 +3304,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - [[package]] name = "parking_lot" version = "0.12.5" @@ -3666,17 +3392,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - [[package]] name = "pkg-config" version = "0.3.33" @@ -3722,20 +3437,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix", - "windows-sys 0.61.2", -] - [[package]] name = "portable-atomic" version = "1.14.0" @@ -4414,16 +4115,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "simd-adler32" version = "0.3.10" @@ -4846,25 +4537,6 @@ dependencies = [ "url", ] -[[package]] -name = "tauri-plugin-notification" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad2fd40946aef810c4be9fd33a2d1b9b397cb79042b2d21c81a0a8f204354fd1" -dependencies = [ - "log", - "notify-rust", - "rand", - "serde", - "serde_json", - "serde_repr", - "tauri", - "tauri-plugin", - "thiserror 2.0.20", - "time", - "url", -] - [[package]] name = "tauri-runtime" version = "2.11.3" @@ -4965,17 +4637,6 @@ dependencies = [ "toml 1.1.4+spec-1.1.0", ] -[[package]] -name = "tauri-winrt-notification" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" -dependencies = [ - "thiserror 2.0.20", - "windows", - "windows-version", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -5301,21 +4962,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", - "tracing-attributes", "tracing-core", ] -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "tracing-core" version = "0.1.36" @@ -5387,17 +5036,6 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", -] - [[package]] name = "unarray" version = "0.1.4" @@ -6316,76 +5954,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zbus" -version = "5.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow 1.0.4", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "5.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 3.0.3", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" -dependencies = [ - "serde", - "winnow 1.0.4", - "zvariant", -] - -[[package]] -name = "zcheapstr" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" -dependencies = [ - "serde", -] - [[package]] name = "zerocopy" version = "0.8.56" @@ -6471,44 +6039,3 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zvariant" -version = "5.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" -dependencies = [ - "endi", - "enumflags2", - "serde", - "winnow 1.0.4", - "zcheapstr", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 3.0.3", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 3.0.3", - "winnow 1.0.4", -] diff --git a/Cargo.toml b/Cargo.toml index f6de897..1f3d654 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ # shell (src-tauri) is a thin binary crate depending on lore-core and lore-ipc. [workspace] resolver = "2" -members = ["crates/lore-core", "crates/lore-ipc", "crates/lore-orchestrator", "crates/lorectl", "src-tauri"] +members = ["crates/lore-core", "crates/lore-ipc", "crates/lorectl", "src-tauri"] [workspace.package] version = "0.0.0" diff --git a/crates/lore-ipc/bindings/ActivityDto.ts b/crates/lore-ipc/bindings/ActivityDto.ts deleted file mode 100644 index f36c90d..0000000 --- a/crates/lore-ipc/bindings/ActivityDto.ts +++ /dev/null @@ -1,4 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ActivityKind } from "./ActivityKind"; - -export type ActivityDto = { kind: ActivityKind, text: string, }; diff --git a/crates/lore-ipc/bindings/ActivityKind.ts b/crates/lore-ipc/bindings/ActivityKind.ts deleted file mode 100644 index 38a484e..0000000 --- a/crates/lore-ipc/bindings/ActivityKind.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * What an agent is doing, parsed from its output (`task_activity`). - */ -export type ActivityKind = "message" | "tool" | "result" | "error" | "output"; diff --git a/crates/lore-ipc/bindings/ContinueTaskRequest.ts b/crates/lore-ipc/bindings/ContinueTaskRequest.ts deleted file mode 100644 index 9fa622c..0000000 --- a/crates/lore-ipc/bindings/ContinueTaskRequest.ts +++ /dev/null @@ -1,22 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { TaskAgent } from "./TaskAgent"; -import type { TaskEffort } from "./TaskEffort"; - -/** - * Input of `continue_task`: send more work to a finished, failed, stopped, or - * interrupted task in the same worktree. - */ -export type ContinueTaskRequest = { id: string, prompt: string, -/** - * Switch agents (a handoff). Omit to keep the current agent, which resumes - * its own session when possible. - */ -agent?: TaskAgent, -/** - * Replace the stored model for this run. Omit to keep the task's model. - */ -model?: string, -/** - * Replace the stored effort for this run. Omit to keep the task's effort. - */ -effort?: TaskEffort, }; diff --git a/crates/lore-ipc/bindings/CreateTaskRequest.ts b/crates/lore-ipc/bindings/CreateTaskRequest.ts deleted file mode 100644 index 0b8e045..0000000 --- a/crates/lore-ipc/bindings/CreateTaskRequest.ts +++ /dev/null @@ -1,37 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { TaskAgent } from "./TaskAgent"; -import type { TaskEffort } from "./TaskEffort"; -import type { TaskPermission } from "./TaskPermission"; - -/** - * Input of `create_task`. - */ -export type CreateTaskRequest = { -/** - * Any path inside the target git repository. - */ -repo_path: string, title: string, prompt: string, agent: TaskAgent, -/** - * Defaults to `edits`. - */ -permission?: TaskPermission, -/** - * Repository-relative files or folders (ending in `/`) this task owns. - * Other agents are told not to edit them, and commits touching them are - * refused for other tasks unless forced. - */ -claims?: Array, -/** - * Hand off to the other agent automatically if this one hits a usage - * limit. Defaults to true. - */ -auto_handoff?: boolean, -/** - * Model alias or id (`opus`, `sonnet`, `fable`, `gpt-5`, …). Omit to use - * the agent's own default. Validated before it is passed as a flag. - */ -model?: string, -/** - * Reasoning / effort level. Omit to use the agent's own default. - */ -effort?: TaskEffort, }; diff --git a/crates/lore-ipc/bindings/DecisionDto.ts b/crates/lore-ipc/bindings/DecisionDto.ts deleted file mode 100644 index d73d575..0000000 --- a/crates/lore-ipc/bindings/DecisionDto.ts +++ /dev/null @@ -1,12 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * One entry of the shared decision log (`list_decisions`), the orchestrator's - * memory of what happened across tasks in a repository. - */ -export type DecisionDto = { at_ms: number, task_id: string, task_title: string, repo_path: string, -/** - * `created` | `continued` | `handoff` | `auto_handoff` | `committed` | - * `merged` | `merge_conflict` | `stopped` | `discarded`. - */ -kind: string, detail: string, }; diff --git a/crates/lore-ipc/bindings/DirEntryDto.ts b/crates/lore-ipc/bindings/DirEntryDto.ts deleted file mode 100644 index 36eaf62..0000000 --- a/crates/lore-ipc/bindings/DirEntryDto.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * One entry of a workspace directory listing (`list_workspace_dir`). - */ -export type DirEntryDto = { name: string, -/** - * Path relative to the workspace root, `/`-separated. - */ -rel_path: string, is_dir: boolean, }; diff --git a/crates/lore-ipc/bindings/FileContentDto.ts b/crates/lore-ipc/bindings/FileContentDto.ts deleted file mode 100644 index d01a5c5..0000000 --- a/crates/lore-ipc/bindings/FileContentDto.ts +++ /dev/null @@ -1,14 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * A read-only file view (`read_workspace_file`). - */ -export type FileContentDto = { rel_path: string, -/** - * UTF-8 text, or null for binary files. - */ -text: string | null, size: number, -/** - * Only the first part of a large file was returned. - */ -truncated: boolean, }; diff --git a/crates/lore-ipc/bindings/MergeQueueDto.ts b/crates/lore-ipc/bindings/MergeQueueDto.ts deleted file mode 100644 index 81abcb0..0000000 --- a/crates/lore-ipc/bindings/MergeQueueDto.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { MergeQueueItemDto } from "./MergeQueueItemDto"; - -/** - * State of a repository's merge queue (`get_merge_queue`). - */ -export type MergeQueueDto = { repo_path: string, running: boolean, test_command: string | null, items: Array, }; diff --git a/crates/lore-ipc/bindings/MergeQueueItemDto.ts b/crates/lore-ipc/bindings/MergeQueueItemDto.ts deleted file mode 100644 index a51abd8..0000000 --- a/crates/lore-ipc/bindings/MergeQueueItemDto.ts +++ /dev/null @@ -1,11 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * One task in a merge queue. - */ -export type MergeQueueItemDto = { task_id: string, title: string, -/** - * `pending` | `updating` | `testing` | `merging` | `merged` | `failed` | - * `skipped` | `cancelled` | `interrupted`. - */ -status: string, detail: string | null, }; diff --git a/crates/lore-ipc/bindings/MergeResultDto.ts b/crates/lore-ipc/bindings/MergeResultDto.ts deleted file mode 100644 index 3c0aa46..0000000 --- a/crates/lore-ipc/bindings/MergeResultDto.ts +++ /dev/null @@ -1,14 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Outcome of `merge_task`. - */ -export type MergeResultDto = { merged: boolean, -/** - * Branch of the primary checkout the task was merged into. - */ -into_branch: string, -/** - * Conflicting paths when the merge was aborted. - */ -conflicts: Array, message: string, }; diff --git a/crates/lore-ipc/bindings/RepoSettingsDto.ts b/crates/lore-ipc/bindings/RepoSettingsDto.ts deleted file mode 100644 index 52fd3fd..0000000 --- a/crates/lore-ipc/bindings/RepoSettingsDto.ts +++ /dev/null @@ -1,11 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Per-repository settings Lore keeps (`get_repo_settings` / `set_repo_test_command`). - */ -export type RepoSettingsDto = { repo_path: string, -/** - * Shell command the merge queue runs in a task's worktree before merging, - * e.g. `npm test`. None means merges are not gated on tests. - */ -test_command: string | null, }; diff --git a/crates/lore-ipc/bindings/TaskAgent.ts b/crates/lore-ipc/bindings/TaskAgent.ts deleted file mode 100644 index 7a4a20f..0000000 --- a/crates/lore-ipc/bindings/TaskAgent.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Which coding-agent CLI runs an orchestrated task (ADR-0007). - */ -export type TaskAgent = "claude_code" | "codex"; diff --git a/crates/lore-ipc/bindings/TaskDiffDto.ts b/crates/lore-ipc/bindings/TaskDiffDto.ts deleted file mode 100644 index cba9759..0000000 --- a/crates/lore-ipc/bindings/TaskDiffDto.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Unified diff of a task's worktree against its base commit (`task_diff`). - */ -export type TaskDiffDto = { text: string, truncated: boolean, }; diff --git a/crates/lore-ipc/bindings/TaskDto.ts b/crates/lore-ipc/bindings/TaskDto.ts deleted file mode 100644 index 13f95d7..0000000 --- a/crates/lore-ipc/bindings/TaskDto.ts +++ /dev/null @@ -1,75 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { TaskAgent } from "./TaskAgent"; -import type { TaskEffort } from "./TaskEffort"; -import type { TaskOverlapDto } from "./TaskOverlapDto"; -import type { TaskPermission } from "./TaskPermission"; -import type { TaskState } from "./TaskState"; - -/** - * One orchestrated task with its live status. Payload element of `list_tasks`. - */ -export type TaskDto = { id: string, title: string, prompt: string, agent: TaskAgent, state: TaskState, -/** - * Repository top-level the worktree was created from. - */ -repo_path: string, -/** - * Lore-owned worktree the agent runs in. - */ -worktree_path: string, branch: string, -/** - * Commit the worktree branched from. - */ -base_commit: string, created_at_ms: number, exit_code: number | null, -/** - * Commits on the task branch since `base_commit`. - */ -commits_ahead: number, -/** - * Paths changed relative to `base_commit`, committed or not (capped). - */ -changed_files: Array, -/** - * Total changed paths; larger than `changed_files.len()` when capped. - */ -changed_files_total?: number, -/** - * Most recent human-readable line from the agent's output, truncated. - */ -last_activity: string | null, permission?: TaskPermission, -/** - * Model alias or id used for the latest run, when one was chosen. - */ -model?: string, effort?: TaskEffort, -/** - * How many times an agent has run in this worktree (1 + continuations). - */ -runs?: number, -/** - * Paths with uncommitted changes in the worktree. - */ -uncommitted_count?: number, -/** - * Something needs the user: e.g. usage limit reached or not logged in. - */ -attention?: string, -/** - * Other unmerged tasks in the same repository that changed the same files. - */ -overlaps?: Array, -/** - * Files or folders this task owns. - */ -claims?: Array, -/** - * Changed files that fall under another unmerged task's claims. - */ -claim_conflicts?: Array, auto_handoff?: boolean, -/** - * Branch currently checked out in the task's repository (the merge target). - */ -repo_branch?: string, -/** - * Set once the task branch was merged into the primary checkout. - */ -merged_into?: string, }; diff --git a/crates/lore-ipc/bindings/TaskEffort.ts b/crates/lore-ipc/bindings/TaskEffort.ts deleted file mode 100644 index df58e63..0000000 --- a/crates/lore-ipc/bindings/TaskEffort.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * How hard the model should try (Claude `--effort`, Codex `model_reasoning_effort`). - * Omit to use the agent's own default. - */ -export type TaskEffort = "low" | "medium" | "high" | "extra_high" | "max"; diff --git a/crates/lore-ipc/bindings/TaskOverlapDto.ts b/crates/lore-ipc/bindings/TaskOverlapDto.ts deleted file mode 100644 index 2fbfd0c..0000000 --- a/crates/lore-ipc/bindings/TaskOverlapDto.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Files a task shares with another unmerged task (overlap warning, step 3). - */ -export type TaskOverlapDto = { task_id: string, title: string, files: Array, }; diff --git a/crates/lore-ipc/bindings/TaskPermission.ts b/crates/lore-ipc/bindings/TaskPermission.ts deleted file mode 100644 index 374a32d..0000000 --- a/crates/lore-ipc/bindings/TaskPermission.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * How much an agent may do without asking (ADR-0007). Lore never bypasses - * permission prompts or sandboxes. - */ -export type TaskPermission = "edits" | "auto"; diff --git a/crates/lore-ipc/bindings/TaskState.ts b/crates/lore-ipc/bindings/TaskState.ts deleted file mode 100644 index 8e8bfaa..0000000 --- a/crates/lore-ipc/bindings/TaskState.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Lifecycle of an orchestrated task. - */ -export type TaskState = "running" | "finished" | "failed" | "stopped" | "interrupted"; diff --git a/crates/lore-ipc/bindings/TasksChangedEvent.ts b/crates/lore-ipc/bindings/TasksChangedEvent.ts deleted file mode 100644 index cc25982..0000000 --- a/crates/lore-ipc/bindings/TasksChangedEvent.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Emitted when tasks change (state, new output, or an automatic handoff), so - * the UI can refresh just those tasks instead of polling everything. - */ -export type TasksChangedEvent = { ids: Array, }; diff --git a/crates/lore-ipc/src/lib.rs b/crates/lore-ipc/src/lib.rs index 428cfea..71fecea 100644 --- a/crates/lore-ipc/src/lib.rs +++ b/crates/lore-ipc/src/lib.rs @@ -395,349 +395,6 @@ pub struct CheckUpdateResultDto { pub published_at: Option, } -/// Which coding-agent CLI runs an orchestrated task (ADR-0007). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export)] -pub enum TaskAgent { - ClaudeCode, - Codex, -} - -/// Lifecycle of an orchestrated task. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export)] -pub enum TaskState { - Running, - /// The agent exited with status 0. - Finished, - /// The agent exited non-zero or could not be started. - Failed, - /// The user stopped it. - Stopped, - /// It was running when Lore last quit; the process is no longer supervised. - Interrupted, -} - -/// Input of `create_task`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] -#[ts(export)] -pub struct CreateTaskRequest { - /// Any path inside the target git repository. - pub repo_path: String, - pub title: String, - pub prompt: String, - pub agent: TaskAgent, - /// Defaults to `edits`. - #[serde(default)] - #[ts(optional)] - pub permission: Option, - /// Repository-relative files or folders (ending in `/`) this task owns. - /// Other agents are told not to edit them, and commits touching them are - /// refused for other tasks unless forced. - #[serde(default)] - #[ts(optional)] - pub claims: Option>, - /// Hand off to the other agent automatically if this one hits a usage - /// limit. Defaults to true. - #[serde(default)] - #[ts(optional)] - pub auto_handoff: Option, - /// Model alias or id (`opus`, `sonnet`, `fable`, `gpt-5`, …). Omit to use - /// the agent's own default. Validated before it is passed as a flag. - #[serde(default)] - #[ts(optional)] - pub model: Option, - /// Reasoning / effort level. Omit to use the agent's own default. - #[serde(default)] - #[ts(optional)] - pub effort: Option, -} - -/// How hard the model should try (Claude `--effort`, Codex `model_reasoning_effort`). -/// Omit to use the agent's own default. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export)] -pub enum TaskEffort { - Low, - Medium, - High, - /// Claude `xhigh`; Codex maps to `high`. - ExtraHigh, - Max, -} - -impl TaskEffort { - /// Value passed to Claude `--effort`. - pub fn as_claude(self) -> &'static str { - match self { - Self::Low => "low", - Self::Medium => "medium", - Self::High => "high", - Self::ExtraHigh => "xhigh", - Self::Max => "max", - } - } - - /// Value passed to Codex `model_reasoning_effort`. - pub fn as_codex(self) -> &'static str { - match self { - Self::Low => "low", - Self::Medium => "medium", - Self::High | Self::ExtraHigh | Self::Max => "high", - } - } -} - -/// How much an agent may do without asking (ADR-0007). Lore never bypasses -/// permission prompts or sandboxes. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export)] -pub enum TaskPermission { - /// Claude: edit files only (`acceptEdits`); shell commands are denied. - /// Codex: sandboxed to the worktree. - #[default] - Edits, - /// Claude: `auto` mode, where the agent's own safety classifier approves - /// routine actions such as running tests. Codex: same sandbox as `edits`. - Auto, -} - -/// Input of `continue_task`: send more work to a finished, failed, stopped, or -/// interrupted task in the same worktree. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] -#[ts(export)] -pub struct ContinueTaskRequest { - pub id: String, - pub prompt: String, - /// Switch agents (a handoff). Omit to keep the current agent, which resumes - /// its own session when possible. - #[serde(default)] - #[ts(optional)] - pub agent: Option, - /// Replace the stored model for this run. Omit to keep the task's model. - #[serde(default)] - #[ts(optional)] - pub model: Option, - /// Replace the stored effort for this run. Omit to keep the task's effort. - #[serde(default)] - #[ts(optional)] - pub effort: Option, -} - -/// Outcome of `merge_task`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] -#[ts(export)] -pub struct MergeResultDto { - pub merged: bool, - /// Branch of the primary checkout the task was merged into. - pub into_branch: String, - /// Conflicting paths when the merge was aborted. - pub conflicts: Vec, - pub message: String, -} - -/// One orchestrated task with its live status. Payload element of `list_tasks`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] -#[ts(export)] -pub struct TaskDto { - pub id: String, - pub title: String, - pub prompt: String, - pub agent: TaskAgent, - pub state: TaskState, - /// Repository top-level the worktree was created from. - pub repo_path: String, - /// Lore-owned worktree the agent runs in. - pub worktree_path: String, - pub branch: String, - /// Commit the worktree branched from. - pub base_commit: String, - #[ts(type = "number")] - pub created_at_ms: i64, - #[ts(type = "number | null")] - pub exit_code: Option, - /// Commits on the task branch since `base_commit`. - #[ts(type = "number")] - pub commits_ahead: i64, - /// Paths changed relative to `base_commit`, committed or not (capped). - pub changed_files: Vec, - /// Total changed paths; larger than `changed_files.len()` when capped. - #[serde(default)] - #[ts(optional, type = "number")] - pub changed_files_total: Option, - /// Most recent human-readable line from the agent's output, truncated. - pub last_activity: Option, - #[serde(default)] - #[ts(optional)] - pub permission: Option, - /// Model alias or id used for the latest run, when one was chosen. - #[serde(default)] - #[ts(optional)] - pub model: Option, - #[serde(default)] - #[ts(optional)] - pub effort: Option, - /// How many times an agent has run in this worktree (1 + continuations). - #[serde(default)] - #[ts(optional, type = "number")] - pub runs: Option, - /// Paths with uncommitted changes in the worktree. - #[serde(default)] - #[ts(optional, type = "number")] - pub uncommitted_count: Option, - /// Something needs the user: e.g. usage limit reached or not logged in. - #[serde(default)] - #[ts(optional)] - pub attention: Option, - /// Other unmerged tasks in the same repository that changed the same files. - #[serde(default)] - #[ts(optional)] - pub overlaps: Option>, - /// Files or folders this task owns. - #[serde(default)] - #[ts(optional)] - pub claims: Option>, - /// Changed files that fall under another unmerged task's claims. - #[serde(default)] - #[ts(optional)] - pub claim_conflicts: Option>, - #[serde(default)] - #[ts(optional)] - pub auto_handoff: Option, - /// Branch currently checked out in the task's repository (the merge target). - #[serde(default)] - #[ts(optional)] - pub repo_branch: Option, - /// Set once the task branch was merged into the primary checkout. - #[serde(default)] - #[ts(optional)] - pub merged_into: Option, -} - -/// One entry of the shared decision log (`list_decisions`), the orchestrator's -/// memory of what happened across tasks in a repository. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] -#[ts(export)] -pub struct DecisionDto { - #[ts(type = "number")] - pub at_ms: i64, - pub task_id: String, - pub task_title: String, - pub repo_path: String, - /// `created` | `continued` | `handoff` | `auto_handoff` | `committed` | - /// `merged` | `merge_conflict` | `stopped` | `discarded`. - pub kind: String, - pub detail: String, -} - -/// Emitted when tasks change (state, new output, or an automatic handoff), so -/// the UI can refresh just those tasks instead of polling everything. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] -#[ts(export)] -pub struct TasksChangedEvent { - pub ids: Vec, -} - -/// Per-repository settings Lore keeps (`get_repo_settings` / `set_repo_test_command`). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] -#[ts(export)] -pub struct RepoSettingsDto { - pub repo_path: String, - /// Shell command the merge queue runs in a task's worktree before merging, - /// e.g. `npm test`. None means merges are not gated on tests. - pub test_command: Option, -} - -/// One task in a merge queue. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] -#[ts(export)] -pub struct MergeQueueItemDto { - pub task_id: String, - pub title: String, - /// `pending` | `updating` | `testing` | `merging` | `merged` | `failed` | - /// `skipped` | `cancelled` | `interrupted`. - pub status: String, - pub detail: Option, -} - -/// State of a repository's merge queue (`get_merge_queue`). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] -#[ts(export)] -pub struct MergeQueueDto { - pub repo_path: String, - pub running: bool, - pub test_command: Option, - pub items: Vec, -} - -/// Files a task shares with another unmerged task (overlap warning, step 3). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] -#[ts(export)] -pub struct TaskOverlapDto { - pub task_id: String, - pub title: String, - pub files: Vec, -} - -/// One entry of a workspace directory listing (`list_workspace_dir`). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] -#[ts(export)] -pub struct DirEntryDto { - pub name: String, - /// Path relative to the workspace root, `/`-separated. - pub rel_path: String, - pub is_dir: bool, -} - -/// A read-only file view (`read_workspace_file`). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] -#[ts(export)] -pub struct FileContentDto { - pub rel_path: String, - /// UTF-8 text, or null for binary files. - pub text: Option, - #[ts(type = "number")] - pub size: i64, - /// Only the first part of a large file was returned. - pub truncated: bool, -} - -/// What an agent is doing, parsed from its output (`task_activity`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export)] -pub enum ActivityKind { - /// Assistant prose. - Message, - /// A tool call, e.g. "Edit src/lib.rs". - Tool, - /// The final result. - Result, - /// An error reported by the agent or on stderr. - Error, - /// A non-JSON output line. - Output, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] -#[ts(export)] -pub struct ActivityDto { - pub kind: ActivityKind, - pub text: String, -} - -/// Unified diff of a task's worktree against its base commit (`task_diff`). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] -#[ts(export)] -pub struct TaskDiffDto { - pub text: String, - pub truncated: bool, -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/lore-orchestrator/Cargo.toml b/crates/lore-orchestrator/Cargo.toml deleted file mode 100644 index 956b1ba..0000000 --- a/crates/lore-orchestrator/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "lore-orchestrator" -description = "Runs coding agents in parallel, each in a Lore-owned git worktree (ADR-0007)." -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -homepage.workspace = true -authors.workspace = true - -[lints] -workspace = true - -[dependencies] -lore-ipc = { path = "../lore-ipc" } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -thiserror = "1" - -[dev-dependencies] -tempfile = "3" diff --git a/crates/lore-orchestrator/src/agent.rs b/crates/lore-orchestrator/src/agent.rs deleted file mode 100644 index 51e3e5f..0000000 --- a/crates/lore-orchestrator/src/agent.rs +++ /dev/null @@ -1,814 +0,0 @@ -//! How each agent CLI is launched headless, and how its output is summarized. -//! -//! Launch flags were verified against `claude --help` (2.1.270) and -//! `codex exec --help` (0.154.0) on 2026-09-14. Lore never passes a flag that -//! bypasses the agent's permission prompts or sandbox (ADR-0007). - -use std::fs::File; -use std::io::{Read, Seek, SeekFrom}; -use std::path::{Path, PathBuf}; -use std::process::Command; - -use lore_ipc::{ActivityDto, ActivityKind, TaskAgent, TaskEffort, TaskPermission}; - -/// Program used to launch each agent. Defaults to `claude` / `codex` on `PATH`; -/// tests substitute a fake agent script. -#[derive(Debug, Clone)] -pub struct AgentPrograms { - pub claude_code: PathBuf, - pub codex: PathBuf, -} - -impl Default for AgentPrograms { - fn default() -> Self { - Self { - claude_code: PathBuf::from("claude"), - codex: PathBuf::from("codex"), - } - } -} - -/// How to start an agent run. -#[derive(Debug, Clone)] -pub struct Launch<'a> { - pub agent: TaskAgent, - pub permission: TaskPermission, - pub worktree: &'a Path, - pub prompt: &'a str, - /// Resume this agent session instead of starting a new one (Claude only). - pub resume_session: Option<&'a str>, - /// Claude `--model` / Codex `--model`. None uses the agent's default. - pub model: Option<&'a str>, - /// Claude `--effort` / Codex `model_reasoning_effort`. - pub effort: Option, -} - -/// A model id Lore will pass as a CLI argument: letters, digits, `.`, `_`, `-`, -/// no leading dash, so it cannot be read as a flag. -pub fn parse_model(raw: Option<&str>) -> crate::Result> { - let Some(raw) = raw.map(str::trim).filter(|s| !s.is_empty()) else { - return Ok(None); - }; - if raw.len() > 80 - || raw.starts_with('-') - || !raw - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) - { - return Err(crate::Error::Invalid(format!( - "model must be a short alias or id such as opus, sonnet, fable, or gpt-5 (got {raw})" - ))); - } - Ok(Some(raw.to_string())) -} - -/// Build the headless command for one agent run. -pub fn command(programs: &AgentPrograms, launch: Launch<'_>) -> Command { - let mut cmd = match launch.agent { - TaskAgent::ClaudeCode => { - let mut cmd = Command::new(&programs.claude_code); - // acceptEdits lets the agent edit files in its worktree without - // auto-approving shell commands; `auto` delegates routine approvals to - // Claude's own classifier. Never bypassPermissions. stream-json - // requires --verbose. Print mode skips the workspace-trust dialog, so - // settings and MCP servers committed to the repository (which may be - // untrusted) are not loaded: only the user's own and local settings. - let mode = match launch.permission { - TaskPermission::Edits => "acceptEdits", - TaskPermission::Auto => "auto", - }; - cmd.args([ - "-p", - "--output-format", - "stream-json", - "--verbose", - "--permission-mode", - mode, - "--setting-sources", - "user,local", - "--strict-mcp-config", - ]); - if let Some(session) = launch.resume_session { - cmd.arg(format!("--resume={session}")); - } - if let Some(model) = launch.model { - cmd.arg("--model").arg(model); - } - if let Some(effort) = launch.effort { - cmd.arg("--effort").arg(effort.as_claude()); - } - cmd.arg("--").arg(launch.prompt); - cmd - } - TaskAgent::Codex => { - let mut cmd = Command::new(&programs.codex); - // workspace-write confines writes to the worktree via Codex's sandbox. - // `codex exec resume` (0.144.6) accepts neither --sandbox nor --cd, so - // Codex continuations start a new session with a brief instead. - cmd.args(["exec", "--json", "--sandbox", "workspace-write", "--cd"]) - .arg(launch.worktree); - if let Some(model) = launch.model { - cmd.arg("--model").arg(model); - } - if let Some(effort) = launch.effort { - cmd.arg("-c") - .arg(format!("model_reasoning_effort=\"{}\"", effort.as_codex())); - } - cmd.arg("--").arg(launch.prompt); - cmd - } - }; - cmd.current_dir(launch.worktree); - cmd -} - -const SESSION_TAIL_BYTES: u64 = 4 * 1024 * 1024; - -/// Claude's session id from the latest run in the log, if that run was a -/// Claude run that reported one. Only `{"type":"system","subtype":"init"}` -/// events count, and the id must be a UUID so it can never be read as a flag. -pub fn claude_session_id(log: &Path) -> Option { - let text = read_tail(log, SESSION_TAIL_BYTES)?; - let latest_run = text - .rsplit_once(RUN_MARKER) - .map_or(text.as_str(), |(_, rest)| rest); - latest_run - .lines() - .rev() - .filter_map(|l| serde_json::from_str::(l.trim()).ok()) - .filter(|v| { - v.get("type").and_then(|t| t.as_str()) == Some("system") - && v.get("subtype").and_then(|t| t.as_str()) == Some("init") - }) - .find_map(|v| { - v.get("session_id") - .and_then(|s| s.as_str()) - .map(str::to_string) - }) - .filter(|s| is_uuid(s)) -} - -fn is_uuid(s: &str) -> bool { - s.len() == 36 - && s.char_indices().all(|(i, c)| match i { - 8 | 13 | 18 | 23 => c == '-', - _ => c.is_ascii_hexdigit(), - }) -} - -fn read_tail(log: &Path, max: u64) -> Option { - let mut file = File::open(log).ok()?; - let len = file.metadata().ok()?.len(); - file.seek(SeekFrom::Start(len.saturating_sub(max))).ok()?; - let mut buf = Vec::new(); - file.read_to_end(&mut buf).ok()?; - let text = String::from_utf8_lossy(&buf).into_owned(); - Some(if len > max { - text.split_once('\n') - .map_or(String::new(), |(_, r)| r.to_string()) - } else { - text - }) -} - -/// A problem in the latest run that the user must act on, if any. Only errors -/// count (failed results and stderr lines), never agent prose or tool calls. -pub fn attention(log: &Path) -> Option { - let events = activity_with_tail(log, 30, 64 * 1024); - let start = events - .iter() - .rposition(|e| e.kind == ActivityKind::Output && e.text.starts_with(RUN_MARKER)) - .map_or(0, |i| i + 1); - events[start..] - .iter() - .rev() - .filter(|e| matches!(e.kind, ActivityKind::Error | ActivityKind::Output)) - .find_map(|e| { - if is_claude_hook_failure(&e.text) { - return None; - } - let lower = e.text.to_lowercase(); - // Only quota exhaustion, not transient 429 "rate limit" retries. - if lower.contains("usage limit") - || lower.contains("hit your limit") - || lower.contains("quota exceeded") - { - Some( - "Usage limit reached. Continue later or hand off to the other agent." - .to_string(), - ) - } else if lower.contains("failed to authenticate") - || lower.contains("not logged in") - || lower.contains("please run /login") - || lower.contains("invalid api key") - { - Some(format!( - "Agent is not signed in. Run it once in a terminal to log in. ({})", - cap_chars(&e.text, 120) - )) - } else { - None - } - }) -} - -/// Whether the latest run stopped because the agent ran out of quota, which is -/// what an automatic handoff to the other agent reacts to. -pub fn usage_limited(log: &Path) -> bool { - attention(log).is_some_and(|a| a.starts_with("Usage limit")) -} - -/// Written between runs in a task log. -pub const RUN_MARKER: &str = "--- Lore:"; - -fn cap_chars(s: &str, n: usize) -> String { - s.chars().take(n).collect() -} - -const TAIL_BYTES: u64 = 64 * 1024; -const MAX_ACTIVITY_CHARS: usize = 240; - -/// The most recent human-readable line of an agent's log, if any. -/// -/// Understands Claude Code `stream-json` and Codex `--json` events loosely: it -/// prefers assistant text / final results, and otherwise falls back to the last -/// non-JSON line (e.g. an error printed to stderr). -pub fn last_activity(log: &Path) -> Option { - let mut file = File::open(log).ok()?; - let len = file.metadata().ok()?.len(); - file.seek(SeekFrom::Start(len.saturating_sub(TAIL_BYTES))) - .ok()?; - let mut buf = Vec::new(); - file.read_to_end(&mut buf).ok()?; - let text = String::from_utf8_lossy(&buf); - // A read that starts mid-file almost always starts mid-line; drop that - // fragment so partial JSON (possibly tool output) is never shown. - let text = if len > TAIL_BYTES { - text.split_once('\n').map_or("", |(_, rest)| rest) - } else { - &text - }; - - // Prefer what the agent said; fall back to a plain line (e.g. a startup - // error on stderr) only when no event carries text. Trailing stderr noise - // such as a failing hook must not hide the agent's final result. - let lines: Vec<&str> = text - .lines() - .map(str::trim) - .filter(|l| !l.is_empty()) - .collect(); - let from_events = lines.iter().rev().find_map(|line| { - serde_json::from_str::(line) - .ok() - .and_then(|v| event_text(&v)) - }); - from_events - .or_else(|| { - lines - .iter() - .rev() - .find(|l| !l.starts_with('{') && !is_claude_hook_failure(l)) - .map(|l| l.to_string()) - }) - .map(|s| truncate(&s)) -} - -fn event_text(v: &serde_json::Value) -> Option { - // Claude Code: {"type":"result","result":"..."} - if let Some(result) = v.get("result").and_then(|r| r.as_str()) { - return non_empty(result); - } - // Claude Code: {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}} - if let Some(content) = v.pointer("/message/content").and_then(|c| c.as_array()) { - let text = content - .iter() - .filter_map(|b| match b.get("type").and_then(|t| t.as_str()) { - Some("text") => b.get("text").and_then(|t| t.as_str()).map(str::to_string), - Some("tool_use") => b - .get("name") - .and_then(|n| n.as_str()) - .map(|n| format!("using {n}")), - _ => None, - }) - .collect::>() - .join(" "); - return non_empty(&text); - } - // Codex errors: {"type":"error","message":...} and turn.failed. - if matches!( - v.get("type").and_then(|t| t.as_str()), - Some("error") | Some("turn.failed") - ) { - return ["/message", "/error/message"] - .iter() - .find_map(|p| v.pointer(p).and_then(|s| s.as_str())) - .and_then(non_empty); - } - // Codex: only agent messages, never reasoning items. - if let Some(item) = v.get("item") { - return match item.get("type").and_then(|t| t.as_str()) { - Some("agent_message") => item - .get("text") - .and_then(|t| t.as_str()) - .and_then(non_empty), - _ => None, - }; - } - for ptr in ["/msg/message", "/message", "/payload/message"] { - if let Some(s) = v.pointer(ptr).and_then(|m| m.as_str()) { - return non_empty(s); - } - } - None -} - -const ACTIVITY_TAIL_BYTES: u64 = 512 * 1024; -const MAX_EVENT_CHARS: usize = 2_000; - -/// Claude settings/hooks can fail on stderr without the agent itself failing. -/// Those lines mention "error" so they used to show as errors and steal focus. -fn is_claude_hook_failure(line: &str) -> bool { - let l = line.to_ascii_lowercase(); - l.contains("hook") - && (l.contains("econnrefused") - || l.contains("hook failed") - || (l.contains("hook [") && l.contains("failed"))) -} - -fn line_kind(line: &str) -> ActivityKind { - if is_claude_hook_failure(line) { - ActivityKind::Output - } else if line.to_ascii_lowercase().contains("error") { - ActivityKind::Error - } else { - ActivityKind::Output - } -} - -/// The agent's recent activity as a readable timeline, oldest first. -/// -/// Thinking/reasoning and tool results (which may contain whole files) are -/// never included; tool calls are summarized by name and target. -pub fn activity(log: &Path, max_events: usize) -> Vec { - activity_with_tail(log, max_events, ACTIVITY_TAIL_BYTES) -} - -fn activity_with_tail(log: &Path, max_events: usize, tail: u64) -> Vec { - let Some(text) = read_tail(log, tail) else { - return Vec::new(); - }; - let mut events: Vec = Vec::new(); - for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) { - match serde_json::from_str::(line) { - Ok(v) => events.extend(activity_from_event(&v)), - Err(_) if !line.starts_with('{') => events.push(ActivityDto { - kind: line_kind(line), - text: cap(line), - }), - Err(_) => {} - } - } - let skip = events.len().saturating_sub(max_events); - events.split_off(skip) -} - -fn activity_from_event(v: &serde_json::Value) -> Vec { - let mut out = Vec::new(); - let push = |out: &mut Vec, kind, text: &str| { - if let Some(text) = non_empty(text) { - out.push(ActivityDto { - kind, - text: cap(&text), - }); - } - }; - match v.get("type").and_then(|t| t.as_str()) { - Some("result") => { - let is_error = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false); - let kind = if is_error { - ActivityKind::Error - } else { - ActivityKind::Result - }; - if let Some(r) = v.get("result").and_then(|r| r.as_str()) { - push(&mut out, kind, r); - } - } - Some("assistant") => { - for block in v - .pointer("/message/content") - .and_then(|c| c.as_array()) - .into_iter() - .flatten() - { - match block.get("type").and_then(|t| t.as_str()) { - Some("text") => { - if let Some(s) = block.get("text").and_then(|s| s.as_str()) { - push(&mut out, ActivityKind::Message, s); - } - } - Some("tool_use") => { - let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("tool"); - let target = [ - "file_path", - "path", - "command", - "pattern", - "url", - "description", - ] - .iter() - .find_map(|k| { - block - .pointer(&format!("/input/{k}")) - .and_then(|s| s.as_str()) - }) - .unwrap_or(""); - push(&mut out, ActivityKind::Tool, &format!("{name} {target}")); - } - _ => {} - } - } - } - _ => { - if let Some(item) = v.get("item") { - match item.get("type").and_then(|t| t.as_str()) { - Some("agent_message") => { - if let Some(s) = item.get("text").and_then(|s| s.as_str()) { - push(&mut out, ActivityKind::Message, s); - } - } - Some("command_execution") => { - if let Some(s) = item.get("command").and_then(|s| s.as_str()) { - push(&mut out, ActivityKind::Tool, &format!("run {s}")); - } - } - Some("file_change") | Some("patch") => { - push(&mut out, ActivityKind::Tool, "edit files"); - } - Some("error") => { - if let Some(s) = item.get("message").and_then(|s| s.as_str()) { - push(&mut out, ActivityKind::Error, s); - } - } - _ => {} - } - } else if let Some(s) = ["/message", "/error/message"] - .iter() - .filter(|_| { - matches!( - v.get("type").and_then(|t| t.as_str()), - Some("error") | Some("turn.failed") - ) - }) - .find_map(|p| v.pointer(p).and_then(|s| s.as_str())) - { - push(&mut out, ActivityKind::Error, s); - } - } - } - out -} - -fn cap(s: &str) -> String { - if s.chars().count() <= MAX_EVENT_CHARS { - return s.to_string(); - } - let mut out: String = s.chars().take(MAX_EVENT_CHARS - 1).collect(); - out.push('…'); - out -} - -fn non_empty(s: &str) -> Option { - let s = s.split_whitespace().collect::>().join(" "); - (!s.is_empty()).then_some(s) -} - -fn truncate(s: &str) -> String { - if s.chars().count() <= MAX_ACTIVITY_CHARS { - return s.to_string(); - } - let mut out: String = s.chars().take(MAX_ACTIVITY_CHARS - 1).collect(); - out.push('…'); - out -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - use std::io::Write; - - fn log_with(lines: &[&str]) -> tempfile::NamedTempFile { - let mut f = tempfile::NamedTempFile::new().unwrap(); - for l in lines { - writeln!(f, "{l}").unwrap(); - } - f - } - - #[test] - fn prefers_latest_claude_assistant_text() { - let f = log_with(&[ - r#"{"type":"system","subtype":"init"}"#, - r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Reading the parser"}]}}"#, - r#"{"type":"user","message":{"content":[{"type":"tool_result","content":"x"}]}}"#, - ]); - assert_eq!( - last_activity(f.path()).as_deref(), - Some("Reading the parser") - ); - } - - #[test] - fn reports_claude_tool_use_and_result() { - let f = log_with(&[ - r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Edit"}]}}"#, - ]); - assert_eq!(last_activity(f.path()).as_deref(), Some("using Edit")); - let f = log_with(&[r#"{"type":"result","result":"Done: fixed it"}"#]); - assert_eq!(last_activity(f.path()).as_deref(), Some("Done: fixed it")); - } - - #[test] - fn falls_back_to_plain_stderr_line() { - let f = log_with(&[r#"{"type":"system"}"#, "Error: usage limit reached"]); - assert_eq!( - last_activity(f.path()).as_deref(), - Some("Error: usage limit reached") - ); - } - - #[test] - fn final_result_wins_over_trailing_stderr_noise() { - // Shape observed from a real `claude -p` run whose auth had expired. - let f = log_with(&[ - r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Failed to authenticate"}]}}"#, - r#"{"type":"result","is_error":true,"result":"Failed to authenticate: session expired"}"#, - "SessionEnd hook [http://127.0.0.1:1/hook] failed: connect ECONNREFUSED", - ]); - assert_eq!( - last_activity(f.path()).as_deref(), - Some("Failed to authenticate: session expired") - ); - } - - #[test] - fn activity_timeline_skips_thinking_and_tool_results() { - let f = log_with(&[ - r#"{"type":"system","subtype":"init"}"#, - r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"private"},{"type":"text","text":"Looking at auth"}]}}"#, - r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Edit","input":{"file_path":"src/auth.ts"}}]}}"#, - r#"{"type":"user","message":{"content":[{"type":"tool_result","content":"whole file contents"}]}}"#, - r#"{"type":"result","is_error":false,"result":"Fixed"}"#, - "hook failed: connect ECONNREFUSED error", - ]); - let events = activity(f.path(), 50); - let got: Vec<_> = events.iter().map(|e| (e.kind, e.text.as_str())).collect(); - assert_eq!( - got, - [ - (ActivityKind::Message, "Looking at auth"), - (ActivityKind::Tool, "Edit src/auth.ts"), - (ActivityKind::Result, "Fixed"), - ( - ActivityKind::Output, - "hook failed: connect ECONNREFUSED error" - ), - ] - ); - assert_eq!(activity(f.path(), 2).len(), 2); - assert!(!events - .iter() - .any(|e| e.text.contains("private") || e.text.contains("whole file"))); - } - - #[test] - fn claude_hook_failures_are_output_and_never_attention() { - let f = log_with(&[ - "SessionEnd hook [http://127.0.0.1:8737/agents/hooks/claude/event?managed_by=headroom&version=3] failed: connect ECONNREFUSED 127.0.0.1:8737", - ]); - let events = activity(f.path(), 10); - assert_eq!(events.len(), 1); - assert_eq!(events[0].kind, ActivityKind::Output); - assert!(attention(f.path()).is_none()); - assert!(last_activity(f.path()).is_none()); - } - - fn args(launch: Launch<'_>) -> Vec { - command(&AgentPrograms::default(), launch) - .get_args() - .map(|a| a.to_string_lossy().to_string()) - .collect() - } - - #[test] - fn resume_and_permission_flags() { - let base = Launch { - agent: TaskAgent::ClaudeCode, - permission: TaskPermission::Auto, - worktree: Path::new("/w"), - prompt: "-p looks like a flag", - resume_session: Some("abc"), - model: None, - effort: None, - }; - let a = args(base.clone()); - let mode = a.iter().position(|x| x == "--permission-mode").unwrap(); - assert_eq!(a[mode + 1], "auto"); - assert!(a.iter().any(|x| x == "--resume=abc")); - assert_eq!(&a[a.len() - 2..], ["--", "-p looks like a flag"]); - assert!(!a - .iter() - .any(|x| x.contains("dangerously") || x == "bypassPermissions")); - - let c = args(Launch { - agent: TaskAgent::Codex, - ..base - }); - assert!( - !c.iter().any(|x| x == "resume"), - "codex never resumes (loses sandbox)" - ); - assert_eq!(&c[c.len() - 2..], ["--", "-p looks like a flag"]); - } - - #[test] - fn finds_latest_session_id_and_attention() { - let uuid_old = "11111111-2222-3333-4444-555555555555"; - let uuid_new = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; - let f = log_with(&[ - &format!(r#"{{"type":"system","subtype":"init","session_id":"{uuid_old}"}}"#), - "--- Lore: run 2 (Claude Code) ---", - &format!(r#"{{"type":"system","subtype":"init","session_id":"{uuid_new}"}}"#), - r#"{"type":"result","is_error":true,"result":"Claude AI usage limit reached|1789300000"}"#, - ]); - assert_eq!(claude_session_id(f.path()).as_deref(), Some(uuid_new)); - assert!(attention(f.path()).unwrap().contains("Usage limit")); - - // A later non-Claude run, spoofed ids, or flag-like ids never resume. - let codex_last = log_with(&[ - &format!(r#"{{"type":"system","subtype":"init","session_id":"{uuid_old}"}}"#), - "--- Lore: run 2 (Codex) ---", - r#"{"thread_id":"019a-codex"}"#, - ]); - assert_eq!(claude_session_id(codex_last.path()), None); - let spoof = log_with(&[ - r#"{"type":"system","subtype":"init","session_id":"--dangerously-skip-permissions"}"#, - r#"{"session_id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}"#, - ]); - assert_eq!(claude_session_id(spoof.path()), None); - - let ok = log_with(&[ - r#"{"type":"result","is_error":true,"result":"usage limit reached"}"#, - "--- Lore: run 2 (Claude Code) ---", - r#"{"type":"assistant","message":{"content":[{"type":"text","text":"added rate limit middleware"}]}}"#, - r#"{"type":"result","is_error":false,"result":"Done"}"#, - ]); - assert_eq!( - attention(ok.path()), - None, - "earlier runs and prose do not count" - ); - } - - /// Shapes captured from `codex exec --json` 0.154.0 on 2026-09-13. - #[test] - fn codex_usage_limit_is_detected() { - let f = log_with(&[ - r#"{"type":"turn.started"}"#, - r#"{"type":"item.completed","item":{"id":"item_0","type":"error","message":"Skill descriptions were shortened to fit the skills context budget."}}"#, - r#"{"type":"error","message":"You've hit your usage limit. Upgrade to Pro or try again at 4:30 PM."}"#, - r#"{"type":"turn.failed","error":{"message":"You've hit your usage limit. Upgrade to Pro or try again at 4:30 PM."}}"#, - ]); - assert!(attention(f.path()).unwrap().contains("Usage limit reached")); - assert!(usage_limited(f.path())); - let events = activity(f.path(), 10); - assert_eq!(events.last().map(|e| e.kind), Some(ActivityKind::Error)); - } - - #[test] - fn truncates_long_text() { - let long = "a".repeat(1000); - let f = log_with(&[&format!(r#"{{"type":"result","result":"{long}"}}"#)]); - assert_eq!( - last_activity(f.path()).unwrap().chars().count(), - MAX_ACTIVITY_CHARS - ); - } - - #[test] - fn skips_partial_first_line_of_a_tail_read() { - let mut lines = vec![format!( - r#"{{"type":"result","result":"{}"}}"#, - "x".repeat(70_000) - )]; - lines.push(r#"{"type":"system"}"#.to_string()); - let refs: Vec<&str> = lines.iter().map(String::as_str).collect(); - let f = log_with(&refs); - assert_eq!(last_activity(f.path()), None); - } - - #[test] - fn codex_reasoning_is_never_shown() { - let f = log_with(&[ - r#"{"type":"item.completed","item":{"type":"agent_message","text":"Patched parser"}}"#, - r#"{"type":"item.completed","item":{"type":"reasoning","text":"secret thoughts"}}"#, - ]); - assert_eq!(last_activity(f.path()).as_deref(), Some("Patched parser")); - } - - #[test] - fn claude_command_ignores_repository_settings() { - let cmd = command( - &AgentPrograms::default(), - Launch { - agent: TaskAgent::ClaudeCode, - permission: TaskPermission::Edits, - worktree: Path::new("/tmp"), - prompt: "hi", - resume_session: None, - model: None, - effort: None, - }, - ); - let args: Vec<_> = cmd - .get_args() - .map(|a| a.to_string_lossy().to_string()) - .collect(); - let at = args.iter().position(|a| a == "--setting-sources").unwrap(); - assert_eq!(args[at + 1], "user,local"); - assert!(args.iter().any(|a| a == "--strict-mcp-config")); - } - - #[test] - fn claude_command_never_bypasses_permissions() { - let cmd = command( - &AgentPrograms::default(), - Launch { - agent: TaskAgent::ClaudeCode, - permission: TaskPermission::Edits, - worktree: Path::new("/tmp"), - prompt: "hi", - resume_session: None, - model: None, - effort: None, - }, - ); - let args: Vec<_> = cmd - .get_args() - .map(|a| a.to_string_lossy().to_string()) - .collect(); - assert!(!args - .iter() - .any(|a| a.contains("dangerously") || a == "bypassPermissions")); - assert_eq!(args.last().map(String::as_str), Some("hi")); - } - - #[test] - fn model_and_effort_flags_are_passed_before_the_prompt() { - let claude = args(Launch { - agent: TaskAgent::ClaudeCode, - permission: TaskPermission::Edits, - worktree: Path::new("/w"), - prompt: "do it", - resume_session: None, - model: Some("opus"), - effort: Some(TaskEffort::High), - }); - let model = claude.iter().position(|x| x == "--model").unwrap(); - assert_eq!(claude[model + 1], "opus"); - let effort = claude.iter().position(|x| x == "--effort").unwrap(); - assert_eq!(claude[effort + 1], "high"); - assert_eq!(&claude[claude.len() - 2..], ["--", "do it"]); - - let codex = args(Launch { - agent: TaskAgent::Codex, - permission: TaskPermission::Edits, - worktree: Path::new("/w"), - prompt: "do it", - resume_session: None, - model: Some("gpt-6-astra"), - effort: Some(TaskEffort::Max), - }); - let model = codex.iter().position(|x| x == "--model").unwrap(); - assert_eq!(codex[model + 1], "gpt-6-astra"); - let cfg = codex.iter().position(|x| x == "-c").unwrap(); - assert_eq!(codex[cfg + 1], "model_reasoning_effort=\"high\""); - assert_eq!(&codex[codex.len() - 2..], ["--", "do it"]); - } - - #[test] - fn parse_model_rejects_flag_like_values() { - assert_eq!(parse_model(None).unwrap(), None); - assert_eq!(parse_model(Some(" ")).unwrap(), None); - assert_eq!(parse_model(Some("opus")).unwrap().as_deref(), Some("opus")); - assert_eq!( - parse_model(Some("claude-fable-5")).unwrap().as_deref(), - Some("claude-fable-5") - ); - assert!(parse_model(Some("--dangerously-skip-permissions")).is_err()); - assert!(parse_model(Some("opus 5")).is_err()); - assert!(parse_model(Some("../evil")).is_err()); - } -} diff --git a/crates/lore-orchestrator/src/git.rs b/crates/lore-orchestrator/src/git.rs deleted file mode 100644 index 77afb51..0000000 --- a/crates/lore-orchestrator/src/git.rs +++ /dev/null @@ -1,515 +0,0 @@ -//! Minimal git CLI wrapper for worktree management. -//! -//! Every invocation disables hooks and terminal prompts so a repository's own -//! config cannot run code or block on credentials while Lore manages worktrees. - -use std::collections::HashSet; -use std::fs; -use std::path::{Component, Path, PathBuf}; -use std::process::Command; - -use crate::{Error, Result}; - -fn git(dir: &Path) -> Command { - let mut cmd = Command::new("git"); - cmd.arg("-C") - .arg(dir) - .args([ - "-c", - "core.hooksPath=/dev/null", - "-c", - "core.fsmonitor=false", - ]) - .env("GIT_TERMINAL_PROMPT", "0") - .env("GIT_OPTIONAL_LOCKS", "0"); - cmd -} - -fn run(dir: &Path, args: &[&str]) -> Result { - run_ok_codes(dir, args, &[0]) -} - -/// Like `run_ok_codes`, but never reads more than `max` bytes of stdout. Returns -/// the (possibly cut) output and whether more was available. -fn run_capped(dir: &Path, args: &[&str], ok: &[i32], max: usize) -> Result<(String, bool)> { - use std::io::Read; - let mut child = git(dir) - .args(args) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) - .spawn() - .map_err(|e| Error::Git(format!("could not run git: {e}")))?; - let mut buf = Vec::new(); - let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); - if let Some(stdout) = child.stdout.take() { - let _ = stdout.take(limit).read_to_end(&mut buf); - } - let cut = buf.len() > max; - if cut { - // Stop git instead of letting it block on a full pipe. - let _ = child.kill(); - let _ = child.wait(); - buf.truncate(max); - } else { - let status = child - .wait() - .map_err(|e| Error::Git(format!("could not run git: {e}")))?; - if !status.code().is_some_and(|c| ok.contains(&c)) { - return Err(Error::Git(format!( - "git {} failed", - args.first().copied().unwrap_or_default() - ))); - } - } - let mut text = String::from_utf8_lossy(&buf).into_owned(); - if cut { - // from_utf8_lossy may have replaced a split trailing character; fine. - while text.len() > max { - text.pop(); - } - } - Ok((text, cut)) -} - -fn run_ok_codes(dir: &Path, args: &[&str], ok: &[i32]) -> Result { - let out = git(dir) - .args(args) - .output() - .map_err(|e| Error::Git(format!("could not run git: {e}")))?; - if !out.status.code().is_some_and(|c| ok.contains(&c)) { - let stderr = String::from_utf8_lossy(&out.stderr); - return Err(Error::Git(format!( - "git {} failed: {}", - args.first().copied().unwrap_or_default(), - stderr.trim() - ))); - } - Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string()) -} - -/// Resolve the top-level directory of the repository containing `path`. -pub fn toplevel(path: &Path) -> Result { - run(path, &["rev-parse", "--show-toplevel"]).map(PathBuf::from) -} - -/// Full SHA of `HEAD` in `repo`. -pub fn head_commit(repo: &Path) -> Result { - run(repo, &["rev-parse", "--verify", "HEAD^{commit}"]) -} - -/// Whether a local branch named `branch` exists. -pub fn branch_exists(repo: &Path, branch: &str) -> bool { - run( - repo, - &[ - "show-ref", - "--verify", - "--quiet", - &format!("refs/heads/{branch}"), - ], - ) - .is_ok() -} - -/// Create `path` as a new worktree on new branch `branch` starting at `base`. -pub fn add_worktree(repo: &Path, path: &Path, branch: &str, base: &str) -> Result<()> { - let path = path - .to_str() - .ok_or_else(|| Error::Git("worktree path is not valid UTF-8".into()))?; - run(repo, &["worktree", "add", "-b", branch, path, base]).map(|_| ()) -} - -/// Remove the registered worktree at `path`, discarding its changes. -pub fn remove_worktree(repo: &Path, path: &Path) -> Result<()> { - let path = path - .to_str() - .ok_or_else(|| Error::Git("worktree path is not valid UTF-8".into()))?; - run(repo, &["worktree", "remove", "--force", path]).map(|_| ()) -} - -/// Drop metadata for worktrees whose directories no longer exist. -pub fn prune_worktrees(repo: &Path) -> Result<()> { - run(repo, &["worktree", "prune"]).map(|_| ()) -} - -/// Delete local branch `branch`, even if unmerged. -pub fn delete_branch(repo: &Path, branch: &str) -> Result<()> { - if branch_exists(repo, branch) { - run(repo, &["branch", "-D", branch])?; - } - Ok(()) -} - -/// Commits reachable from `HEAD` in `worktree` but not from `base`. -pub fn commits_ahead(worktree: &Path, base: &str) -> i64 { - run(worktree, &["rev-list", "--count", &format!("{base}..HEAD")]) - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(0) -} - -/// Paths changed relative to `base`: committed, staged, unstaged, and untracked. -/// Returns at most `cap` paths plus the total count. -pub fn changed_files(worktree: &Path, base: &str, cap: usize) -> (Vec, usize) { - let mut files: Vec = Vec::new(); - for args in [ - &["diff", "--name-only", "-z", base][..], - &["ls-files", "-z", "--others", "--exclude-standard"][..], - ] { - if let Ok(out) = run(worktree, args) { - files.extend(out.split('\0').map(str::to_string)); - } - } - files.retain(|f| !f.is_empty()); - files.sort(); - files.dedup(); - let total = files.len(); - files.truncate(cap); - (files, total) -} - -/// Unified diff of `worktree` against `base`, including untracked files as -/// additions. Never buffers more than `max_bytes` of git output. -pub fn diff_against(worktree: &Path, base: &str, max_bytes: usize) -> Result<(String, bool)> { - let (mut text, mut truncated) = run_capped( - worktree, - &["diff", "--no-color", "--no-ext-diff", base, "--"], - &[0], - max_bytes, - )?; - let untracked = run( - worktree, - &["ls-files", "-z", "--others", "--exclude-standard"], - )?; - for file in untracked.split('\0').filter(|f| !f.is_empty()) { - let remaining = max_bytes.saturating_sub(text.len() + 1); - if truncated || remaining == 0 { - truncated = true; - break; - } - // `--no-index` exits 1 when the files differ, which is always here. - if let Ok((part, cut)) = run_capped( - worktree, - &["diff", "--no-color", "--no-index", "--", "/dev/null", file], - &[0, 1], - remaining, - ) { - if !text.is_empty() { - text.push('\n'); - } - text.push_str(&part); - truncated |= cut; - } - } - Ok((text, truncated)) -} - -/// `git log --oneline base..HEAD`, newest first, capped. -pub fn log_oneline(worktree: &Path, base: &str, max: usize) -> Vec { - run( - worktree, - &[ - "log", - "--oneline", - "--no-decorate", - &format!("-{max}"), - &format!("{base}..HEAD"), - ], - ) - .map(|s| s.lines().map(str::to_string).collect()) - .unwrap_or_default() -} - -/// Directory names produced by common test/tooling runs, never user source. -fn is_generated_artifact_dir(name: &str) -> bool { - matches!( - name, - "__pycache__" - | ".pytest_cache" - | ".mypy_cache" - | ".ruff_cache" - | ".hypothesis" - | ".tox" - | ".nox" - | "htmlcov" - | ".coverage" - ) -} - -/// File suffixes produced by interpreters and coverage tools. -fn is_generated_artifact_file(name: &str) -> bool { - let lower = name.to_ascii_lowercase(); - lower.ends_with(".pyc") - || lower.ends_with(".pyo") - || lower == ".coverage" - || lower.starts_with(".coverage.") -} - -/// Whether a repository-relative porcelain path is a generated test artifact -/// (Python bytecode caches, pytest/mypy/ruff caches, coverage files). Paths -/// that escape the worktree with `..` or that are absolute are never treated -/// as artifacts, so they cannot be deleted by artifact cleanup. -pub fn is_generated_test_artifact(rel: &str) -> bool { - let path = Path::new(rel); - if path.is_absolute() || path.components().any(|c| matches!(c, Component::ParentDir)) { - return false; - } - let mut saw_normal = false; - for component in path.components() { - let Component::Normal(name) = component else { - return false; - }; - saw_normal = true; - let name = name.to_string_lossy(); - if is_generated_artifact_dir(&name) { - return true; - } - } - saw_normal - && path - .file_name() - .and_then(|n| n.to_str()) - .is_some_and(is_generated_artifact_file) -} - -fn path_is_inside(child: &Path, parent: &Path) -> bool { - child.starts_with(parent) && child != parent -} - -/// Delete `rel` only if it resolves inside `worktree`. Never follows a symlink -/// out of the worktree. -fn remove_inside_worktree(worktree: &Path, rel: &str) -> Result<()> { - let rel_path = Path::new(rel); - if rel_path.is_absolute() - || rel_path - .components() - .any(|c| matches!(c, Component::ParentDir | Component::RootDir)) - { - return Err(Error::Invalid( - "refusing to delete a path that is not repository-relative".into(), - )); - } - let full = worktree.join(rel_path); - if !full.exists() { - return Ok(()); - } - let real_wt = fs::canonicalize(worktree)?; - let real = fs::canonicalize(&full)?; - if !path_is_inside(&real, &real_wt) { - return Err(Error::Invalid( - "refusing to delete a path outside the task worktree".into(), - )); - } - if real.is_dir() { - fs::remove_dir_all(&real)?; - } else { - fs::remove_file(&real)?; - let mut dir = rel_path.parent(); - while let Some(parent) = dir { - let name = parent - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or_default(); - if !is_generated_artifact_dir(name) { - break; - } - let _ = fs::remove_dir(worktree.join(parent)); - dir = parent.parent(); - } - } - Ok(()) -} - -/// After a merge-queue test command, delete only newly appeared generated -/// artifacts. Paths present in `before` (including untracked user files) are -/// left untouched. This is not `git clean`. -pub fn discard_new_test_artifacts(worktree: &Path, before: &HashSet) -> Result<()> { - for path in uncommitted(worktree) { - if before.contains(&path) || !is_generated_test_artifact(&path) { - continue; - } - remove_inside_worktree(worktree, &path)?; - } - Ok(()) -} - -/// Paths with uncommitted changes (tracked or untracked). -pub fn uncommitted(worktree: &Path) -> Vec { - let Ok(out) = run( - worktree, - &["status", "--porcelain=v1", "-z", "--untracked-files=all"], - ) else { - return Vec::new(); - }; - parse_porcelain_z(&out) -} - -/// Parse `status --porcelain=v1 -z`. A rename or copy entry (`R`/`C`) is -/// followed by an extra NUL-separated field holding the original path. -fn parse_porcelain_z(out: &str) -> Vec { - let mut paths = Vec::new(); - let mut fields = out.split('\0'); - while let Some(entry) = fields.next() { - let (Some(status), Some(path)) = (entry.get(..2), entry.get(3..)) else { - continue; - }; - if !path.is_empty() { - paths.push(path.to_string()); - } - if status.contains('R') || status.contains('C') { - fields.next(); - } - } - paths -} - -/// Commits on `branch` since `base`. -pub fn branch_commits_ahead(repo: &Path, base: &str, branch: &str) -> i64 { - run( - repo, - &[ - "rev-list", - "--count", - &format!("{base}..refs/heads/{branch}"), - ], - ) - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(0) -} - -/// The branch checked out in `worktree`, if any. -pub fn checked_out_branch(worktree: &Path) -> Option { - run(worktree, &["symbolic-ref", "--quiet", "HEAD"]) - .ok() - .and_then(|r| r.strip_prefix("refs/heads/").map(str::to_string)) -} - -/// Whether a committer identity is configured for `repo`. -pub fn has_identity(repo: &Path) -> bool { - run(repo, &["var", "GIT_COMMITTER_IDENT"]).is_ok() -} - -/// Name of the checked-out branch, or an error when HEAD is detached. -pub fn current_branch(repo: &Path) -> Result { - run(repo, &["symbolic-ref", "--quiet", "--short", "HEAD"]) - .map_err(|_| Error::Invalid("the repository is not on a branch (detached HEAD)".into())) -} - -/// Stage everything in `worktree` and commit it. Returns false when there was -/// nothing to commit. -pub fn commit_all(worktree: &Path, message: &str) -> Result { - if uncommitted(worktree).is_empty() { - return Ok(false); - } - run(worktree, &["add", "-A"])?; - run(worktree, &["commit", "--no-verify", "-q", "-m", message])?; - Ok(true) -} - -/// Merge `branch` into the branch checked out at `repo`. -/// -/// On any failure the checkout is restored to its pre-merge HEAD with a clean -/// index and tree (`merge --abort`, falling back to `reset --merge`). Conflicts -/// are returned as data; other failures as errors. -pub fn merge_branch( - repo: &Path, - branch: &str, - message: &str, -) -> Result>> { - let head_before = run(repo, &["rev-parse", "HEAD"])?; - // Hooks and fsmonitor are off (see `git`). Repository-configured filters and - // merge drivers still apply; that trust boundary is documented in SECURITY.md. - let result = run( - repo, - &["merge", "--no-ff", "--no-verify", "-m", message, branch], - ); - match result { - Ok(_) => { - if run(repo, &["rev-parse", "HEAD"])? == head_before { - return Err(Error::Git("git reported nothing to merge".into())); - } - Ok(Ok(())) - } - Err(err) => { - let conflicts: Vec = run(repo, &["diff", "--name-only", "--diff-filter=U"]) - .map(|s| s.lines().map(str::to_string).collect()) - .unwrap_or_default(); - let head_now = run(repo, &["rev-parse", "HEAD"]).ok(); - // Only undo what this merge did: if HEAD moved, something else - // committed and resetting would discard it. - if head_now.as_deref() == Some(head_before.as_str()) - && run(repo, &["merge", "--abort"]).is_err() - { - let _ = run(repo, &["reset", "--merge", &head_before]); - } - let restored = run(repo, &["rev-parse", "HEAD"]).ok().as_deref() - == Some(head_before.as_str()) - && uncommitted(repo).is_empty(); - if !restored { - return Err(Error::Git(format!( - "the merge failed and your checkout could not be fully restored; check `git status` ({err})" - ))); - } - if conflicts.is_empty() { - Err(err) - } else { - Ok(Err(conflicts)) - } - } - } -} - -/// Merge `branch` into the branch checked out in `worktree`, bringing a task up -/// to date before it is tested and merged. A conflicting merge is aborted and -/// the conflicting paths returned. -pub fn merge_into_worktree( - worktree: &Path, - branch: &str, -) -> Result>> { - let message = format!("Update from {branch}"); - match run( - worktree, - &["merge", "--no-edit", "--no-verify", "-m", &message, branch], - ) { - Ok(_) => Ok(Ok(())), - Err(err) => { - let conflicts: Vec = run(worktree, &["diff", "--name-only", "--diff-filter=U"]) - .map(|s| s.lines().map(str::to_string).collect()) - .unwrap_or_default(); - let _ = run(worktree, &["merge", "--abort"]); - if conflicts.is_empty() { - Err(err) - } else { - Ok(Err(conflicts)) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::{is_generated_test_artifact, parse_porcelain_z}; - - #[test] - fn porcelain_handles_renames_and_non_ascii() { - let out = "R x\0\u{e9}\u{e9}.txt\0?? new.txt\0 M src/a.rs\0"; - assert_eq!(parse_porcelain_z(out), ["x", "new.txt", "src/a.rs"]); - assert!(parse_porcelain_z("\u{e9}\0").is_empty()); - } - - #[test] - fn generated_artifacts_are_caches_not_source() { - assert!(is_generated_test_artifact( - "__pycache__/mod.cpython-312.pyc" - )); - assert!(is_generated_test_artifact("pkg/__pycache__/x.pyc")); - assert!(is_generated_test_artifact(".pytest_cache/v/cache")); - assert!(is_generated_test_artifact("mod.pyc")); - assert!(!is_generated_test_artifact("calc.py")); - assert!(!is_generated_test_artifact("notes.txt")); - assert!(!is_generated_test_artifact("../__pycache__/x.pyc")); - assert!(!is_generated_test_artifact("/tmp/__pycache__/x.pyc")); - } -} diff --git a/crates/lore-orchestrator/src/lib.rs b/crates/lore-orchestrator/src/lib.rs deleted file mode 100644 index dd9cccd..0000000 --- a/crates/lore-orchestrator/src/lib.rs +++ /dev/null @@ -1,2431 +0,0 @@ -//! # lore-orchestrator -//! -//! Runs coding agents in parallel, each in its own Lore-owned git worktree -//! (ADR-0007, `docs/product/ORCHESTRATOR_PLAN.md`). -//! -//! Boundaries: -//! - Worktrees and logs live only under `/worktrees` and `/logs`; -//! discard refuses any path outside them and only deletes `lore/` branches. -//! - The user's primary checkout gains a branch plus worktree metadata; its -//! files change only through an explicit `merge_task`, which refuses on a -//! dirty checkout and restores it on any failure. -//! - Agents are launched without permission-bypass flags (see [`agent`]), each -//! as its own process group so stopping a task stops everything it started. -//! - No archive access and no network: this crate only spawns local processes. -//! - One orchestrator per root: `open` takes an exclusive lock on `/lock`. -//! -//! Locking: every method takes `&self`. The internal mutex guards only task -//! records and child handles. Git commands, process waits, and log parsing run -//! without it; a per-task busy marker keeps two operations off the same task. -//! Git-derived status comes from [`describe`] on a [`TaskSnapshot`]. - -pub mod agent; -mod git; -mod process; -pub mod workspace; - -use std::collections::{HashMap, HashSet}; -use std::fs::{self, File}; -use std::io::Write; -use std::os::unix::process::CommandExt; -use std::path::{Path, PathBuf}; -use std::process::{Child, Stdio}; -use std::sync::atomic::{AtomicU32, Ordering}; -use std::sync::{Mutex, MutexGuard}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -use lore_ipc::{ - ActivityDto, ActivityKind, ContinueTaskRequest, CreateTaskRequest, DecisionDto, MergeQueueDto, - MergeQueueItemDto, MergeResultDto, RepoSettingsDto, TaskAgent, TaskDiffDto, TaskDto, - TaskEffort, TaskOverlapDto, TaskPermission, TaskState, -}; -use serde::{Deserialize, Serialize}; - -pub use agent::AgentPrograms; - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("{0}")] - Invalid(String), - #[error("{0}")] - Git(String), - #[error("task not found")] - NotFound, - #[error("io: {0}")] - Io(#[from] std::io::Error), - #[error("task store could not be written: {0}")] - Store(#[from] serde_json::Error), -} - -pub type Result = std::result::Result; - -const STORE_FILE: &str = "tasks.json"; -const LOCK_FILE: &str = "lock"; -const DECISIONS_FILE: &str = "decisions.jsonl"; -const REPOS_FILE: &str = "repos.json"; -const QUEUES_FILE: &str = "queues.json"; -const QUEUE_CANCEL_WHY: &str = "Lore quit while this merge queue was running. Test processes were stopped. Remaining steps were not merged."; -const QUEUE_INTERRUPT_WHY: &str = "Lore exited while this merge queue was running, so it was interrupted. Test processes were stopped. Remaining steps were not merged."; -const MAX_TEST_COMMAND: usize = 2_000; -/// How long a merge-queue test command may run before it is stopped. -const DEFAULT_TEST_TIMEOUT: Duration = Duration::from_secs(30 * 60); -const MAX_DECISIONS: usize = 2_000; -const BRANCH_PREFIX: &str = "lore/"; -const MAX_TITLE: usize = 200; -const MAX_PROMPT: usize = 100_000; -const MAX_CHANGED_FILES: usize = 1_000; -/// How long helper processes may outlive a finished agent before SIGKILL. -const LINGER_GRACE: Duration = Duration::from_secs(3); - -/// A task as persisted on disk. -#[derive(Debug, Clone, Serialize, Deserialize)] -struct TaskRecord { - id: String, - title: String, - prompt: String, - agent: TaskAgent, - state: TaskState, - repo_path: PathBuf, - worktree_path: PathBuf, - branch: String, - base_commit: String, - log_path: PathBuf, - created_at_ms: i64, - exit_code: Option, - /// Agent pid, which is also its process-group id. - #[serde(default)] - pid: Option, - #[serde(default)] - permission: TaskPermission, - /// Model alias/id for `--model`, when the user picked one. - #[serde(default)] - model: Option, - #[serde(default)] - effort: Option, - /// Runs in this worktree: the first launch plus each continuation. - #[serde(default = "one")] - runs: u32, - /// Branch of the primary checkout this task was merged into. - #[serde(default)] - merged_into: Option, - /// Repository-relative files/folders this task owns. - #[serde(default)] - claims: Vec, - #[serde(default = "yes")] - auto_handoff: bool, - /// `runs` value at which an automatic handoff already happened. - #[serde(default)] - handoff_run: Option, - /// Automatic handoffs since the user last continued the task by hand. One - /// is allowed; if the other agent is also out of quota, the task waits. - #[serde(default)] - auto_handoffs: u32, -} - -fn yes() -> bool { - true -} - -fn one() -> u32 { - 1 -} - -/// A point-in-time copy of one task, safe to [`describe`] without holding the -/// orchestrator. -#[derive(Debug, Clone)] -pub struct TaskSnapshot(TaskRecord); - -impl TaskSnapshot { - pub fn id(&self) -> &str { - &self.0.id - } - pub fn worktree_path(&self) -> &Path { - &self.0.worktree_path - } -} - -struct Inner { - programs: AgentPrograms, - tasks: Vec, - children: HashMap, - /// Tasks with an operation in progress outside the lock. - busy: HashSet, - /// Process groups whose leader exited but which may still have helpers: - /// group id → deadline for SIGKILL. - lingering: HashMap, - /// Workspace roots the user opened (canonical). - opened_roots: HashSet, - /// Repositories with a merge in progress; git allows one at a time. - merging_repos: HashSet, - /// Set by `shutdown`; no new agent may start afterwards. - shutting_down: bool, - /// Merge queues by repository, including finished ones until replaced. - queues: HashMap, - /// Repositories whose running queue was asked to stop. - cancel_queues: HashSet, - /// Process groups of merge-queue test commands that are running, keyed by - /// repository so a relaunch can stop an orphaned test. - queue_tests: HashMap, - test_timeout: Duration, - load_warning: Option, -} - -/// Owns task records, their worktrees, and the agent processes it started. -pub struct Orchestrator { - root: PathBuf, - inner: Mutex, - _instance: InstanceLock, -} - -/// One orchestrator per root, across and within processes. -/// -/// Within a process, a registry of open roots. Across processes, `/lock` -/// records the owner's pid; a live owner running the same executable blocks -/// opening. (An `flock` was tried first, but descriptors inherited by children -/// forked elsewhere in the process kept it held after the owner closed it.) -struct InstanceLock { - root: PathBuf, -} - -fn open_roots() -> &'static Mutex> { - static ROOTS: std::sync::OnceLock>> = std::sync::OnceLock::new(); - ROOTS.get_or_init(|| Mutex::new(HashSet::new())) -} - -impl InstanceLock { - fn acquire(root: &Path) -> Result { - let already = || { - Error::Invalid("Lore is already running (another window holds the task list)".into()) - }; - let root = fs::canonicalize(root)?; - let lock_path = root.join(LOCK_FILE); - if let Some(pid) = fs::read_to_string(&lock_path) - .ok() - .and_then(|s| s.trim().parse::().ok()) - { - let me = std::process::id(); - if pid != me && process::group_alive_pid(pid) { - if let Ok(exe) = std::env::current_exe() { - if process::runs_program(pid, &exe) { - return Err(already()); - } - } - } - } - { - let mut roots = open_roots().lock().unwrap_or_else(|p| p.into_inner()); - if !roots.insert(root.clone()) { - return Err(already()); - } - } - if let Err(e) = fs::write(&lock_path, std::process::id().to_string()) { - open_roots() - .lock() - .unwrap_or_else(|p| p.into_inner()) - .remove(&root); - return Err(e.into()); - } - Ok(Self { root }) - } -} - -impl Drop for InstanceLock { - fn drop(&mut self) { - let _ = fs::remove_file(self.root.join(LOCK_FILE)); - open_roots() - .lock() - .unwrap_or_else(|p| p.into_inner()) - .remove(&self.root); - } -} - -/// Marks a repository's merge queue finished when its runner ends, however it ends. -struct QueueFinish<'a> { - orchestrator: &'a Orchestrator, - repo: PathBuf, -} - -impl Drop for QueueFinish<'_> { - fn drop(&mut self) { - let mut inner = self.orchestrator.lock(); - inner.cancel_queues.remove(&self.repo); - if let Some(q) = inner.queues.get_mut(&self.repo) { - q.running = false; - for item in &mut q.items { - if matches!( - item.status.as_str(), - "pending" | "updating" | "testing" | "merging" - ) { - item.status = "cancelled".into(); - } - } - } - let _ = self.orchestrator.save_queues(&inner); - } -} - -/// Releases a repository's merge slot when a merge ends, however it ends. -struct RepoMerge<'a> { - orchestrator: &'a Orchestrator, - repo: PathBuf, -} - -impl Drop for RepoMerge<'_> { - fn drop(&mut self) { - self.orchestrator.lock().merging_repos.remove(&self.repo); - } -} - -/// Clears a task's busy marker when an operation ends, however it ends. -struct Busy<'a> { - orchestrator: &'a Orchestrator, - id: String, -} - -impl Drop for Busy<'_> { - fn drop(&mut self) { - let mut inner = self.orchestrator.lock(); - inner.busy.remove(&self.id); - } -} - -impl Orchestrator { - /// Open (or create) the orchestrator rooted at `root`. - /// - /// Fails if another process already has this root open. Never fails because - /// of a damaged store: an unreadable `tasks.json` is moved aside and reported - /// via [`Orchestrator::load_warning`]. Tasks that were running when the - /// previous process exited become `Interrupted`, and any of their agents - /// still alive are stopped. - pub fn open(root: impl Into, programs: AgentPrograms) -> Result { - let root = root.into(); - fs::create_dir_all(root.join("worktrees"))?; - fs::create_dir_all(root.join("logs"))?; - let instance = InstanceLock::acquire(&root)?; - - let store = root.join(STORE_FILE); - let mut load_warning = None; - let mut tasks: Vec = match fs::read(&store) { - Ok(bytes) => match serde_json::from_slice(&bytes) { - Ok(tasks) => tasks, - Err(e) => { - let aside = root.join(format!("{STORE_FILE}.corrupt-{}", now_ms())); - let _ = fs::rename(&store, &aside); - load_warning = Some(format!( - "The task list could not be read ({e}) and was moved to {}. \ - Worktrees on disk were left untouched.", - aside.display() - )); - Vec::new() - } - }, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(), - Err(e) => return Err(e.into()), - }; - - let mut changed = false; - for task in &mut tasks { - if task.state == TaskState::Running { - if let Some(pid) = task.pid { - if process::group_alive(pid) - && process::runs_program(pid, program_for(&programs, task.agent)) - { - process::stop_group(pid, None); - } - } - task.state = TaskState::Interrupted; - changed = true; - } - } - - let mut queue_warning = None; - let (mut queues, mut leftover_tests) = match load_queues_file(&root) { - Ok(loaded) => loaded, - Err(msg) => { - queue_warning = Some(msg); - (HashMap::new(), HashMap::new()) - } - }; - let mut queue_events = Vec::new(); - for (repo, queue) in &mut queues { - if let Some(pgid) = leftover_tests.remove(repo) { - if process::group_alive(pgid) { - process::stop_group(pgid, None); - } - } - if queue.running { - mark_queue_stopped(queue, "interrupted", QUEUE_INTERRUPT_WHY); - queue_events.push(queue.clone()); - } - } - if load_warning.is_none() { - load_warning = queue_warning; - } - - let orchestrator = Self { - root, - inner: Mutex::new(Inner { - programs, - tasks, - children: HashMap::new(), - busy: HashSet::new(), - lingering: HashMap::new(), - opened_roots: HashSet::new(), - merging_repos: HashSet::new(), - shutting_down: false, - queues, - cancel_queues: HashSet::new(), - queue_tests: HashMap::new(), - test_timeout: DEFAULT_TEST_TIMEOUT, - load_warning, - }), - _instance: instance, - }; - if changed { - let inner = orchestrator.lock(); - orchestrator.save(&inner)?; - } - if !queue_events.is_empty() { - let inner = orchestrator.lock(); - let _ = orchestrator.save_queues(&inner); - drop(inner); - for queue in &queue_events { - orchestrator.record_queue_event(queue, "queue_interrupted", QUEUE_INTERRUPT_WHY); - } - } - Ok(orchestrator) - } - - fn lock(&self) -> MutexGuard<'_, Inner> { - // A panic while holding the lock leaves records consistent enough to - // keep serving; recover rather than failing every later call. - self.inner.lock().unwrap_or_else(|p| p.into_inner()) - } - - /// Why previously saved tasks could not be loaded, if that happened. - pub fn load_warning(&self) -> Option { - self.lock().load_warning.clone() - } - - /// Replace the programs used for tasks launched from now on. - pub fn set_programs(&self, programs: AgentPrograms) { - self.lock().programs = programs; - } - - /// Record a workspace root the user opened, allowing it to be browsed. - /// Returns the repository top-level. - pub fn open_workspace(&self, path: &str) -> Result { - let top = workspace::repository_root(path)?; - self.lock().opened_roots.insert(top.clone()); - Ok(top) - } - - /// Validate a root the webview wants to browse: an opened workspace, the - /// repository of a task, or a task worktree. Returns the canonical root. - pub fn browsable_root(&self, root: &str) -> Result { - let canonical = workspace::workspace_root(root)?; - let inner = self.lock(); - let allowed = inner.opened_roots.contains(&canonical) - || inner.tasks.iter().any(|t| { - [&t.repo_path, &t.worktree_path] - .iter() - .any(|p| fs::canonicalize(p).is_ok_and(|p| p == canonical)) - }); - if allowed { - Ok(canonical) - } else { - Err(Error::Invalid( - "open this folder in Lore before browsing it".into(), - )) - } - } - - fn mark_busy(&self, inner: &mut Inner, id: &str) -> Result> { - if !inner.busy.insert(id.to_string()) { - return Err(Error::Invalid( - "another action on this task is still in progress".into(), - )); - } - Ok(Busy { - orchestrator: self, - id: id.to_string(), - }) - } - - /// Create a worktree for the request and launch its agent there. - pub fn create_task(&self, req: &CreateTaskRequest) -> Result { - let title = req.title.trim(); - let prompt = req.prompt.trim(); - if title.is_empty() - || title.chars().count() > MAX_TITLE - || title.chars().any(char::is_control) - { - return Err(Error::Invalid( - "title must be 1-200 printable characters".into(), - )); - } - if prompt.is_empty() || prompt.len() > MAX_PROMPT { - return Err(Error::Invalid( - "prompt must be non-empty and under 100 KB".into(), - )); - } - let requested = PathBuf::from(&req.repo_path); - if !requested.is_absolute() || !requested.is_dir() { - return Err(Error::Invalid( - "repository path must be an existing absolute directory".into(), - )); - } - - let claims = normalize_claims(req.claims.as_deref().unwrap_or(&[]))?; - let model = agent::parse_model(req.model.as_deref())?; - - // Git work, without the lock. - let repo = git::toplevel(&requested).map_err(workspace::not_a_git_repository)?; - let base_commit = git::head_commit(&repo)?; - let created_at_ms = now_ms(); - let id = new_id(created_at_ms); - let worktree_path = self.root.join("worktrees").join(&id); - let log_path = self.root.join("logs").join(format!("{id}.log")); - let preferred = format!("{BRANCH_PREFIX}{}", slug(title)); - let fallback = format!("{preferred}-{id}"); - let branch = if !git::branch_exists(&repo, &preferred) - && git::add_worktree(&repo, &worktree_path, &preferred, &base_commit).is_ok() - { - preferred - } else { - git::add_worktree(&repo, &worktree_path, &fallback, &base_commit)?; - fallback - }; - - let mut record = TaskRecord { - id: id.clone(), - title: title.to_string(), - prompt: prompt.to_string(), - agent: req.agent, - state: TaskState::Running, - repo_path: repo, - worktree_path, - branch, - base_commit, - log_path, - created_at_ms, - exit_code: None, - pid: None, - permission: req.permission.unwrap_or_default(), - model, - effort: req.effort, - runs: 1, - merged_into: None, - claims: claims.clone(), - auto_handoff: req.auto_handoff.unwrap_or(true), - handoff_run: None, - auto_handoffs: 0, - }; - - let mut inner = self.lock(); - if inner.shutting_down { - drop(inner); - let _ = git::remove_worktree(&record.repo_path, &record.worktree_path); - let _ = git::delete_branch(&record.repo_path, &record.branch); - return Err(Error::Invalid("Lore is quitting".into())); - } - let opening = format!( - "{}{}", - coordination_preamble(&inner.tasks, &record), - record.prompt - ); - match spawn(&inner.programs, &record, &opening, None) { - Ok(child) => { - record.pid = Some(child.id()); - inner.children.insert(id.clone(), child); - } - Err(e) => { - record.state = TaskState::Failed; - let _ = fs::write( - &record.log_path, - format!("Lore could not start the agent: {e}\n"), - ); - } - } - if let Ok(top) = fs::canonicalize(&record.repo_path) { - inner.opened_roots.insert(top); - } - inner.tasks.push(record.clone()); - if let Err(e) = self.save(&inner) { - // Without a record nothing could ever clean this up, so roll back. - drop(inner); - let _ = self.discard_task(&id); - return Err(e); - } - let created = inner.tasks.last().cloned().ok_or(Error::NotFound)?; - drop(inner); - self.record_decision( - &created, - "created", - &format!( - "{} on {} (owns: {})", - agent_name(created.agent), - created.branch, - if created.claims.is_empty() { - "nothing declared".to_string() - } else { - created.claims.join(", ") - } - ), - ); - Ok(TaskSnapshot(created)) - } - - /// Send more work to a task that is not running, in the same worktree. - /// - /// Keeping the agent resumes its own session when it supports that (Claude); - /// otherwise, including every switch of agent (a handoff), the new run gets a - /// brief built from the task's commits, changes, and recent activity. - pub fn continue_task(&self, req: &ContinueTaskRequest) -> Result { - self.continue_task_inner(req, false) - } - - fn continue_task_inner( - &self, - req: &ContinueTaskRequest, - automatic: bool, - ) -> Result { - let prompt = req.prompt.trim(); - if prompt.is_empty() || prompt.len() > MAX_PROMPT { - return Err(Error::Invalid( - "prompt must be non-empty and under 100 KB".into(), - )); - } - let (task, _busy) = { - let mut inner = self.lock(); - self.reap(&mut inner)?; - let task = inner.task(&req.id)?.clone(); - ensure_idle(&inner, &task)?; - if task.merged_into.is_some() { - return Err(Error::Invalid("the task was already merged".into())); - } - let busy = self.mark_busy(&mut inner, &req.id)?; - (task, busy) - }; - if !task.worktree_path.is_dir() { - return Err(Error::Invalid( - "the task's worktree no longer exists".into(), - )); - } - - // Log parsing and git for the brief, without the lock. - let new_agent = req.agent.unwrap_or(task.agent); - let next_model = match &req.model { - Some(raw) => agent::parse_model(Some(raw))?, - None => task.model.clone(), - }; - let next_effort = req.effort.or(task.effort); - let same_setup = - new_agent == task.agent && next_model == task.model && next_effort == task.effort; - let session = (same_setup && new_agent == TaskAgent::ClaudeCode) - .then(|| agent::claude_session_id(&task.log_path)) - .flatten(); - let full_prompt = match session { - Some(_) => prompt.to_string(), - None => handoff_brief( - &task, - prompt, - &self.decisions(Some(&task.repo_path.display().to_string()), 15), - ), - }; - - let mut inner = self.lock(); - if inner.shutting_down { - return Err(Error::Invalid("Lore is quitting".into())); - } - let index = inner.index(&req.id)?; - let previous = inner.tasks[index].clone(); - let mut record = previous.clone(); - record.agent = new_agent; - record.model = next_model; - record.effort = next_effort; - record.runs += 1; - if !automatic { - record.auto_handoffs = 0; - } - record.exit_code = None; - let child = match spawn(&inner.programs, &record, &full_prompt, session.as_deref()) { - Ok(child) => { - record.pid = Some(child.id()); - record.state = TaskState::Running; - Some(child) - } - Err(e) => { - record.state = TaskState::Failed; - record.pid = None; - let _ = fs::OpenOptions::new() - .append(true) - .open(&record.log_path) - .and_then(|mut f| writeln!(f, "Lore could not start the agent: {e}")); - None - } - }; - inner.tasks[index] = record; - if let Err(e) = self.save(&inner) { - // Keep memory and disk in agreement: undo the run. - if let Some(mut child) = child { - let pgid = child.id(); - process::stop_group(pgid, Some(&mut child)); - } - inner.tasks[index] = previous; - return Err(e); - } - if let Some(child) = child { - inner.children.insert(req.id.clone(), child); - } - let updated = inner.tasks[index].clone(); - drop(inner); - self.record_decision( - &updated, - if req.agent.is_some_and(|a| a != previous.agent) { - "handoff" - } else { - "continued" - }, - &format!("run {} with {}", updated.runs, agent_name(updated.agent)), - ); - Ok(TaskSnapshot(updated)) - } - - /// Commit every uncommitted change in the task's worktree. - pub fn commit_task(&self, id: &str, message: &str) -> Result { - self.commit_task_forced(id, message, false) - } - - /// As [`Orchestrator::commit_task`], but `force` commits even when the task - /// changed files another unmerged task claims. - pub fn commit_task_forced(&self, id: &str, message: &str, force: bool) -> Result { - let (task, others, _busy) = { - let mut inner = self.lock(); - self.reap(&mut inner)?; - let task = inner.task(id)?.clone(); - ensure_idle(&inner, &task)?; - let busy = self.mark_busy(&mut inner, id)?; - let others: Vec<(String, Vec)> = inner - .tasks - .iter() - .filter(|t| { - t.id != task.id && t.repo_path == task.repo_path && t.merged_into.is_none() - }) - .map(|t| (t.title.clone(), t.claims.clone())) - .collect(); - (task, others, busy) - }; - if !force { - let (changed, _) = - git::changed_files(&task.worktree_path, &task.base_commit, usize::MAX); - let mut trespass: Vec = Vec::new(); - for (title, claims) in &others { - for file in &changed { - if claims.iter().any(|c| claim_covers(c, file)) { - trespass.push(format!("{file} (owned by \"{title}\")")); - } - } - } - if !trespass.is_empty() { - trespass.sort(); - trespass.dedup(); - trespass.truncate(20); - return Err(Error::Invalid(format!( - "this task changed files another agent owns: {}. Review them, then commit \ - again with force if that is intended.", - trespass.join(", ") - ))); - } - } - let message = message.trim(); - let message = if message.is_empty() { - task.title.clone() - } else { - message.to_string() - }; - if message.len() > 10_000 { - return Err(Error::Invalid("commit message is too long".into())); - } - if !git::commit_all(&task.worktree_path, &message)? { - return Err(Error::Invalid("there is nothing to commit".into())); - } - self.record_decision(&task, "committed", &message); - Ok(TaskSnapshot(task)) - } - - /// Merge the task's branch into the branch checked out in its repository. - /// - /// Refuses while the agent runs, while the worktree has uncommitted changes, - /// or while the primary checkout has uncommitted changes. A conflicting merge - /// is aborted, leaving the checkout exactly as it was. - pub fn merge_task(&self, id: &str) -> Result { - let (task, _busy) = { - let mut inner = self.lock(); - self.reap(&mut inner)?; - let task = inner.task(id)?.clone(); - ensure_idle(&inner, &task)?; - if let Some(into) = &task.merged_into { - return Err(Error::Invalid(format!( - "the task was already merged into {into}" - ))); - } - if !inner.merging_repos.insert(task.repo_path.clone()) { - return Err(Error::Invalid( - "another merge into this repository is in progress".into(), - )); - } - let busy = self.mark_busy(&mut inner, id); - let busy = match busy { - Ok(b) => b, - Err(e) => { - inner.merging_repos.remove(&task.repo_path); - return Err(e); - } - }; - (task, busy) - }; - let _merging = RepoMerge { - orchestrator: self, - repo: task.repo_path.clone(), - }; - self.merge_prepared(&task) - } - - /// The merge itself, for a caller that already holds the task's busy marker - /// and the repository's merge slot. - fn merge_prepared(&self, task: &TaskRecord) -> Result { - let id = task.id.as_str(); - if !task.branch.starts_with(BRANCH_PREFIX) { - return Err(Error::Invalid( - "refusing to merge a branch Lore did not create".into(), - )); - } - if git::checked_out_branch(&task.worktree_path).as_deref() != Some(task.branch.as_str()) { - return Err(Error::Invalid(format!( - "the task's worktree is no longer on {}; check out that branch there first", - task.branch - ))); - } - if !git::uncommitted(&task.worktree_path).is_empty() { - return Err(Error::Invalid( - "the task has uncommitted changes; commit them first".into(), - )); - } - let into = git::current_branch(&task.repo_path)?; - let dirty = git::uncommitted(&task.repo_path); - if !dirty.is_empty() { - return Err(Error::Invalid(format!( - "your checkout on {into} has uncommitted changes ({} files); commit or stash them first", - dirty.len() - ))); - } - if git::branch_commits_ahead(&task.repo_path, &task.base_commit, &task.branch) == 0 { - return Err(Error::Invalid("the task has no commits to merge".into())); - } - if !git::has_identity(&task.repo_path) { - return Err(Error::Invalid( - "git has no user.name/user.email configured for this repository".into(), - )); - } - let message = format!("Merge {} ({})", task.branch, task.title); - match git::merge_branch(&task.repo_path, &task.branch, &message)? { - Ok(()) => { - let mut inner = self.lock(); - let mut note = String::new(); - if let Ok(index) = inner.index(id) { - inner.tasks[index].merged_into = Some(into.clone()); - // The merge already happened in git; a failed save must not - // turn it into an error the user might retry. - if let Err(e) = self.save(&inner) { - note = format!(" (Lore could not save this: {e})"); - } - } - self.record_decision(task, "merged", &format!("into {into}")); - Ok(MergeResultDto { - merged: true, - message: format!("Merged {} into {into}.{note}", task.branch), - into_branch: into, - conflicts: Vec::new(), - }) - } - Err(conflicts) => { - self.record_decision( - task, - "merge_conflict", - &format!("into {into}: {}", conflicts.join(", ")), - ); - Ok(MergeResultDto { - merged: false, - message: format!( - "Merging {} into {into} conflicts in {} files; nothing was changed.", - task.branch, - conflicts.len() - ), - into_branch: into, - conflicts, - }) - } - } - } - - fn load_repo_settings(&self) -> HashMap { - fs::read(self.root.join(REPOS_FILE)) - .ok() - .and_then(|b| serde_json::from_slice(&b).ok()) - .unwrap_or_default() - } - - /// Settings Lore keeps for a repository (any path inside it). - pub fn repo_settings(&self, repo_path: &str) -> Result { - let top = workspace::repository_root(repo_path)?; - let key = top.display().to_string(); - Ok(self - .load_repo_settings() - .remove(&key) - .unwrap_or(RepoSettingsDto { - repo_path: key, - test_command: None, - })) - } - - /// Set or clear the command the merge queue runs before merging a task. - pub fn set_repo_test_command( - &self, - repo_path: &str, - command: Option<&str>, - ) -> Result { - let top = workspace::repository_root(repo_path)?; - let key = top.display().to_string(); - let command = command.map(str::trim).filter(|c| !c.is_empty()); - if let Some(c) = command { - if c.len() > MAX_TEST_COMMAND || c.contains('\0') { - return Err(Error::Invalid(format!( - "test command must be under {MAX_TEST_COMMAND} characters" - ))); - } - } - // One writer at a time for the settings file. - let _guard = self.lock(); - let mut all = self.load_repo_settings(); - let settings = RepoSettingsDto { - repo_path: key.clone(), - test_command: command.map(str::to_string), - }; - all.insert(key, settings.clone()); - let tmp = self.root.join(format!("{REPOS_FILE}.tmp")); - fs::write(&tmp, serde_json::to_vec_pretty(&all)?)?; - fs::rename(tmp, self.root.join(REPOS_FILE))?; - Ok(settings) - } - - /// Override the merge-queue test timeout (tests use a short one). - pub fn set_test_timeout(&self, timeout: Duration) { - self.lock().test_timeout = timeout; - } - - /// The latest merge queue for a repository, including one restored after relaunch. - pub fn merge_queue(&self, repo_path: &str) -> Option { - let top = workspace::repository_root(repo_path).ok()?; - self.lock().queues.get(&top).cloned() - } - - /// Ask a running queue to stop after (or during) its current step. - pub fn cancel_merge_queue(&self, repo_path: &str) -> Result<()> { - let top = workspace::repository_root(repo_path)?; - let mut inner = self.lock(); - if inner.queues.get(&top).is_some_and(|q| q.running) { - inner.cancel_queues.insert(top); - } - Ok(()) - } - - /// Validate a queue and register it as running, holding the repository's - /// merge slot. Call [`Orchestrator::run_merge_queue`] next (on a thread). - pub fn start_merge_queue(&self, repo_path: &str, task_ids: &[String]) -> Result { - if task_ids.is_empty() { - return Err(Error::Invalid("choose at least one task to merge".into())); - } - let top = workspace::repository_root(repo_path)?; - let test_command = self.repo_settings(&top.display().to_string())?.test_command; - let mut inner = self.lock(); - self.reap(&mut inner)?; - let mut items = Vec::new(); - let mut selected = Vec::new(); - let mut seen = HashSet::new(); - for id in task_ids { - if !seen.insert(id.clone()) { - continue; - } - let task = inner.task(id)?; - let same_repo = fs::canonicalize(&task.repo_path).is_ok_and(|p| p == top); - if !same_repo { - return Err(Error::Invalid(format!( - "\"{}\" belongs to a different repository", - task.title - ))); - } - if task.merged_into.is_some() { - return Err(Error::Invalid(format!( - "\"{}\" is already merged", - task.title - ))); - } - ensure_idle(&inner, task)?; - items.push(MergeQueueItemDto { - task_id: id.clone(), - title: task.title.clone(), - status: "pending".into(), - detail: None, - }); - selected.push(task.clone()); - } - if let Some(msg) = overlapping_claim_error(&selected) { - return Err(Error::Invalid(msg)); - } - if inner.queues.get(&top).is_some_and(|q| q.running) { - return Err(Error::Invalid( - "a merge queue is already running for this repository".into(), - )); - } - if !inner.merging_repos.insert(top.clone()) { - return Err(Error::Invalid( - "another merge into this repository is in progress".into(), - )); - } - inner.cancel_queues.remove(&top); - let queue = MergeQueueDto { - repo_path: top.display().to_string(), - running: true, - test_command, - items, - }; - inner.queues.insert(top, queue.clone()); - self.save_queues(&inner)?; - Ok(queue) - } - - /// Run a started queue to completion: for each task in order, bring its - /// branch up to date with the target branch, run the repository's test - /// command in the task's worktree, then merge. Stops at the first failure; - /// later tasks are marked skipped. Blocking. - pub fn run_merge_queue(&self, repo_path: &str) -> MergeQueueDto { - let top = - workspace::repository_root(repo_path).unwrap_or_else(|_| PathBuf::from(repo_path)); - let _slot = RepoMerge { - orchestrator: self, - repo: top.clone(), - }; - // Clears `running` however this function ends, including a panic. - let _finish = QueueFinish { - orchestrator: self, - repo: top.clone(), - }; - let (ids, test_command, timeout) = { - let inner = self.lock(); - let Some(q) = inner.queues.get(&top) else { - return MergeQueueDto { - repo_path: top.display().to_string(), - running: false, - test_command: None, - items: Vec::new(), - }; - }; - ( - q.items - .iter() - .map(|i| i.task_id.clone()) - .collect::>(), - q.test_command.clone(), - inner.test_timeout, - ) - }; - - let mut stopped: Option<&str> = None; - for (index, id) in ids.iter().enumerate() { - if self.queue_cancelled(&top) { - self.set_queue_item(&top, index, "cancelled", None); - stopped = Some("the queue was cancelled"); - continue; - } - if let Some(why) = stopped { - self.set_queue_item(&top, index, "skipped", Some(why)); - continue; - } - match self.run_queue_item(&top, index, id, test_command.as_deref(), timeout) { - Ok(()) => {} - Err(reason) => { - let cancelled = self.queue_cancelled(&top); - self.set_queue_item( - &top, - index, - if cancelled { "cancelled" } else { "failed" }, - Some(&reason), - ); - stopped = Some(if cancelled { - "the queue was cancelled" - } else { - "an earlier task failed" - }); - } - } - } - - let mut inner = self.lock(); - inner.cancel_queues.remove(&top); - let queue = match inner.queues.get_mut(&top) { - Some(q) => { - q.running = false; - q.clone() - } - None => MergeQueueDto { - repo_path: top.display().to_string(), - running: false, - test_command, - items: Vec::new(), - }, - }; - let _ = self.save_queues(&inner); - queue - } - - /// Release a started queue whose runner could not be started. - pub fn abandon_merge_queue(&self, repo_path: &str) { - let top = - workspace::repository_root(repo_path).unwrap_or_else(|_| PathBuf::from(repo_path)); - drop(QueueFinish { - orchestrator: self, - repo: top.clone(), - }); - self.lock().merging_repos.remove(&top); - } - - fn queue_cancelled(&self, repo: &Path) -> bool { - self.lock().cancel_queues.contains(repo) - } - - fn set_queue_item(&self, repo: &Path, index: usize, status: &str, detail: Option<&str>) { - let mut inner = self.lock(); - if let Some(item) = inner - .queues - .get_mut(repo) - .and_then(|q| q.items.get_mut(index)) - { - // Quit/relaunch already recorded a durable outcome; don't replace it - // with the test command's SIGTERM as a failure. - if matches!(item.status.as_str(), "cancelled" | "interrupted") { - let _ = self.save_queues(&inner); - return; - } - item.status = status.to_string(); - item.detail = detail.map(|d| d.chars().take(2_000).collect()); - } - let _ = self.save_queues(&inner); - } - - /// One queue step. Returns a human-readable reason on failure. - fn run_queue_item( - &self, - repo: &Path, - index: usize, - id: &str, - test_command: Option<&str>, - timeout: Duration, - ) -> std::result::Result<(), String> { - let (task, _busy) = { - let mut inner = self.lock(); - let _ = self.reap(&mut inner); - let task = inner.task(id).map_err(|e| e.to_string())?.clone(); - ensure_idle(&inner, &task).map_err(|e| e.to_string())?; - if task.merged_into.is_some() { - return Err("already merged".into()); - } - let busy = self.mark_busy(&mut inner, id).map_err(|e| e.to_string())?; - (task, busy) - }; - if !git::uncommitted(&task.worktree_path).is_empty() { - return Err("the task has uncommitted changes; commit them first".into()); - } - // Never commit an update onto anything but the task's own branch. - if git::checked_out_branch(&task.worktree_path).as_deref() != Some(task.branch.as_str()) { - return Err(format!( - "the task's worktree is no longer on {}; check out that branch there first", - task.branch - )); - } - if git::branch_commits_ahead(&task.repo_path, &task.base_commit, &task.branch) == 0 { - return Err("the task has no commits to merge".into()); - } - - self.set_queue_item(repo, index, "updating", None); - // Pin the target: the branch and commit tested are the ones merged into. - let into = git::current_branch(&task.repo_path).map_err(|e| e.to_string())?; - let target = git::head_commit(&task.repo_path).map_err(|e| e.to_string())?; - match git::merge_into_worktree(&task.worktree_path, &target) { - Ok(Ok(())) => { - // Diffs, changed files, and claims stay about this task's own work. - let mut inner = self.lock(); - if let Ok(i) = inner.index(id) { - inner.tasks[i].base_commit = target.clone(); - let _ = self.save(&inner); - } - } - Ok(Err(conflicts)) => { - self.record_decision( - &task, - "queue_failed", - &format!("updating from {into} conflicts: {}", conflicts.join(", ")), - ); - return Err(format!( - "updating from {into} conflicts in {}; resolve it in the worktree or continue the agent", - conflicts.join(", ") - )); - } - Err(e) => return Err(format!("could not update from {into}: {e}")), - } - - let before_tests: HashSet = - git::uncommitted(&task.worktree_path).into_iter().collect(); - if let Some(command) = test_command { - self.set_queue_item(repo, index, "testing", Some(command)); - let log = self.root.join("logs").join(format!("{id}.test.log")); - let repo_key = repo.to_path_buf(); - let outcome = run_test_command( - &task.worktree_path, - command, - &log, - timeout, - || self.queue_cancelled(repo), - |pgid, running| { - let mut inner = self.lock(); - if running { - inner.queue_tests.insert(repo_key.clone(), pgid); - } else { - inner.queue_tests.remove(&repo_key); - } - let _ = self.save_queues(&inner); - }, - ); - let _ = git::discard_new_test_artifacts(&task.worktree_path, &before_tests); - if let Err(reason) = outcome { - if self.queue_cancelled(repo) { - return Err("cancelled".into()); - } - self.record_decision(&task, "queue_failed", &format!("tests: {reason}")); - return Err(format!("tests failed: {reason}")); - } - let leftover = git::uncommitted(&task.worktree_path); - if !leftover.is_empty() { - return Err(format!( - "the task has uncommitted changes after tests ({}); commit them first", - leftover.join(", ") - )); - } - } - - if self.queue_cancelled(repo) { - return Err("cancelled before merging".into()); - } - // The target branch must be the one that was tested. - let now = git::current_branch(&task.repo_path).map_err(|e| e.to_string())?; - let head = git::head_commit(&task.repo_path).map_err(|e| e.to_string())?; - if now != into || head != target { - return Err(format!( - "{into} changed while this task was being tested; run the queue again" - )); - } - self.set_queue_item(repo, index, "merging", None); - let mut updated = task.clone(); - updated.base_commit = target; - let result = self.merge_prepared(&updated).map_err(|e| e.to_string())?; - if result.merged { - self.set_queue_item(repo, index, "merged", Some(&result.message)); - Ok(()) - } else { - Err(result.message) - } - } - - /// Append one entry to the shared decision log. Best-effort: a task must - /// never fail because its history could not be written. - fn record_decision(&self, task: &TaskRecord, kind: &str, detail: &str) { - self.append_decision(DecisionDto { - at_ms: now_ms(), - task_id: task.id.clone(), - task_title: task.title.clone(), - repo_path: task.repo_path.display().to_string(), - kind: kind.to_string(), - detail: detail.chars().take(2_000).collect(), - }); - } - - fn record_queue_event(&self, queue: &MergeQueueDto, kind: &str, detail: &str) { - let (task_id, task_title) = queue - .items - .first() - .map(|item| (item.task_id.clone(), item.title.clone())) - .unwrap_or_else(|| (String::new(), "Merge queue".into())); - self.append_decision(DecisionDto { - at_ms: now_ms(), - task_id, - task_title, - repo_path: queue.repo_path.clone(), - kind: kind.to_string(), - detail: detail.chars().take(2_000).collect(), - }); - } - - fn append_decision(&self, entry: DecisionDto) { - if let (Ok(mut file), Ok(line)) = ( - fs::OpenOptions::new() - .create(true) - .append(true) - .open(self.root.join(DECISIONS_FILE)), - serde_json::to_string(&entry), - ) { - let _ = writeln!(file, "{line}"); - } - } - - fn save_queues(&self, inner: &Inner) -> Result<()> { - let mut map = HashMap::new(); - for (repo, queue) in &inner.queues { - map.insert( - repo.display().to_string(), - PersistentQueue { - queue: queue.clone(), - test_pgid: inner.queue_tests.get(repo).copied(), - }, - ); - } - let tmp = self.root.join(format!("{QUEUES_FILE}.tmp")); - { - let file = File::create(&tmp)?; - serde_json::to_writer_pretty(&file, &map)?; - file.sync_all()?; - } - fs::rename(tmp, self.root.join(QUEUES_FILE))?; - Ok(()) - } - - /// The most recent decisions, newest first, optionally for one repository. - pub fn decisions(&self, repo_path: Option<&str>, limit: usize) -> Vec { - let Ok(text) = fs::read_to_string(self.root.join(DECISIONS_FILE)) else { - return Vec::new(); - }; - let mut out: Vec = text - .lines() - .rev() - .filter_map(|l| serde_json::from_str::(l).ok()) - .filter(|d| repo_path.is_none_or(|r| d.repo_path == r)) - .take(limit.min(MAX_DECISIONS)) - .collect(); - out.sort_by_key(|d| std::cmp::Reverse(d.at_ms)); - out - } - - /// Cheap per-task change signals for the UI: state and how much output the - /// agent has produced. No git, no parsing. - pub fn change_signatures(&self) -> Vec<(String, TaskState, u64)> { - let mut inner = self.lock(); - let _ = self.reap(&mut inner); - inner - .tasks - .iter() - .map(|t| { - let len = fs::metadata(&t.log_path).map(|m| m.len()).unwrap_or(0); - (t.id.clone(), t.state, len) - }) - .collect() - } - - /// Hand tasks that stopped on a usage limit to the other agent, once per - /// run. Returns the ids that were handed off. - pub fn run_auto_handoffs(&self) -> Vec { - let candidates: Vec = { - let mut inner = self.lock(); - let _ = self.reap(&mut inner); - inner - .tasks - .iter() - .filter(|t| { - t.auto_handoff - && t.merged_into.is_none() - && matches!(t.state, TaskState::Failed | TaskState::Finished) - && t.handoff_run != Some(t.runs) - && t.auto_handoffs == 0 - && !inner.busy.contains(&t.id) - }) - .cloned() - .collect() - }; - let mut handed = Vec::new(); - for task in candidates { - if !agent::usage_limited(&task.log_path) { - continue; - } - let other = match task.agent { - TaskAgent::ClaudeCode => TaskAgent::Codex, - TaskAgent::Codex => TaskAgent::ClaudeCode, - }; - // Mark the attempt first: a failing handoff must not retry forever. - { - let mut inner = self.lock(); - if let Ok(index) = inner.index(&task.id) { - inner.tasks[index].handoff_run = Some(task.runs); - inner.tasks[index].auto_handoffs += 1; - let _ = self.save(&inner); - } - } - let request = ContinueTaskRequest { - id: task.id.clone(), - prompt: format!( - "The previous agent stopped because it hit its usage limit. \ - Continue this task from where it left off: {}", - task.title - ), - agent: Some(other), - model: None, - effort: None, - }; - if self.continue_task_inner(&request, true).is_ok() { - self.record_decision( - &task, - "auto_handoff", - &format!( - "{} hit its usage limit; handed off to {}", - agent_name(task.agent), - agent_name(other) - ), - ); - handed.push(task.id); - } - } - handed - } - - /// Refresh process states and snapshot every task, newest first. - pub fn snapshots(&self) -> Result> { - let mut inner = self.lock(); - self.reap(&mut inner)?; - let mut out: Vec = inner.tasks.iter().cloned().map(TaskSnapshot).collect(); - out.sort_by_key(|t| std::cmp::Reverse(t.0.created_at_ms)); - Ok(out) - } - - /// Refresh process states and snapshot one task. - pub fn snapshot(&self, id: &str) -> Result { - let mut inner = self.lock(); - self.reap(&mut inner)?; - inner.task(id).cloned().map(TaskSnapshot) - } - - /// Convenience for callers that do not care about lock scope (tests, CLI). - pub fn list_tasks(&self) -> Result> { - Ok(self.snapshots()?.iter().map(describe).collect()) - } - - /// Convenience: one task with git-derived status. - pub fn task(&self, id: &str) -> Result { - self.snapshot(id).map(|s| describe(&s)) - } - - /// Stop a running agent and everything it started. No-op otherwise. - pub fn stop_task(&self, id: &str) -> Result { - let target = { - let mut inner = self.lock(); - self.reap(&mut inner)?; - let index = inner.index(id)?; - if inner.tasks[index].state != TaskState::Running { - return Ok(TaskSnapshot(inner.tasks[index].clone())); - } - let child = inner.children.remove(id); - let task = &mut inner.tasks[index]; - task.state = TaskState::Stopped; - let target = (task.pid, task.agent, child, inner.programs.clone()); - let saved = self.save(&inner); - (target, saved) - }; - // Waiting for the process group happens without the lock. Stop the agent - // even if saving failed: an untracked agent must never keep running. - let ((pid, agent, mut child, programs), saved) = target; - kill_processes(pid, agent, child.as_mut(), &programs); - saved?; - self.snapshot(id) - } - - /// Stop the agent, remove the worktree and its branch, and forget the task. - /// Uncommitted and unmerged work in that worktree is lost. - pub fn discard_task(&self, id: &str) -> Result<()> { - let owned = self.root.join("worktrees"); - let (task, mut child, programs, _busy) = { - let mut inner = self.lock(); - let task = inner.task(id)?.clone(); - // Records are data on disk; never trust them to point somewhere safe. - if !is_strictly_inside(&task.worktree_path, &owned) - || task.worktree_path.file_name().and_then(|n| n.to_str()) != Some(task.id.as_str()) - { - return Err(Error::Invalid( - "refusing to remove a worktree outside Lore's directory".into(), - )); - } - if !task.branch.starts_with(BRANCH_PREFIX) || task.branch.len() <= BRANCH_PREFIX.len() { - return Err(Error::Invalid( - "refusing to delete a branch Lore did not create".into(), - )); - } - let busy = self.mark_busy(&mut inner, id)?; - let child = inner.children.remove(id); - (task, child, inner.programs.clone(), busy) - }; - - kill_processes(task.pid, task.agent, child.as_mut(), &programs); - let repo_exists = task.repo_path.is_dir(); - let removed_by_git = - repo_exists && git::remove_worktree(&task.repo_path, &task.worktree_path).is_ok(); - if !removed_by_git && task.worktree_path.exists() { - // Git no longer knows this worktree (or the repo is gone). Delete the - // directory ourselves, but only after resolving symlinks. - let real = fs::canonicalize(&task.worktree_path)?; - let real_owned = fs::canonicalize(&owned)?; - if !is_strictly_inside(&real, &real_owned) { - return Err(Error::Invalid( - "refusing to remove a worktree outside Lore's directory".into(), - )); - } - fs::remove_dir_all(&real)?; - } - let mut branch_note = String::new(); - if repo_exists { - let _ = git::prune_worktrees(&task.repo_path); - // A branch checked out elsewhere cannot be deleted; the task is still - // discarded, and the branch is left for the user. - if let Err(e) = git::delete_branch(&task.repo_path, &task.branch) { - branch_note = format!(" (branch kept: {e})"); - } - } - if is_strictly_inside(&task.log_path, &self.root.join("logs")) { - let _ = fs::remove_file(&task.log_path); - } - let mut inner = self.lock(); - if let Ok(index) = inner.index(id) { - inner.tasks.remove(index); - } - let result = self.save(&inner); - drop(inner); - self.record_decision(&task, "discarded", &format!("{}{branch_note}", task.branch)); - result - } - - /// Stop every running agent (called when the app quits). No git work. - pub fn shutdown(&self) { - let mut inner = self.lock(); - inner.shutting_down = true; - // Running merge queues stop, and so do their test commands. - let running_queues: Vec = inner - .queues - .iter() - .filter(|(_, q)| q.running) - .map(|(repo, _)| repo.clone()) - .collect(); - inner.cancel_queues.extend(running_queues.iter().cloned()); - let mut cancelled_queues = Vec::new(); - for repo in &running_queues { - if let Some(queue) = inner.queues.get_mut(repo) { - mark_queue_stopped(queue, "cancelled", QUEUE_CANCEL_WHY); - cancelled_queues.push(queue.clone()); - } - } - let queue_pgids: Vec = inner.queue_tests.drain().map(|(_, pgid)| pgid).collect(); - for pgid in &queue_pgids { - process::signal_group(*pgid, "TERM"); - } - let _ = self.save_queues(&inner); - let running: Vec<(Option, Option)> = inner - .tasks - .iter() - .filter(|t| t.state == TaskState::Running) - .map(|t| t.id.clone()) - .collect::>() - .into_iter() - .map(|id| { - let pid = inner.task(&id).ok().and_then(|t| t.pid); - (pid, inner.children.remove(&id)) - }) - .collect(); - for task in inner - .tasks - .iter_mut() - .filter(|t| t.state == TaskState::Running) - { - task.state = TaskState::Stopped; - } - let _ = self.save(&inner); - drop(inner); - for queue in &cancelled_queues { - self.record_queue_event(queue, "queue_cancelled", QUEUE_CANCEL_WHY); - } - // Signal every group first, then wait once, so quitting with many - // agents takes one grace period rather than one per agent. - for (pid, _) in &running { - if let Some(pid) = pid { - process::signal_group(*pid, "TERM"); - } - } - let deadline = Instant::now() + LINGER_GRACE; - let mut running = running; - while Instant::now() < deadline - && (running - .iter() - .any(|(pid, _)| pid.is_some_and(process::group_alive)) - || queue_pgids.iter().copied().any(process::group_alive)) - { - for (_, child) in &mut running { - if let Some(c) = child { - let _ = c.try_wait(); - } - } - std::thread::sleep(Duration::from_millis(50)); - } - for (pid, child) in &mut running { - if let Some(pid) = pid { - if process::group_alive(*pid) { - process::signal_group(*pid, "KILL"); - } - } - if let Some(c) = child { - let _ = c.kill(); - let _ = c.wait(); - } - } - for pgid in queue_pgids { - if process::group_alive(pgid) { - process::signal_group(pgid, "KILL"); - } - } - } - - /// Record the exit of any finished agents. Helpers left in a finished - /// agent's process group get SIGTERM now and SIGKILL after a grace period; - /// until they are gone the task counts as not idle. - fn reap(&self, inner: &mut Inner) -> Result<()> { - let now = Instant::now(); - inner.lingering.retain(|&pgid, deadline| { - if !process::group_alive(pgid) { - return false; - } - if now >= *deadline { - process::signal_group(pgid, "KILL"); - } - true - }); - - let mut done = Vec::new(); - for (id, child) in &mut inner.children { - if let Ok(Some(status)) = child.try_wait() { - done.push((id.clone(), status, child.id())); - } - } - if done.is_empty() { - return Ok(()); - } - for (id, status, pid) in done { - inner.children.remove(&id); - if process::group_alive(pid) { - process::signal_group(pid, "TERM"); - inner.lingering.insert(pid, now + LINGER_GRACE); - } - if let Some(task) = inner.tasks.iter_mut().find(|t| t.id == id) { - task.exit_code = status.code().map(i64::from); - task.state = if status.success() { - TaskState::Finished - } else { - TaskState::Failed - }; - } - } - self.save(inner) - } - - fn save(&self, inner: &Inner) -> Result<()> { - let tmp = self.root.join(format!("{STORE_FILE}.tmp")); - { - let file = File::create(&tmp)?; - serde_json::to_writer_pretty(&file, &inner.tasks)?; - file.sync_all()?; - } - fs::rename(tmp, self.root.join(STORE_FILE))?; - Ok(()) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct PersistentQueue { - #[serde(flatten)] - queue: MergeQueueDto, - #[serde(default)] - test_pgid: Option, -} - -type LoadedQueues = (HashMap, HashMap); - -fn load_queues_file(root: &Path) -> std::result::Result { - let path = root.join(QUEUES_FILE); - match fs::read(&path) { - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok((HashMap::new(), HashMap::new())), - Err(e) => Err(format!("The merge queue list could not be read ({e}).")), - Ok(bytes) => match serde_json::from_slice::>(&bytes) { - Ok(map) => { - let mut queues = HashMap::new(); - let mut tests = HashMap::new(); - for (key, stored) in map { - let repo = PathBuf::from(key); - if let Some(pgid) = stored.test_pgid { - tests.insert(repo.clone(), pgid); - } - queues.insert(repo, stored.queue); - } - Ok((queues, tests)) - } - Err(e) => { - let aside = root.join(format!("{QUEUES_FILE}.corrupt-{}", now_ms())); - let _ = fs::rename(&path, &aside); - Err(format!( - "The merge queue list could not be read ({e}) and was moved to {}.", - aside.display() - )) - } - }, - } -} - -fn mark_queue_stopped(queue: &mut MergeQueueDto, status: &str, why: &str) { - queue.running = false; - for item in &mut queue.items { - if matches!( - item.status.as_str(), - "pending" | "updating" | "testing" | "merging" - ) { - item.status = status.to_string(); - item.detail = Some(why.chars().take(2_000).collect()); - } - } -} - -impl Inner { - fn index(&self, id: &str) -> Result { - self.tasks - .iter() - .position(|t| t.id == id) - .ok_or(Error::NotFound) - } - - fn task(&self, id: &str) -> Result<&TaskRecord> { - self.tasks - .iter() - .find(|t| t.id == id) - .ok_or(Error::NotFound) - } -} - -const TEST_LOG_TAIL: u64 = 4_000; - -/// Run `command` with `/bin/sh -c` in `worktree`, output to `log`. Stops the -/// whole process group on timeout or when `cancelled` turns true. -fn run_test_command( - worktree: &Path, - command: &str, - log: &Path, - timeout: Duration, - cancelled: impl Fn() -> bool, - track: impl Fn(u32, bool), -) -> std::result::Result<(), String> { - let out = File::create(log).map_err(|e| e.to_string())?; - let err = out.try_clone().map_err(|e| e.to_string())?; - let mut child = std::process::Command::new("/bin/sh") - .arg("-c") - .arg(command) - .current_dir(worktree) - .process_group(0) - .stdin(Stdio::null()) - .stdout(Stdio::from(out)) - .stderr(Stdio::from(err)) - .spawn() - .map_err(|e| format!("could not start the test command: {e}"))?; - let pgid = child.id(); - track(pgid, true); - let result = wait_test_command(&mut child, pgid, log, timeout, cancelled); - track(pgid, false); - result -} - -fn wait_test_command( - child: &mut Child, - pgid: u32, - log: &Path, - timeout: Duration, - cancelled: impl Fn() -> bool, -) -> std::result::Result<(), String> { - let deadline = Instant::now() + timeout; - loop { - match child.try_wait() { - Ok(Some(status)) => { - // Clean up anything the tests left running. - if process::group_alive(pgid) { - process::stop_group(pgid, None); - } - return if status.success() { - Ok(()) - } else { - Err(format!( - "exit {}; last output:\n{}", - status - .code() - .map_or("signal".to_string(), |c| c.to_string()), - log_tail(log) - )) - }; - } - Ok(None) => {} - Err(e) => return Err(e.to_string()), - } - if cancelled() { - process::stop_group(pgid, Some(child)); - return Err("cancelled".into()); - } - if Instant::now() >= deadline { - process::stop_group(pgid, Some(child)); - return Err(format!("timed out after {}s", timeout.as_secs())); - } - std::thread::sleep(Duration::from_millis(100)); - } -} - -fn log_tail(log: &Path) -> String { - use std::io::{Read, Seek, SeekFrom}; - let Ok(mut f) = File::open(log) else { - return String::new(); - }; - let len = f.metadata().map(|m| m.len()).unwrap_or(0); - let _ = f.seek(SeekFrom::Start(len.saturating_sub(TEST_LOG_TAIL))); - let mut buf = Vec::new(); - let _ = f.read_to_end(&mut buf); - String::from_utf8_lossy(&buf).trim().to_string() -} - -/// The task is not running and nothing it started is still exiting. -fn ensure_idle(inner: &Inner, task: &TaskRecord) -> Result<()> { - if task.state == TaskState::Running { - return Err(Error::Invalid("the agent is still running".into())); - } - if task - .pid - .is_some_and(|pid| inner.lingering.contains_key(&pid) && process::group_alive(pid)) - { - return Err(Error::Invalid( - "the agent's helper processes are still exiting; try again in a moment".into(), - )); - } - Ok(()) -} - -/// Stop a task's process group. Signals a stored pid only when we own the child -/// or the pid still runs the agent program (guards against pid reuse). -fn kill_processes( - pid: Option, - agent: TaskAgent, - child: Option<&mut Child>, - programs: &AgentPrograms, -) { - match (pid, child) { - (Some(pid), Some(child)) => process::stop_group(pid, Some(child)), - (Some(pid), None) => { - if process::group_alive(pid) && process::runs_program(pid, program_for(programs, agent)) - { - process::stop_group(pid, None); - } - } - (None, Some(child)) => { - let _ = child.kill(); - let _ = child.wait(); - } - (None, None) => {} - } -} - -/// Start one agent run for `task`, appending its output to the task log. -fn spawn( - programs: &AgentPrograms, - task: &TaskRecord, - prompt: &str, - resume: Option<&str>, -) -> Result { - let mut log = fs::OpenOptions::new() - .create(true) - .append(true) - .open(&task.log_path)?; - writeln!(log, "{} opening prompt ---", agent::RUN_MARKER)?; - writeln!(log, "{prompt}")?; - if task.runs > 1 { - writeln!( - log, - "{} run {} ({}) ---", - agent::RUN_MARKER, - task.runs, - agent_name(task.agent) - )?; - } - let err = log.try_clone()?; - let child = agent::command( - programs, - agent::Launch { - agent: task.agent, - permission: task.permission, - worktree: &task.worktree_path, - prompt, - resume_session: resume, - model: task.model.as_deref(), - effort: task.effort, - }, - ) - .process_group(0) - .stdin(Stdio::null()) - .stdout(Stdio::from(log)) - .stderr(Stdio::from(err)) - .spawn()?; - Ok(child) -} - -/// Full status for a task, including git-derived fields. Runs git subprocesses, -/// so call it without holding the orchestrator. -pub fn describe(snapshot: &TaskSnapshot) -> TaskDto { - let t = &snapshot.0; - let exists = t.worktree_path.is_dir(); - let changed = if exists { - git::changed_files(&t.worktree_path, &t.base_commit, MAX_CHANGED_FILES) - } else { - (Vec::new(), 0) - }; - TaskDto { - id: t.id.clone(), - title: t.title.clone(), - prompt: t.prompt.clone(), - agent: t.agent, - state: t.state, - repo_path: t.repo_path.display().to_string(), - worktree_path: t.worktree_path.display().to_string(), - branch: t.branch.clone(), - base_commit: t.base_commit.clone(), - created_at_ms: t.created_at_ms, - exit_code: t.exit_code, - commits_ahead: if exists { - git::commits_ahead(&t.worktree_path, &t.base_commit) - } else { - 0 - }, - changed_files: changed.0, - changed_files_total: Some(i64::try_from(changed.1).unwrap_or(i64::MAX)), - last_activity: agent::last_activity(&t.log_path) - .map(|s| strip_worktree_prefix(&s, &t.worktree_path)), - permission: Some(t.permission), - model: t.model.clone(), - effort: t.effort, - runs: Some(i64::from(t.runs)), - uncommitted_count: Some(if exists { - i64::try_from(git::uncommitted(&t.worktree_path).len()).unwrap_or(i64::MAX) - } else { - 0 - }), - attention: if t.state == TaskState::Running { - None - } else { - agent::attention(&t.log_path) - }, - overlaps: None, - claims: Some(t.claims.clone()), - claim_conflicts: None, - auto_handoff: Some(t.auto_handoff), - repo_branch: git::current_branch(&t.repo_path).ok(), - merged_into: t.merged_into.clone(), - } -} - -/// Mark tasks in the same repository that changed the same files (unmerged -/// tasks only). Call on the full list returned by `describe`. -pub fn annotate_overlaps(tasks: &mut [TaskDto]) { - let files: Vec> = tasks - .iter() - .map(|t| t.changed_files.iter().map(String::as_str).collect()) - .collect(); - let mut result: Vec> = vec![Vec::new(); tasks.len()]; - for i in 0..tasks.len() { - for j in (i + 1)..tasks.len() { - let (a, b) = (&tasks[i], &tasks[j]); - if a.repo_path != b.repo_path || a.merged_into.is_some() || b.merged_into.is_some() { - continue; - } - let mut shared: Vec = files[i] - .intersection(&files[j]) - .map(|s| s.to_string()) - .collect(); - if shared.is_empty() { - continue; - } - shared.sort(); - result[i].push(TaskOverlapDto { - task_id: b.id.clone(), - title: b.title.clone(), - files: shared.clone(), - }); - result[j].push(TaskOverlapDto { - task_id: a.id.clone(), - title: a.title.clone(), - files: shared, - }); - } - } - for (task, overlaps) in tasks.iter_mut().zip(result) { - task.overlaps = Some(overlaps); - } - annotate_claim_conflicts(tasks); -} - -/// Mark files a task changed that another unmerged task in the same repository -/// claims as its own. -fn annotate_claim_conflicts(tasks: &mut [TaskDto]) { - let mut result: Vec> = vec![Vec::new(); tasks.len()]; - for i in 0..tasks.len() { - for j in 0..tasks.len() { - if i == j { - continue; - } - let (mine, theirs) = (&tasks[i], &tasks[j]); - if mine.repo_path != theirs.repo_path - || mine.merged_into.is_some() - || theirs.merged_into.is_some() - { - continue; - } - let claims = theirs.claims.clone().unwrap_or_default(); - let mut files: Vec = mine - .changed_files - .iter() - .filter(|f| claims.iter().any(|c| claim_covers(c, f))) - .cloned() - .collect(); - if files.is_empty() { - continue; - } - files.sort(); - result[i].push(TaskOverlapDto { - task_id: theirs.id.clone(), - title: theirs.title.clone(), - files, - }); - } - } - for (task, conflicts) in tasks.iter_mut().zip(result) { - task.claim_conflicts = Some(conflicts); - } -} - -fn agent_name(agent: TaskAgent) -> &'static str { - match agent { - TaskAgent::ClaudeCode => "Claude Code", - TaskAgent::Codex => "Codex", - } -} - -const MAX_CLAIMS: usize = 50; - -/// Validate and normalize claimed paths: repository-relative, no `..`, folders -/// keep their trailing `/`. -fn normalize_claims(claims: &[String]) -> Result> { - if claims.len() > MAX_CLAIMS { - return Err(Error::Invalid(format!("at most {MAX_CLAIMS} claims"))); - } - let mut out = Vec::new(); - for claim in claims { - let claim = claim.trim().trim_start_matches("./"); - if claim.is_empty() { - continue; - } - let is_dir = claim.ends_with('/'); - let path = Path::new(claim.trim_end_matches('/')); - if path.is_absolute() - || path - .components() - .any(|c| !matches!(c, std::path::Component::Normal(_)) || c.as_os_str() == ".git") - || claim.len() > 300 - { - return Err(Error::Invalid(format!( - "claim must be a repository-relative path: {claim}" - ))); - } - out.push(if is_dir { - format!("{}/", path.display()) - } else { - path.display().to_string() - }); - } - out.sort(); - out.dedup(); - Ok(out) -} - -/// Whether `file` falls under `claim` (exact file, or anything under a folder). -fn claim_covers(claim: &str, file: &str) -> bool { - match claim.strip_suffix('/') { - Some(dir) => file.starts_with(&format!("{dir}/")), - None => file == claim, - } -} - -/// Whether two declared claims name the same path or one contains the other. -fn claims_overlap(a: &str, b: &str) -> bool { - if a == b { - return true; - } - claim_contains(a, b) || claim_contains(b, a) -} - -fn claim_contains(outer: &str, inner: &str) -> bool { - match outer.strip_suffix('/') { - Some(dir) => inner == dir || inner.starts_with(&format!("{dir}/")), - None => false, - } -} - -fn overlapping_claim_error(tasks: &[TaskRecord]) -> Option { - let mut msgs = Vec::new(); - for (i, a) in tasks.iter().enumerate() { - for b in tasks.iter().skip(i + 1) { - for ca in &a.claims { - for cb in &b.claims { - if claims_overlap(ca, cb) { - let shared = if ca == cb { - format!("both claim {ca}") - } else { - format!("{ca} overlaps {cb}") - }; - msgs.push(format!( - "cannot queue \"{}\" and \"{}\": {shared}", - a.title, b.title - )); - } - } - } - } - } - msgs.sort(); - msgs.dedup(); - if msgs.is_empty() { - None - } else { - Some(msgs.join("; ")) - } -} - -/// What an agent is told about the other agents working in the same repository, -/// prepended to its first prompt (the "shared brain", step 4). -fn coordination_preamble(tasks: &[TaskRecord], task: &TaskRecord) -> String { - let others: Vec<&TaskRecord> = tasks - .iter() - .filter(|t| { - t.id != task.id - && t.repo_path == task.repo_path - && t.merged_into.is_none() - && matches!( - t.state, - TaskState::Running | TaskState::Interrupted | TaskState::Finished - ) - }) - .collect(); - if others.is_empty() && task.claims.is_empty() { - return String::new(); - } - let mut out = String::from( - "## Working alongside other agents (from Lore)\n\ - You are one of several agents working in the same repository, each in its own \ - git worktree. Stay inside your own scope so your work merges cleanly.\n\n", - ); - if !task.claims.is_empty() { - out.push_str(&format!("You own: {}\n", task.claims.join(", "))); - } - let mut overlaps = Vec::new(); - for other in &others { - let owns = if other.claims.is_empty() { - "no declared files".to_string() - } else { - other.claims.join(", ") - }; - out.push_str(&format!( - "- \"{}\" ({}, {}) owns: {owns}\n", - other.title, - agent_name(other.agent), - state_word(other.state), - )); - for mine in &task.claims { - for theirs in &other.claims { - if claims_overlap(mine, theirs) { - overlaps.push(format!( - "Your claim {mine} overlaps \"{}\"'s claim {theirs}.", - other.title - )); - } - } - } - } - if !overlaps.is_empty() { - overlaps.sort(); - overlaps.dedup(); - out.push('\n'); - out.push_str(&overlaps.join(" ")); - out.push_str(" Lore will refuse to merge both tasks until their claims are distinct.\n"); - } - let claimed: Vec<&str> = others - .iter() - .flat_map(|o| o.claims.iter().map(String::as_str)) - .filter(|theirs| !task.claims.iter().any(|mine| claims_overlap(mine, theirs))) - .collect(); - if !claimed.is_empty() { - out.push_str(&format!( - "\nDo not edit files owned by another agent ({}). If your task needs them, \ - say so in your final message instead of editing them.\n", - claimed.join(", ") - )); - } - out.push_str("\n## Your task\n"); - out -} - -fn state_word(state: TaskState) -> &'static str { - match state { - TaskState::Running => "running", - TaskState::Finished => "finished", - TaskState::Failed => "failed", - TaskState::Stopped => "stopped", - TaskState::Interrupted => "interrupted", - } -} - -/// Context for an agent picking up a task it has no session for. -fn handoff_brief(task: &TaskRecord, prompt: &str, history: &[DecisionDto]) -> String { - let wt = &task.worktree_path; - let commits = git::log_oneline(wt, &task.base_commit, 30); - let (changed, _) = git::changed_files(wt, &task.base_commit, 100); - let uncommitted = git::uncommitted(wt); - let recent: Vec = agent::activity(&task.log_path, 40) - .into_iter() - .filter(|e| { - matches!( - e.kind, - ActivityKind::Message | ActivityKind::Result | ActivityKind::Error - ) - }) - .map(|e| { - format!( - "- [{:?}] {}", - e.kind, - e.text.chars().take(400).collect::() - ) - }) - .collect(); - let list = |items: &[String]| { - if items.is_empty() { - "(none)".to_string() - } else { - items - .iter() - .map(|i| format!("- {i}")) - .collect::>() - .join("\n") - } - }; - let owns = if task.claims.is_empty() { - "nothing declared; stay within the task's scope".to_string() - } else { - task.claims.join(", ") - }; - let history_lines: Vec = history - .iter() - .take(15) - .map(|d| { - format!( - "- {} \"{}\": {}", - d.kind, - d.task_title, - d.detail.chars().take(200).collect::() - ) - }) - .collect(); - format!( - "You are continuing a task another agent session started in this git worktree \ - (branch {branch}). Inspect the code yourself before relying on this summary.\n\n\ - ## Original task: {title}\n{original}\n\n\ - ## Files this task owns\n{owns}\n\n\ - ## Commits so far\n{commits}\n\n\ - ## Files changed since the task began\n{changed}\n\n\ - ## Uncommitted paths\n{uncommitted}\n\n\ - ## Recent notes from the previous agent\n{recent}\n\n\ - ## What other agents did in this repository\n{history}\n\n\ - ## What to do now\n{prompt}\n", - history = if history_lines.is_empty() { - "(nothing recorded)".to_string() - } else { - history_lines.join("\n") - }, - branch = task.branch, - title = task.title, - original = task.prompt, - commits = list(&commits), - changed = list(&changed), - uncommitted = list(&uncommitted), - recent = if recent.is_empty() { - "(none)".to_string() - } else { - recent.join("\n") - }, - ) -} - -const MAX_DIFF_BYTES: usize = 2 * 1024 * 1024; - -/// Unified diff of the task's worktree against its base commit. Runs git; call -/// it without holding the orchestrator. -pub fn diff(snapshot: &TaskSnapshot) -> Result { - let t = &snapshot.0; - if !t.worktree_path.is_dir() { - return Ok(TaskDiffDto { - text: String::new(), - truncated: false, - }); - } - let (text, truncated) = git::diff_against(&t.worktree_path, &t.base_commit, MAX_DIFF_BYTES)?; - Ok(TaskDiffDto { text, truncated }) -} - -/// The task agent's recent activity timeline (see [`agent::activity`]). -/// Tool targets that include the worktree path are shown relative to it. -pub fn activity(snapshot: &TaskSnapshot, max_events: usize) -> Vec { - let worktree = &snapshot.0.worktree_path; - agent::activity(&snapshot.0.log_path, max_events) - .into_iter() - .map(|mut e| { - e.text = strip_worktree_prefix(&e.text, worktree); - e - }) - .collect() -} - -fn strip_worktree_prefix(text: &str, worktree: &Path) -> String { - let wt = worktree.to_string_lossy(); - if wt.is_empty() { - return text.to_string(); - } - let slash = if wt.ends_with('/') { - wt.to_string() - } else { - format!("{wt}/") - }; - if text.contains(&slash) { - text.replace(&slash, "") - } else if text.contains(wt.as_ref()) { - text.replace(wt.as_ref(), "") - } else { - text.to_string() - } -} - -fn program_for(programs: &AgentPrograms, agent: TaskAgent) -> &Path { - match agent { - TaskAgent::ClaudeCode => &programs.claude_code, - TaskAgent::Codex => &programs.codex, - } -} - -fn now_ms() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX)) - .unwrap_or(0) -} - -/// Unique within this process and practically unique across restarts. -fn new_id(now_ms: i64) -> String { - static COUNTER: AtomicU32 = AtomicU32::new(0); - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.subsec_nanos()) - .unwrap_or(0); - let mix = nanos ^ std::process::id().rotate_left(16) ^ COUNTER.fetch_add(1, Ordering::Relaxed); - format!("t{now_ms:x}{:08x}", mix) -} - -/// Lowercase ASCII slug for branch names; never empty. -fn slug(title: &str) -> String { - let mut out = String::new(); - for c in title.chars() { - if c.is_ascii_alphanumeric() { - out.push(c.to_ascii_lowercase()); - } else if !out.ends_with('-') && !out.is_empty() { - out.push('-'); - } - if out.len() >= 40 { - break; - } - } - let out = out.trim_end_matches('-').to_string(); - if out.is_empty() { - "task".into() - } else { - out - } -} - -/// `path` is a descendant of `dir` after lexical normalization (no `..`). -fn is_strictly_inside(path: &Path, dir: &Path) -> bool { - use std::path::Component; - if path.components().any(|c| matches!(c, Component::ParentDir)) { - return false; - } - path.starts_with(dir) && path != dir -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - - #[test] - fn slug_is_branch_safe() { - assert_eq!(slug("Fix the Stripe webhook!!"), "fix-the-stripe-webhook"); - assert_eq!(slug(" *** "), "task"); - assert!(slug(&"x".repeat(100)).len() <= 40); - } - - #[test] - fn inside_check_rejects_escape() { - let dir = Path::new("/a/worktrees"); - assert!(is_strictly_inside(Path::new("/a/worktrees/t1"), dir)); - assert!(!is_strictly_inside(Path::new("/a/worktrees"), dir)); - assert!(!is_strictly_inside(Path::new("/a/worktrees/../repo"), dir)); - assert!(!is_strictly_inside(Path::new("/a/other"), dir)); - } - - #[test] - fn ids_do_not_collide_in_a_burst() { - let ids: std::collections::HashSet<_> = (0..1000).map(|_| new_id(1)).collect(); - assert_eq!(ids.len(), 1000); - } - - #[test] - fn claims_overlap_files_and_folders() { - assert!(claims_overlap("calc.py", "calc.py")); - assert!(claims_overlap("src/", "src/auth.rs")); - assert!(claims_overlap("src/auth.rs", "src/")); - assert!(claims_overlap("src/", "src/auth/")); - assert!(!claims_overlap("calc.py", "greet.py")); - assert!(!claims_overlap("src/", "lib.rs")); - } -} diff --git a/crates/lore-orchestrator/src/process.rs b/crates/lore-orchestrator/src/process.rs deleted file mode 100644 index 0c8e333..0000000 --- a/crates/lore-orchestrator/src/process.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! Process-group supervision for agent processes. -//! -//! Each agent is started as the leader of its own process group, so stopping a -//! task also stops everything it launched (shell commands, test runners, MCP -//! servers). Signals are sent with `/bin/kill` to avoid `unsafe` libc calls. - -use std::path::Path; -use std::process::{Child, Command, Stdio}; -use std::time::{Duration, Instant}; - -const GRACE: Duration = Duration::from_secs(3); - -fn kill(args: &[&str]) -> bool { - Command::new("/bin/kill") - .args(args) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) -} - -/// Whether any process in group `pgid` is alive. -pub fn group_alive(pgid: u32) -> bool { - pgid > 1 && kill(&["-0", "--", &format!("-{pgid}")]) -} - -/// Whether process `pid` exists. -pub fn group_alive_pid(pid: u32) -> bool { - pid > 1 && kill(&["-0", &pid.to_string()]) -} - -/// Send `signal` (e.g. `TERM`) to every process in group `pgid`. -pub fn signal_group(pgid: u32, signal: &str) { - if pgid > 1 { - kill(&["-s", signal, "--", &format!("-{pgid}")]); - } -} - -/// Stop group `pgid`: SIGTERM, wait up to a short grace period, then SIGKILL. -/// Reaps `child` (the group leader) when we own it, so it never lingers as a zombie. -pub fn stop_group(pgid: u32, mut child: Option<&mut Child>) { - signal_group(pgid, "TERM"); - let deadline = Instant::now() + GRACE; - while Instant::now() < deadline { - if let Some(c) = child.as_deref_mut() { - let _ = c.try_wait(); - } - if !group_alive(pgid) { - break; - } - std::thread::sleep(Duration::from_millis(50)); - } - if group_alive(pgid) { - signal_group(pgid, "KILL"); - } - if let Some(c) = child { - let _ = c.kill(); - let _ = c.wait(); - } -} - -/// Whether `pid` is still running the given agent program. Guards against -/// signalling an unrelated process that reused a stale pid after a restart. -pub fn runs_program(pid: u32, program: &Path) -> bool { - let Some(name) = program.file_name().and_then(|n| n.to_str()) else { - return false; - }; - Command::new("/bin/ps") - .args(["-o", "command=", "-p", &pid.to_string()]) - .stderr(Stdio::null()) - .output() - .map(|out| { - // The program must be argv[0] or, for interpreters and wrappers - // (`node .../claude`, `sh fake-agent.sh`), argv[1]; never just a - // substring of an unrelated command line like `vim claude.md`. - let line = String::from_utf8_lossy(&out.stdout); - line.split_whitespace() - .take(2) - .any(|word| Path::new(word).file_name().and_then(|n| n.to_str()) == Some(name)) - }) - .unwrap_or(false) -} diff --git a/crates/lore-orchestrator/src/workspace.rs b/crates/lore-orchestrator/src/workspace.rs deleted file mode 100644 index 66d33f0..0000000 --- a/crates/lore-orchestrator/src/workspace.rs +++ /dev/null @@ -1,215 +0,0 @@ -//! Read-only file access inside a workspace root (a git repository top-level -//! or a task worktree), for the explorer and file tabs. -//! -//! Every path is resolved against the root and canonicalized; anything that -//! escapes the root (via `..` or symlinks) is refused. - -use std::fs; -use std::io::Read; -use std::path::{Component, Path, PathBuf}; - -use lore_ipc::{DirEntryDto, FileContentDto}; - -use crate::{git, Error, Result}; - -const MAX_ENTRIES: usize = 5_000; -const MAX_FILE_BYTES: u64 = 1024 * 1024; -/// Never shown in the explorer. -const HIDDEN: &[&str] = &[".git", ".DS_Store"]; - -/// Validate that `root` is an existing repository or worktree top-level. -pub fn workspace_root(root: &str) -> Result { - let root = PathBuf::from(root); - if !root.is_absolute() || !root.is_dir() { - return Err(Error::Invalid( - "workspace must be an existing absolute directory".into(), - )); - } - let top = git::toplevel(&root).map_err(not_a_git_repository)?; - let (a, b) = (fs::canonicalize(&root)?, fs::canonicalize(&top)?); - if a != b { - return Err(Error::Invalid( - "workspace must be the top-level of a git repository".into(), - )); - } - Ok(a) -} - -/// The repository top-level containing any directory `path`. -pub fn repository_root(path: &str) -> Result { - let path = PathBuf::from(path); - if !path.is_absolute() || !path.is_dir() { - return Err(Error::Invalid( - "choose an existing folder inside a git repository".into(), - )); - } - let top = git::toplevel(&path).map_err(not_a_git_repository)?; - Ok(fs::canonicalize(top)?) -} - -pub(crate) fn not_a_git_repository(err: Error) -> Error { - match err { - Error::Git(msg) if msg.to_ascii_lowercase().contains("not a git repository") => { - Error::Invalid("That folder isn't inside a git repository".into()) - } - other => other, - } -} - -fn resolve(root: &Path, rel: &str) -> Result { - let rel = Path::new(rel); - if rel.is_absolute() - || rel.components().any(|c| { - !matches!(c, Component::Normal(_) | Component::CurDir) || c.as_os_str() == ".git" - }) - { - return Err(Error::Invalid( - "path must be relative to the workspace".into(), - )); - } - let full = fs::canonicalize(root.join(rel))?; - if !full.starts_with(root) { - return Err(Error::Invalid("path escapes the workspace".into())); - } - Ok(full) -} - -fn rel_string(root: &Path, path: &Path) -> String { - path.strip_prefix(root) - .unwrap_or(path) - .components() - .map(|c| c.as_os_str().to_string_lossy()) - .collect::>() - .join("/") -} - -/// List one directory: folders first, then files, each alphabetical. -pub fn list_dir(root: &str, rel: &str) -> Result> { - let root = workspace_root(root)?; - let dir = resolve(&root, rel)?; - if !dir.is_dir() { - return Err(Error::Invalid("not a directory".into())); - } - let mut out = Vec::new(); - let entries = fs::read_dir(&dir)? - .flatten() - .filter(|e| !HIDDEN.contains(&e.file_name().to_string_lossy().as_ref())) - .take(MAX_ENTRIES); - for entry in entries { - let name = entry.file_name().to_string_lossy().to_string(); - // Follow symlinks for the folder/file distinction; opening one that - // leaves the workspace is still refused by `resolve`. - let is_dir = fs::metadata(entry.path()) - .map(|m| m.is_dir()) - .unwrap_or(false); - out.push(DirEntryDto { - rel_path: rel_string(&root, &dir.join(&name)), - name, - is_dir, - }); - } - out.sort_by(|a, b| { - b.is_dir - .cmp(&a.is_dir) - .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) - }); - Ok(out) -} - -/// Read a file for display; binary files return no text. -pub fn read_file(root: &str, rel: &str) -> Result { - let root = workspace_root(root)?; - let path = resolve(&root, rel)?; - if !path.is_file() { - return Err(Error::Invalid("not a file".into())); - } - let size = fs::metadata(&path)?.len(); - let mut buf = Vec::new(); - fs::File::open(&path)? - .take(MAX_FILE_BYTES) - .read_to_end(&mut buf)?; - let truncated = size > MAX_FILE_BYTES; - let text = if buf.contains(&0) { - None - } else { - match String::from_utf8(buf) { - Ok(s) => Some(s), - // A cut can split a multi-byte character; keep the valid prefix. - Err(e) if truncated => { - let valid = e.utf8_error().valid_up_to(); - let mut bytes = e.into_bytes(); - bytes.truncate(valid); - String::from_utf8(bytes).ok() - } - Err(_) => None, - } - }; - Ok(FileContentDto { - rel_path: rel_string(&root, &path), - text, - size: i64::try_from(size).unwrap_or(i64::MAX), - truncated, - }) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - use std::process::Command; - - fn repo() -> tempfile::TempDir { - let tmp = tempfile::tempdir().unwrap(); - Command::new("git") - .arg("-C") - .arg(tmp.path()) - .args(["init", "-q"]) - .status() - .unwrap(); - fs::create_dir_all(tmp.path().join("src")).unwrap(); - fs::write(tmp.path().join("src/main.rs"), "fn main() {}\n").unwrap(); - fs::write(tmp.path().join("README.md"), "hi\n").unwrap(); - fs::write(tmp.path().join("logo.bin"), [0u8, 1, 2]).unwrap(); - tmp - } - - #[test] - fn lists_folders_first_and_hides_git() { - let tmp = repo(); - let root = tmp.path().to_str().unwrap(); - let names: Vec<_> = list_dir(root, "") - .unwrap() - .into_iter() - .map(|e| e.name) - .collect(); - assert_eq!(names, ["src", "logo.bin", "README.md"]); - assert_eq!(list_dir(root, "src").unwrap()[0].rel_path, "src/main.rs"); - } - - #[test] - fn reads_text_and_flags_binary() { - let tmp = repo(); - let root = tmp.path().to_str().unwrap(); - assert_eq!( - read_file(root, "src/main.rs").unwrap().text.as_deref(), - Some("fn main() {}\n") - ); - assert_eq!(read_file(root, "logo.bin").unwrap().text, None); - } - - #[test] - fn refuses_escapes() { - let tmp = repo(); - let outside = tempfile::tempdir().unwrap(); - fs::write(outside.path().join("secret"), "x").unwrap(); - std::os::unix::fs::symlink(outside.path(), tmp.path().join("link")).unwrap(); - let root = tmp.path().to_str().unwrap(); - assert!(read_file(root, "../secret").is_err()); - assert!(read_file(root, ".git/config").is_err()); - assert!(list_dir(root, ".git").is_err()); - assert!(read_file(root, "/etc/hosts").is_err()); - assert!(read_file(root, "link/secret").is_err()); - assert!(list_dir(root, "link").is_err()); - assert!(list_dir(tmp.path().join("src").to_str().unwrap(), "").is_err()); - } -} diff --git a/crates/lore-orchestrator/tests/lifecycle.rs b/crates/lore-orchestrator/tests/lifecycle.rs deleted file mode 100644 index 72ebd33..0000000 --- a/crates/lore-orchestrator/tests/lifecycle.rs +++ /dev/null @@ -1,1605 +0,0 @@ -//! End-to-end task lifecycle against a temp git repo and a fake agent script, -//! so tests never run a real agent or touch real `~/.claude` / `~/.codex`. -#![allow(clippy::unwrap_used, clippy::expect_used)] - -use std::fs; -use std::os::unix::fs::PermissionsExt; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::{Duration, Instant}; - -use lore_ipc::{CreateTaskRequest, TaskAgent, TaskEffort, TaskState}; -use lore_orchestrator::{AgentPrograms, Orchestrator}; - -fn sh(dir: &Path, args: &[&str]) { - let ok = Command::new("git") - .arg("-C") - .arg(dir) - .args([ - "-c", - "user.name=t", - "-c", - "user.email=t@t", - "-c", - "commit.gpgsign=false", - ]) - .args(args) - .status() - .unwrap() - .success(); - assert!(ok, "git {args:?}"); -} - -fn repo(tmp: &Path) -> PathBuf { - let repo = tmp.join("repo"); - fs::create_dir_all(&repo).unwrap(); - sh(&repo, &["init", "-q", "-b", "main"]); - fs::write(repo.join("README.md"), "hello\n").unwrap(); - sh(&repo, &["add", "README.md"]); - sh(&repo, &["commit", "-q", "-m", "init"]); - repo -} - -/// A fake agent: records its args, edits a file, commits, prints stream-json. -fn fake_agent(tmp: &Path, body: &str) -> PathBuf { - let path = tmp.join("fake-agent.sh"); - fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); - fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); - path -} - -fn programs(agent: &Path) -> AgentPrograms { - AgentPrograms { - claude_code: agent.to_path_buf(), - codex: agent.to_path_buf(), - } -} - -fn request(repo: &Path, title: &str) -> CreateTaskRequest { - CreateTaskRequest { - repo_path: repo.display().to_string(), - title: title.into(), - prompt: "do the thing".into(), - agent: TaskAgent::ClaudeCode, - permission: None, - claims: None, - auto_handoff: Some(false), - model: None, - effort: None, - } -} - -fn wait_for(orch: &Orchestrator, id: &str, state: TaskState) -> lore_ipc::TaskDto { - let deadline = Instant::now() + Duration::from_secs(20); - loop { - let task = orch.task(id).unwrap(); - if task.state == state { - return task; - } - assert!(Instant::now() < deadline, "task stuck in {:?}", task.state); - std::thread::sleep(Duration::from_millis(50)); - } -} - -#[test] -fn two_tasks_run_in_isolated_worktrees_and_discard_cleans_up() { - let tmp = tempfile::tempdir().unwrap(); - let repo = repo(tmp.path()); - // A dirty primary checkout must not block or leak into worktrees. - fs::write(repo.join("dirty.txt"), "uncommitted\n").unwrap(); - - let agent = fake_agent( - tmp.path(), - r#"echo "$PWD" > .agent-cwd -echo work > "work-$$.txt" -git -c user.name=a -c user.email=a@a -c commit.gpgsign=false add -A >/dev/null -git -c user.name=a -c user.email=a@a -c commit.gpgsign=false commit -qm agent >/dev/null -echo '{"type":"assistant","message":{"content":[{"type":"text","text":"committed the work"}]}}' -echo '{"type":"result","result":"All done"}'"#, - ); - let root = tmp.path().join("orch"); - let orch = Orchestrator::open(&root, programs(&agent)).unwrap(); - - let a = lore_orchestrator::describe(&orch.create_task(&request(&repo, "Task A")).unwrap()); - let b = lore_orchestrator::describe(&orch.create_task(&request(&repo, "Task A")).unwrap()); - assert_ne!(a.worktree_path, b.worktree_path); - assert_eq!(a.branch, "lore/task-a"); - assert!( - b.branch.starts_with("lore/task-a-"), - "duplicate slug gets a unique branch: {}", - b.branch - ); - - for id in [&a.id, &b.id] { - let done = wait_for(&orch, id, TaskState::Finished); - assert_eq!(done.exit_code, Some(0)); - assert_eq!(done.commits_ahead, 1); - assert_eq!(done.last_activity.as_deref(), Some("All done")); - assert!(Path::new(&done.worktree_path).starts_with(root.join("worktrees"))); - let cwd = fs::read_to_string(Path::new(&done.worktree_path).join(".agent-cwd")).unwrap(); - assert_eq!( - fs::canonicalize(cwd.trim()).unwrap(), - fs::canonicalize(&done.worktree_path).unwrap() - ); - assert!(!Path::new(&done.worktree_path).join("dirty.txt").exists()); - } - - // Primary checkout untouched: still on main, dirty file intact, no agent files. - assert_eq!( - fs::read_to_string(repo.join("dirty.txt")).unwrap(), - "uncommitted\n" - ); - assert!(!repo.join(".agent-cwd").exists()); - - orch.discard_task(&a.id).unwrap(); - assert!(!Path::new(&a.worktree_path).exists()); - let branches = Command::new("git") - .arg("-C") - .arg(&repo) - .args(["branch", "--list", "lore/*"]) - .output() - .unwrap(); - let branches = String::from_utf8_lossy(&branches.stdout); - assert!(!branches.contains("lore/task-a\n") && branches.contains(&b.branch)); - assert_eq!(orch.list_tasks().unwrap().len(), 1); -} - -/// Running and not a zombie (a forgotten `Child` is never reaped in tests). -fn pid_alive(pid: &str) -> bool { - let out = Command::new("/bin/ps") - .args(["-o", "stat=", "-p", pid]) - .output() - .unwrap(); - let stat = String::from_utf8_lossy(&out.stdout); - !stat.trim().is_empty() && !stat.contains('Z') -} - -fn wait_until(mut cond: impl FnMut() -> bool) -> bool { - let deadline = Instant::now() + Duration::from_secs(10); - while Instant::now() < deadline { - if cond() { - return true; - } - std::thread::sleep(Duration::from_millis(50)); - } - false -} - -#[test] -fn stop_kills_agent_and_everything_it_started() { - let tmp = tempfile::tempdir().unwrap(); - let repo = repo(tmp.path()); - // The agent starts a helper (like a test runner) and waits on it. - let agent = fake_agent( - tmp.path(), - "sleep 1000 &\necho $! > \"$PWD/../helper-$$.pid\"\necho started\nwait", - ); - let root = tmp.path().join("orch"); - let orch = Orchestrator::open(&root, programs(&agent)).unwrap(); - let t = orch.create_task(&request(&repo, "long")).unwrap(); - let helper_file = || { - fs::read_dir(root.join("worktrees")) - .unwrap() - .flatten() - .find(|e| e.file_name().to_string_lossy().starts_with("helper-")) - .map(|e| e.path()) - }; - // The pid file can exist before the shell has written to it. - let read_pid = || { - helper_file() - .and_then(|f| fs::read_to_string(f).ok()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - }; - assert!( - wait_until(|| read_pid().is_some()), - "agent never started its helper" - ); - let helper_pid = read_pid().unwrap(); - assert!(pid_alive(&helper_pid)); - - let stopped = orch.stop_task(t.id()).unwrap(); - assert_eq!( - lore_orchestrator::describe(&stopped).state, - TaskState::Stopped - ); - assert!( - wait_until(|| !pid_alive(&helper_pid)), - "helper process survived Stop" - ); - orch.discard_task(t.id()).unwrap(); -} - -#[test] -fn restart_marks_running_tasks_interrupted_and_stops_leftover_agents() { - let tmp = tempfile::tempdir().unwrap(); - let repo = repo(tmp.path()); - let agent = fake_agent(tmp.path(), "sleep 1000 &\nwait"); - let root = tmp.path().join("orch"); - - let orch = Orchestrator::open(&root, programs(&agent)).unwrap(); - let stopped = orch.create_task(&request(&repo, "stopped")).unwrap(); - orch.stop_task(stopped.id()).unwrap(); - let left = orch.create_task(&request(&repo, "left running")).unwrap(); - let left_dto = orch.task(left.id()).unwrap(); - assert_eq!(left_dto.state, TaskState::Running); - drop(orch); // the app crashed: no shutdown, children never reaped or killed - - let reopened = Orchestrator::open(&root, programs(&agent)).unwrap(); - assert!(reopened.load_warning().is_none()); - assert_eq!( - reopened.task(stopped.id()).unwrap().state, - TaskState::Stopped - ); - assert_eq!( - reopened.task(left.id()).unwrap().state, - TaskState::Interrupted - ); - let store: serde_json::Value = - serde_json::from_slice(&fs::read(root.join("tasks.json")).unwrap()).unwrap(); - let pid = store - .as_array() - .unwrap() - .iter() - .find(|t| t["id"] == left.id()) - .unwrap()["pid"] - .as_u64() - .unwrap(); - assert!( - wait_until(|| !pid_alive(&pid.to_string())), - "leftover agent was not stopped on reopen" - ); - reopened.discard_task(left.id()).unwrap(); - reopened.discard_task(stopped.id()).unwrap(); -} - -#[test] -fn corrupt_store_is_moved_aside_instead_of_failing() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join("orch"); - fs::create_dir_all(&root).unwrap(); - fs::write(root.join("tasks.json"), "{not json").unwrap(); - let agent = fake_agent(tmp.path(), "true"); - let orch = Orchestrator::open(&root, programs(&agent)).unwrap(); - assert!(orch.load_warning().unwrap().contains("could not be read")); - assert!(orch.list_tasks().unwrap().is_empty()); - assert!(fs::read_dir(&root).unwrap().flatten().any(|e| e - .file_name() - .to_string_lossy() - .starts_with("tasks.json.corrupt-"))); -} - -#[test] -fn tampered_records_cannot_delete_outside_lore() { - let tmp = tempfile::tempdir().unwrap(); - let repo = repo(tmp.path()); - let agent = fake_agent(tmp.path(), "true"); - let root = tmp.path().join("orch"); - let orch = Orchestrator::open(&root, programs(&agent)).unwrap(); - let t = orch.create_task(&request(&repo, "victim")).unwrap(); - let id = t.id().to_string(); - drop(orch); - - let store_path = root.join("tasks.json"); - let original = fs::read_to_string(&store_path).unwrap(); - let mut store: serde_json::Value = serde_json::from_str(&original).unwrap(); - - // Branch outside lore/ (e.g. main) is refused. - store[0]["branch"] = "main".into(); - fs::write(&store_path, store.to_string()).unwrap(); - let orch = Orchestrator::open(&root, programs(&agent)).unwrap(); - assert!(orch.discard_task(&id).is_err()); - drop(orch); - - // Worktree path escaping the owned directory is refused, and the target survives. - let outside = tmp.path().join("precious"); - fs::create_dir_all(&outside).unwrap(); - let mut store: serde_json::Value = serde_json::from_str(&original).unwrap(); - store[0]["worktree_path"] = outside.display().to_string().into(); - fs::write(&store_path, store.to_string()).unwrap(); - let orch = Orchestrator::open(&root, programs(&agent)).unwrap(); - assert!(orch.discard_task(&id).is_err()); - assert!(outside.exists()); - drop(orch); - - // A symlink inside worktrees/ pointing outside is not followed. - fs::write(&store_path, &original).unwrap(); - let wt = root.join("worktrees").join(&id); - Command::new("git") - .arg("-C") - .arg(&repo) - .args(["worktree", "remove", "--force"]) - .arg(&wt) - .status() - .unwrap(); - std::os::unix::fs::symlink(&outside, &wt).unwrap(); - let orch = Orchestrator::open(&root, programs(&agent)).unwrap(); - assert!(orch.discard_task(&id).is_err()); - assert!(outside.exists()); - fs::remove_file(&wt).unwrap(); - - // Once the path is gone, discard still cleans up the record and branch. - orch.discard_task(&id).unwrap(); - assert!(orch.list_tasks().unwrap().is_empty()); -} - -#[test] -fn rejects_bad_input_and_non_repos() { - let tmp = tempfile::tempdir().unwrap(); - let agent = fake_agent(tmp.path(), "true"); - let orch = Orchestrator::open(tmp.path().join("orch"), programs(&agent)).unwrap(); - - let not_repo = tmp.path().join("plain"); - fs::create_dir_all(¬_repo).unwrap(); - let err = orch - .create_task(&request(¬_repo, "x")) - .unwrap_err() - .to_string(); - assert_eq!(err, "That folder isn't inside a git repository"); - assert!(orch - .create_task(&request(Path::new("relative/path"), "x")) - .is_err()); - - let repo = repo(tmp.path()); - assert!(orch.create_task(&request(&repo, " ")).is_err()); - let mut bad = request(&repo, "ok"); - bad.prompt = " ".into(); - assert!(orch.create_task(&bad).is_err()); - assert!(orch.list_tasks().unwrap().is_empty()); -} - -#[test] -fn create_task_stores_model_and_effort_and_rejects_flag_like_models() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = recording_agent(tmp.path()); - let root = tmp.path().join("orch"); - let orch = Orchestrator::open(&root, programs(&agent)).unwrap(); - let mut req = request(&repo, "opus work"); - req.model = Some("opus".into()); - req.effort = Some(TaskEffort::High); - let id = orch.create_task(&req).unwrap().id().to_string(); - let done = wait_for(&orch, &id, TaskState::Finished); - assert_eq!(done.model.as_deref(), Some("opus")); - assert_eq!(done.effort, Some(TaskEffort::High)); - let args = call_args(&root, 0); - assert!( - args.windows(2).any(|w| w == ["--model", "opus"]), - "{args:?}" - ); - assert!( - args.windows(2).any(|w| w == ["--effort", "high"]), - "{args:?}" - ); - - let mut evil = request(&repo, "evil"); - evil.model = Some("--dangerously-skip-permissions".into()); - assert!(orch - .create_task(&evil) - .unwrap_err() - .to_string() - .contains("model")); -} - -#[test] -fn missing_agent_binary_fails_the_task_instead_of_erroring() { - let tmp = tempfile::tempdir().unwrap(); - let repo = repo(tmp.path()); - let orch = Orchestrator::open( - tmp.path().join("orch"), - programs(&tmp.path().join("no-such-agent")), - ) - .unwrap(); - let t = lore_orchestrator::describe(&orch.create_task(&request(&repo, "x")).unwrap()); - assert_eq!(t.state, TaskState::Failed); - assert!(t.last_activity.unwrap().contains("could not start")); -} - -fn git_out(dir: &Path, args: &[&str]) -> String { - let out = Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .output() - .unwrap(); - String::from_utf8_lossy(&out.stdout).trim().to_string() -} - -fn commit_env_repo(tmp: &Path) -> PathBuf { - let repo = repo(tmp); - // Identity for commits/merges made by Lore in tests (worktrees share config). - sh(&repo, &["config", "user.name", "t"]); - sh(&repo, &["config", "user.email", "t@t"]); - sh(&repo, &["config", "commit.gpgsign", "false"]); - repo -} - -/// Records each invocation's args, prints a Claude-style init with a session id, -/// and appends a line to the file named by the last argument's first word. -fn recording_agent(tmp: &Path) -> PathBuf { - fake_agent( - tmp, - r#"n=$(ls "$PWD"/../../calls-* 2>/dev/null | wc -l | tr -d ' ') -printf '%s\n' "$@" > "$PWD/../../calls-$n.txt" -echo '{"type":"system","subtype":"init","session_id":"0f8fad5b-d9cb-469f-a165-70867728950e"}' -echo "edit $n" >> shared.txt -echo '{"type":"result","is_error":false,"result":"ok"}'"#, - ) -} - -fn call_args(root: &Path, n: usize) -> Vec { - fs::read_to_string(root.join(format!("calls-{n}.txt"))) - .unwrap() - .lines() - .map(str::to_string) - .collect() -} - -#[test] -fn continue_resumes_claude_and_hands_off_to_codex_with_a_brief() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = recording_agent(tmp.path()); - let root = tmp.path().join("orch"); - let orch = Orchestrator::open(&root, programs(&agent)).unwrap(); - - let t = orch.create_task(&request(&repo, "shared work")).unwrap(); - let id = t.id().to_string(); - wait_for(&orch, &id, TaskState::Finished); - - // Same agent: resumes its own session with just the new prompt. - let cont = lore_ipc::ContinueTaskRequest { - id: id.clone(), - prompt: "also add docs".into(), - agent: None, - model: None, - effort: None, - }; - orch.continue_task(&cont).unwrap(); - let done = wait_for(&orch, &id, TaskState::Finished); - assert_eq!(done.runs, Some(2)); - let a1 = call_args(&root, 1); - assert!( - a1.iter() - .any(|a| a == "--resume=0f8fad5b-d9cb-469f-a165-70867728950e"), - "resumed: {a1:?}" - ); - assert_eq!(a1.last().unwrap(), "also add docs"); - - // Running tasks cannot be continued. - // Handoff to Codex: no resume, prompt is a brief that carries context. - let handoff = lore_ipc::ContinueTaskRequest { - id: id.clone(), - prompt: "finish the tests".into(), - agent: Some(TaskAgent::Codex), - model: None, - effort: None, - }; - orch.commit_task(&id, "wip").unwrap(); - orch.continue_task(&handoff).unwrap(); - let done = wait_for(&orch, &id, TaskState::Finished); - assert_eq!(done.agent, TaskAgent::Codex); - let a2 = call_args(&root, 2); - assert!(!a2.iter().any(|a| a.starts_with("--resume"))); - // The brief is one multi-line argument; the recorder writes one arg per line. - let dashdash = a2.iter().position(|a| a == "--").unwrap(); - let brief = a2[dashdash + 1..].join("\n"); - assert!(brief.contains("## Original task: shared work"), "{brief}"); - assert!(brief.contains("wip"), "brief lists commits: {brief}"); - assert!(brief.contains("finish the tests")); - assert!(done.last_activity.is_some()); -} - -#[test] -fn commit_and_merge_into_primary_checkout() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = fake_agent(tmp.path(), "echo feature > feature.txt"); - let orch = Orchestrator::open(tmp.path().join("orch"), programs(&agent)).unwrap(); - let id = orch - .create_task(&request(&repo, "feature")) - .unwrap() - .id() - .to_string(); - let done = wait_for(&orch, &id, TaskState::Finished); - assert_eq!(done.uncommitted_count, Some(1)); - - // Uncommitted work blocks merging. - assert!(orch - .merge_task(&id) - .unwrap_err() - .to_string() - .contains("commit them first")); - orch.commit_task(&id, "").unwrap(); - assert_eq!(orch.task(&id).unwrap().commits_ahead, 1); - assert!(orch.commit_task(&id, "again").is_err(), "nothing to commit"); - - // A dirty primary checkout blocks merging and is left untouched. - fs::write(repo.join("README.md"), "local edit\n").unwrap(); - assert!(orch - .merge_task(&id) - .unwrap_err() - .to_string() - .contains("uncommitted")); - sh(&repo, &["checkout", "--", "README.md"]); - - let result = orch.merge_task(&id).unwrap(); - assert!(result.merged, "{}", result.message); - assert_eq!(result.into_branch, "main"); - assert_eq!( - fs::read_to_string(repo.join("feature.txt")).unwrap(), - "feature\n" - ); - assert_eq!(orch.task(&id).unwrap().merged_into.as_deref(), Some("main")); - assert!(orch - .continue_task(&lore_ipc::ContinueTaskRequest { - id: id.clone(), - prompt: "more".into(), - agent: None, - model: None, - effort: None, - }) - .is_err()); -} - -#[test] -fn conflicting_merge_is_aborted_and_overlaps_are_reported() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = fake_agent(tmp.path(), "echo \"$PWD\" > README.md"); - let orch = Orchestrator::open(tmp.path().join("orch"), programs(&agent)).unwrap(); - let a = orch - .create_task(&request(&repo, "a")) - .unwrap() - .id() - .to_string(); - let b = orch - .create_task(&request(&repo, "b")) - .unwrap() - .id() - .to_string(); - wait_for(&orch, &a, TaskState::Finished); - wait_for(&orch, &b, TaskState::Finished); - - let mut all = orch.list_tasks().unwrap(); - lore_orchestrator::annotate_overlaps(&mut all); - let for_a = all - .iter() - .find(|t| t.id == a) - .unwrap() - .overlaps - .clone() - .unwrap(); - assert_eq!(for_a.len(), 1); - assert_eq!(for_a[0].task_id, b); - assert_eq!(for_a[0].files, ["README.md"]); - - orch.commit_task(&a, "a").unwrap(); - orch.commit_task(&b, "b").unwrap(); - assert!(orch.merge_task(&a).unwrap().merged); - let head = git_out(&repo, &["rev-parse", "HEAD"]); - let conflict = orch.merge_task(&b).unwrap(); - assert!(!conflict.merged); - assert_eq!(conflict.conflicts, ["README.md"]); - // Aborted: same HEAD, clean tree, no merge in progress. - assert_eq!(git_out(&repo, &["rev-parse", "HEAD"]), head); - assert_eq!(git_out(&repo, &["status", "--porcelain"]), ""); - assert!(!repo.join(".git/MERGE_HEAD").exists()); - - // Merged tasks no longer count as overlapping. - let mut all = orch.list_tasks().unwrap(); - lore_orchestrator::annotate_overlaps(&mut all); - assert!(all.iter().all(|t| t.overlaps.as_ref().unwrap().is_empty())); -} - -#[test] -fn merge_refuses_a_worktree_off_its_branch() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = fake_agent(tmp.path(), "echo x > x.txt"); - let orch = Orchestrator::open(tmp.path().join("orch"), programs(&agent)).unwrap(); - let id = orch - .create_task(&request(&repo, "detach")) - .unwrap() - .id() - .to_string(); - let done = wait_for(&orch, &id, TaskState::Finished); - orch.commit_task(&id, "x").unwrap(); - // The agent detaches HEAD and commits somewhere the branch ref does not see. - sh( - Path::new(&done.worktree_path), - &["checkout", "-q", "--detach"], - ); - let head = git_out(&repo, &["rev-parse", "HEAD"]); - let err = orch.merge_task(&id).unwrap_err().to_string(); - assert!(err.contains("no longer on"), "{err}"); - assert_eq!(git_out(&repo, &["rev-parse", "HEAD"]), head); - assert!(orch.task(&id).unwrap().merged_into.is_none()); -} - -#[test] -fn a_second_orchestrator_on_the_same_root_is_refused() { - let tmp = tempfile::tempdir().unwrap(); - let agent = fake_agent(tmp.path(), "true"); - let root = tmp.path().join("orch"); - let first = Orchestrator::open(&root, programs(&agent)).unwrap(); - let err = Orchestrator::open(&root, programs(&agent)).err().unwrap(); - assert!(err.to_string().contains("already running"), "{err}"); - drop(first); - if let Err(e) = Orchestrator::open(&root, programs(&agent)) { - let holders = Command::new("lsof") - .arg(root.join("lock")) - .output() - .unwrap(); - panic!( - "reopen failed: {e}\n{}", - String::from_utf8_lossy(&holders.stdout) - ); - } -} - -#[test] -fn only_opened_roots_and_task_worktrees_are_browsable() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let other = tmp.path().join("other"); - fs::create_dir_all(&other).unwrap(); - sh(&other, &["init", "-q"]); - let agent = fake_agent(tmp.path(), "true"); - let orch = Orchestrator::open(tmp.path().join("orch"), programs(&agent)).unwrap(); - - let s = |p: &Path| p.display().to_string(); - assert!(orch.browsable_root(&s(&repo)).is_err(), "not opened yet"); - orch.open_workspace(&s(&repo.join("."))).unwrap(); - assert!(orch.browsable_root(&s(&repo)).is_ok()); - assert!(orch.browsable_root(&s(&other)).is_err(), "unrelated repo"); - - let t = orch.create_task(&request(&repo, "wt")).unwrap(); - assert!(orch.browsable_root(&s(t.worktree_path())).is_ok()); -} - -#[test] -fn activity_paths_are_relative_to_the_worktree() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = fake_agent(tmp.path(), "true"); - let root = tmp.path().join("orch"); - let orch = Orchestrator::open(&root, programs(&agent)).unwrap(); - let snap = orch.create_task(&request(&repo, "paths")).unwrap(); - wait_for(&orch, snap.id(), TaskState::Finished); - let abs = snap.worktree_path().join("greet.py"); - let log = root.join("logs").join(format!("{}.log", snap.id())); - fs::write( - &log, - format!( - "{{\"type\":\"assistant\",\"message\":{{\"content\":[{{\"type\":\"tool_use\",\"name\":\"Edit\",\"input\":{{\"file_path\":\"{}\"}}}}]}}}}\n", - abs.display() - ), - ) - .unwrap(); - let events = lore_orchestrator::activity(&orch.snapshot(snap.id()).unwrap(), 20); - let texts: Vec<_> = events.iter().map(|e| e.text.as_str()).collect(); - assert!(texts.contains(&"Edit greet.py"), "{texts:?}"); - assert!( - events.iter().all(|e| !e.text.contains("worktrees")), - "{texts:?}" - ); -} - -#[test] -fn open_workspace_maps_a_non_git_folder_to_a_plain_message() { - let tmp = tempfile::tempdir().unwrap(); - let agent = fake_agent(tmp.path(), "true"); - let orch = Orchestrator::open(tmp.path().join("orch"), programs(&agent)).unwrap(); - let plain = tmp.path().join("not-a-repo"); - fs::create_dir_all(&plain).unwrap(); - let err = orch - .open_workspace(&plain.display().to_string()) - .unwrap_err() - .to_string(); - assert_eq!(err, "That folder isn't inside a git repository"); - assert!(!err.contains("fatal"), "{err}"); - assert!(!err.contains("rev-parse"), "{err}"); -} - -#[test] -fn slow_stop_does_not_block_status_and_helpers_block_commit_until_gone() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - // Ignores SIGTERM, so Stop has to wait out the grace period before SIGKILL. - let agent = fake_agent( - tmp.path(), - "trap '' TERM\necho > edit.txt\nwhile true; do sleep 0.1; done", - ); - let orch = - std::sync::Arc::new(Orchestrator::open(tmp.path().join("orch"), programs(&agent)).unwrap()); - let id = orch - .create_task(&request(&repo, "stubborn")) - .unwrap() - .id() - .to_string(); - std::thread::sleep(Duration::from_millis(300)); - - let stopper = { - let orch = orch.clone(); - let id = id.clone(); - std::thread::spawn(move || orch.stop_task(&id).unwrap()) - }; - std::thread::sleep(Duration::from_millis(200)); - let started = Instant::now(); - let state = orch - .snapshot(&id) - .map(|s| lore_orchestrator::describe(&s).state) - .unwrap(); - assert!( - started.elapsed() < Duration::from_secs(1), - "status blocked behind Stop" - ); - assert_eq!(state, TaskState::Stopped); - stopper.join().unwrap(); - - // A finished agent whose helper ignores TERM: commit waits for it to be killed. - let agent2 = fake_agent( - tmp.path(), - "(trap '' TERM; while true; do sleep 0.1; done) &\necho x > y.txt", - ); - let orch2 = Orchestrator::open(tmp.path().join("orch2"), programs(&agent2)).unwrap(); - let id2 = orch2 - .create_task(&request(&repo, "helper")) - .unwrap() - .id() - .to_string(); - wait_for(&orch2, &id2, TaskState::Finished); - let err = orch2.commit_task(&id2, "c").unwrap_err().to_string(); - assert!(err.contains("still exiting"), "{err}"); - assert!( - wait_until(|| { - let _ = orch2.snapshot(&id2); - orch2.commit_task(&id2, "c").is_ok() - }), - "helper was never killed" - ); -} - -#[test] -fn continue_rolls_back_when_the_store_cannot_be_saved() { - use std::os::unix::fs::PermissionsExt; - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = fake_agent(tmp.path(), "sleep 1000 &\necho x > f.txt\nwait"); - let root = tmp.path().join("orch"); - let orch = Orchestrator::open(&root, programs(&fake_agent(tmp.path(), "true"))).unwrap(); - let id = orch - .create_task(&request(&repo, "r")) - .unwrap() - .id() - .to_string(); - wait_for(&orch, &id, TaskState::Finished); - orch.set_programs(programs(&agent)); - - fs::set_permissions(&root, fs::Permissions::from_mode(0o500)).unwrap(); - let result = orch.continue_task(&lore_ipc::ContinueTaskRequest { - id: id.clone(), - prompt: "more".into(), - agent: None, - model: None, - effort: None, - }); - fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); - assert!(result.is_err()); - let t = orch.task(&id).unwrap(); - assert_eq!(t.state, TaskState::Finished, "state reverted"); - assert_eq!(t.runs, Some(1)); -} - -#[test] -fn changed_file_totals_and_bounded_diffs() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = fake_agent( - tmp.path(), - "i=0; while [ $i -lt 1100 ]; do echo $i > f$i.txt; i=$((i+1)); done\nhead -c 3000000 /dev/zero | tr '\\0' 'a' > big.txt", - ); - let orch = Orchestrator::open(tmp.path().join("orch"), programs(&agent)).unwrap(); - let id = orch - .create_task(&request(&repo, "many")) - .unwrap() - .id() - .to_string(); - let t = wait_for(&orch, &id, TaskState::Finished); - assert_eq!(t.changed_files.len(), 1000); - assert_eq!(t.changed_files_total, Some(1101)); - let diff = lore_orchestrator::diff(&orch.snapshot(&id).unwrap()).unwrap(); - assert!(diff.truncated); - assert!(diff.text.len() <= 2 * 1024 * 1024); -} - -#[test] -fn claims_are_shared_with_agents_and_guard_commits() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = recording_agent(tmp.path()); - let root = tmp.path().join("orch"); - let orch = Orchestrator::open(&root, programs(&agent)).unwrap(); - - let mut owner = request(&repo, "auth work"); - owner.claims = Some(vec!["src/auth/".into(), "README.md".into()]); - let owner_id = orch.create_task(&owner).unwrap().id().to_string(); - wait_for(&orch, &owner_id, TaskState::Finished); - - // A second task is told what the first one owns. - let other_id = orch - .create_task(&request(&repo, "other work")) - .unwrap() - .id() - .to_string(); - wait_for(&orch, &other_id, TaskState::Finished); - let prompt = call_args(&root, 1).join("\n"); - assert!( - prompt.contains("Working alongside other agents"), - "{prompt}" - ); - assert!(prompt.contains("\"auth work\""), "{prompt}"); - assert!(prompt.contains("README.md, src/auth/"), "{prompt}"); - assert!(prompt.contains("## Your task"), "{prompt}"); - - let log = fs::read_to_string(root.join("logs").join(format!("{other_id}.log"))).unwrap(); - assert!( - log.contains("Working alongside other agents"), - "opening prompt must be in the task log so History can show it:\n{log}" - ); - assert!(log.contains("\"auth work\""), "{log}"); - - // The second task edited a claimed file: commit is refused, then forced. - let wt = orch - .snapshot(&other_id) - .unwrap() - .worktree_path() - .to_path_buf(); - fs::write(wt.join("README.md"), "trespass\n").unwrap(); - let err = orch.commit_task(&other_id, "x").unwrap_err().to_string(); - assert!(err.contains("another agent owns"), "{err}"); - assert!(err.contains("README.md"), "{err}"); - orch.commit_task_forced(&other_id, "x", true).unwrap(); - - let mut all = orch.list_tasks().unwrap(); - lore_orchestrator::annotate_overlaps(&mut all); - let other = all.iter().find(|t| t.id == other_id).unwrap(); - let conflicts = other.claim_conflicts.clone().unwrap(); - assert_eq!(conflicts.len(), 1); - assert_eq!(conflicts[0].task_id, owner_id); - assert_eq!(conflicts[0].files, ["README.md"]); - assert_eq!( - all.iter().find(|t| t.id == owner_id).unwrap().claims, - Some(vec!["README.md".to_string(), "src/auth/".to_string()]) - ); - - // Bad claims are refused. - let mut bad = request(&repo, "bad"); - bad.claims = Some(vec!["../escape".into()]); - assert!(orch.create_task(&bad).is_err()); -} - -#[test] -fn decisions_are_recorded_and_reach_the_next_agent() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = recording_agent(tmp.path()); - let root = tmp.path().join("orch"); - let orch = Orchestrator::open(&root, programs(&agent)).unwrap(); - - let first = orch - .create_task(&request(&repo, "first task")) - .unwrap() - .id() - .to_string(); - wait_for(&orch, &first, TaskState::Finished); - orch.commit_task(&first, "first commit").unwrap(); - assert!(orch.merge_task(&first).unwrap().merged); - - let kinds: Vec = orch - .decisions(None, 50) - .into_iter() - .map(|d| d.kind) - .collect(); - assert!(kinds.contains(&"created".to_string())); - assert!(kinds.contains(&"committed".to_string())); - assert!(kinds.contains(&"merged".to_string())); - assert_eq!(orch.decisions(Some("/nowhere"), 50).len(), 0); - - // A handoff brief carries that history to the next agent. - let second = orch - .create_task(&request(&repo, "second task")) - .unwrap() - .id() - .to_string(); - wait_for(&orch, &second, TaskState::Finished); - orch.continue_task(&lore_ipc::ContinueTaskRequest { - id: second.clone(), - prompt: "keep going".into(), - agent: Some(TaskAgent::Codex), - model: None, - effort: None, - }) - .unwrap(); - wait_for(&orch, &second, TaskState::Finished); - let brief = call_args(&root, 2).join("\n"); - assert!( - brief.contains("What other agents did in this repository"), - "{brief}" - ); - assert!(brief.contains("first task"), "{brief}"); -} - -#[test] -fn a_usage_limit_hands_off_to_the_other_agent_once() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - // Mimics Codex 0.154.0 output when out of quota, then succeeds on the retry. - let agent = fake_agent( - tmp.path(), - r#"n=$(ls "$PWD"/../../calls-* 2>/dev/null | wc -l | tr -d ' ') -printf '%s\n' "$@" > "$PWD/../../calls-$n.txt" -if [ "$n" = "0" ]; then - echo '{"type":"error","message":"You'"'"'ve hit your usage limit. Try again at 4:30 PM."}' - exit 1 -fi -echo '{"type":"result","is_error":false,"result":"picked it up"}'"#, - ); - let root = tmp.path().join("orch"); - let orch = Orchestrator::open(&root, programs(&agent)).unwrap(); - let mut req = request(&repo, "limited"); - req.auto_handoff = Some(true); - let id = orch.create_task(&req).unwrap().id().to_string(); - let failed = wait_for(&orch, &id, TaskState::Failed); - assert!(failed.attention.unwrap().contains("Usage limit")); - - assert_eq!(orch.run_auto_handoffs(), vec![id.clone()]); - let after = wait_for(&orch, &id, TaskState::Finished); - assert_eq!(after.agent, TaskAgent::Codex, "handed to the other agent"); - assert_eq!(after.runs, Some(2)); - // Only once per run, and the handoff is in the shared log. - assert!(orch.run_auto_handoffs().is_empty()); - assert!(orch - .decisions(None, 20) - .iter() - .any(|d| d.kind == "auto_handoff")); -} - -#[test] -fn stop_kills_the_agent_even_when_saving_fails() { - use std::os::unix::fs::PermissionsExt; - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = fake_agent( - tmp.path(), - "echo $$ > \"$PWD/../../agent.pid\"\nsleep 1000 &\nwait", - ); - let root = tmp.path().join("orch"); - let orch = Orchestrator::open(&root, programs(&agent)).unwrap(); - let id = orch - .create_task(&request(&repo, "unsavable")) - .unwrap() - .id() - .to_string(); - assert!(wait_until(|| root.join("agent.pid").exists())); - let pid = fs::read_to_string(root.join("agent.pid")) - .unwrap() - .trim() - .to_string(); - - fs::set_permissions(&root, fs::Permissions::from_mode(0o500)).unwrap(); - let result = orch.stop_task(&id); - fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); - assert!(result.is_err(), "save failure is reported"); - assert!( - wait_until(|| !pid_alive(&pid)), - "agent survived a failed save" - ); -} - -#[test] -fn auto_handoff_does_not_bounce_when_both_agents_are_limited() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = fake_agent( - tmp.path(), - r#"echo '{"type":"error","message":"You'"'"'ve hit your usage limit."}' -exit 1"#, - ); - let orch = Orchestrator::open(tmp.path().join("orch"), programs(&agent)).unwrap(); - let mut req = request(&repo, "both limited"); - req.auto_handoff = Some(true); - let id = orch.create_task(&req).unwrap().id().to_string(); - wait_for(&orch, &id, TaskState::Failed); - assert_eq!(orch.run_auto_handoffs().len(), 1); - wait_for(&orch, &id, TaskState::Failed); - assert!( - orch.run_auto_handoffs().is_empty(), - "second automatic handoff must not happen" - ); - assert_eq!(orch.task(&id).unwrap().runs, Some(2)); - - // A transient rate-limit message is not a usage limit. - let flaky = fake_agent( - tmp.path(), - "echo 'API error 429: rate limit, retrying'\necho '{\"type\":\"result\",\"is_error\":false,\"result\":\"done\"}'", - ); - let orch2 = Orchestrator::open(tmp.path().join("orch2"), programs(&flaky)).unwrap(); - let mut req2 = request(&repo, "flaky"); - req2.auto_handoff = Some(true); - let id2 = orch2.create_task(&req2).unwrap().id().to_string(); - wait_for(&orch2, &id2, TaskState::Finished); - assert!(orch2.task(&id2).unwrap().attention.is_none()); - assert!(orch2.run_auto_handoffs().is_empty()); -} - -#[test] -fn concurrent_merges_into_one_repository_are_serialized() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = fake_agent(tmp.path(), "echo \"$$\" > \"f-$$.txt\""); - let orch = - std::sync::Arc::new(Orchestrator::open(tmp.path().join("orch"), programs(&agent)).unwrap()); - let ids: Vec = (0..4) - .map(|i| { - orch.create_task(&request(&repo, &format!("m{i}"))) - .unwrap() - .id() - .to_string() - }) - .collect(); - for id in &ids { - wait_for(&orch, id, TaskState::Finished); - orch.commit_task(id, "c").unwrap(); - } - let handles: Vec<_> = ids - .iter() - .cloned() - .map(|id| { - let orch = orch.clone(); - std::thread::spawn(move || orch.merge_task(&id)) - }) - .collect(); - let outcomes: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect(); - let merged = outcomes - .iter() - .filter(|r| matches!(r, Ok(m) if m.merged)) - .count(); - for r in &outcomes { - if let Err(e) = r { - assert!(e.to_string().contains("another merge"), "unexpected: {e}"); - } - } - // Whatever merged must still be on main: nothing was rewound. - let log = git_out(&repo, &["log", "--oneline", "main"]); - assert_eq!(log.matches("Merge lore/").count(), merged, "{log}"); - assert_eq!(git_out(&repo, &["status", "--porcelain"]), ""); -} - -#[test] -fn discard_succeeds_when_the_branch_is_checked_out_elsewhere() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = fake_agent(tmp.path(), "true"); - let orch = Orchestrator::open(tmp.path().join("orch"), programs(&agent)).unwrap(); - let t = orch.create_task(&request(&repo, "inspect me")).unwrap(); - let id = t.id().to_string(); - let branch = lore_orchestrator::describe(&t).branch; - wait_for(&orch, &id, TaskState::Finished); - // The user checks the branch out in a second worktree of their own. - let mine = tmp.path().join("mine"); - sh( - &repo, - &[ - "worktree", - "add", - "-q", - "-f", - mine.to_str().unwrap(), - &branch, - ], - ); - orch.discard_task(&id).unwrap(); - assert!(orch.list_tasks().unwrap().is_empty()); - assert!(orch - .decisions(None, 5) - .iter() - .any(|d| d.kind == "discarded" && d.detail.contains("branch kept"))); -} - -fn finished_committed_task(orch: &Orchestrator, repo: &Path, title: &str) -> String { - let id = orch - .create_task(&request(repo, title)) - .unwrap() - .id() - .to_string(); - wait_for(orch, &id, TaskState::Finished); - orch.commit_task(&id, title).unwrap(); - id -} - -#[test] -fn merge_queue_updates_tests_and_merges_in_order() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - // Each agent writes its own file. - let agent = fake_agent( - tmp.path(), - "echo \"$PWD\" > \"out-$(basename \"$PWD\").txt\"", - ); - let orch = Orchestrator::open(tmp.path().join("orch"), programs(&agent)).unwrap(); - let a = finished_committed_task(&orch, &repo, "first"); - let b = finished_committed_task(&orch, &repo, "second"); - - // The test command sees the target branch's latest changes: by the time the - // second task is tested, the first task's file must be present. - let repo_s = repo.display().to_string(); - let first_file = format!("out-{a}.txt"); - orch.set_repo_test_command( - &repo_s, - Some(&format!( - "test -f README.md && (test \"$(basename \"$PWD\")\" = {a} || test -f {first_file})" - )), - ) - .unwrap(); - assert!(orch.repo_settings(&repo_s).unwrap().test_command.is_some()); - - let started = orch - .start_merge_queue(&repo_s, &[a.clone(), b.clone()]) - .unwrap(); - assert!(started.running); - // A second queue or a manual merge cannot run meanwhile. - assert!(orch - .start_merge_queue(&repo_s, std::slice::from_ref(&a)) - .is_err()); - assert!(orch.merge_task(&a).is_err()); - - let done = orch.run_merge_queue(&repo_s); - assert!(!done.running); - let statuses: Vec<_> = done.items.iter().map(|i| i.status.as_str()).collect(); - assert_eq!(statuses, ["merged", "merged"], "{:?}", done.items); - assert!(repo.join(&first_file).exists()); - assert!(repo.join(format!("out-{b}.txt")).exists()); - assert_eq!(git_out(&repo, &["status", "--porcelain"]), ""); - // The slot is released afterwards. - assert!(orch.merge_queue(&repo_s).is_some_and(|q| !q.running)); -} - -#[test] -fn merge_queue_stops_at_the_first_failing_test() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = fake_agent(tmp.path(), "echo x > \"f-$(basename \"$PWD\").txt\""); - let orch = Orchestrator::open(tmp.path().join("orch"), programs(&agent)).unwrap(); - let a = finished_committed_task(&orch, &repo, "good"); - let b = finished_committed_task(&orch, &repo, "bad"); - let c = finished_committed_task(&orch, &repo, "never"); - let repo_s = repo.display().to_string(); - orch.set_repo_test_command( - &repo_s, - Some(&format!( - "if [ \"$(basename \"$PWD\")\" = {b} ]; then echo boom >&2; exit 3; fi" - )), - ) - .unwrap(); - - orch.start_merge_queue(&repo_s, &[a.clone(), b.clone(), c.clone()]) - .unwrap(); - let done = orch.run_merge_queue(&repo_s); - let statuses: Vec<_> = done.items.iter().map(|i| i.status.as_str()).collect(); - assert_eq!( - statuses, - ["merged", "failed", "skipped"], - "{:?}", - done.items - ); - let detail = done.items[1].detail.clone().unwrap(); - assert!( - detail.contains("exit 3") && detail.contains("boom"), - "{detail}" - ); - assert!(orch.task(&b).unwrap().merged_into.is_none()); - assert!(orch.task(&c).unwrap().merged_into.is_none()); - assert!(orch - .decisions(None, 20) - .iter() - .any(|d| d.kind == "queue_failed")); -} - -#[test] -fn merge_queue_times_out_and_can_be_cancelled() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = fake_agent(tmp.path(), "echo x > \"f-$(basename \"$PWD\").txt\""); - let orch = - std::sync::Arc::new(Orchestrator::open(tmp.path().join("orch"), programs(&agent)).unwrap()); - let a = finished_committed_task(&orch, &repo, "slow"); - let repo_s = repo.display().to_string(); - orch.set_repo_test_command(&repo_s, Some("sleep 30")) - .unwrap(); - orch.set_test_timeout(Duration::from_millis(500)); - orch.start_merge_queue(&repo_s, std::slice::from_ref(&a)) - .unwrap(); - let started = Instant::now(); - let done = orch.run_merge_queue(&repo_s); - assert!(started.elapsed() < Duration::from_secs(10)); - assert_eq!(done.items[0].status, "failed"); - assert!(done.items[0].detail.clone().unwrap().contains("timed out")); - - // Cancel while the test command runs. - orch.set_test_timeout(Duration::from_secs(60)); - orch.start_merge_queue(&repo_s, std::slice::from_ref(&a)) - .unwrap(); - let runner = { - let orch = orch.clone(); - let repo_s = repo_s.clone(); - std::thread::spawn(move || orch.run_merge_queue(&repo_s)) - }; - assert!(wait_until(|| orch - .merge_queue(&repo_s) - .is_some_and(|q| q.items[0].status == "testing"))); - orch.cancel_merge_queue(&repo_s).unwrap(); - let done = runner.join().unwrap(); - assert_eq!(done.items[0].status, "cancelled"); - assert!(orch.task(&a).unwrap().merged_into.is_none()); -} - -#[test] -fn merge_queue_refuses_bad_input() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let other = tmp.path().join("other"); - fs::create_dir_all(&other).unwrap(); - sh(&other, &["init", "-q", "-b", "main"]); - sh(&other, &["commit", "-q", "--allow-empty", "-m", "i"]); - let agent = fake_agent(tmp.path(), "echo x > x.txt"); - let orch = Orchestrator::open(tmp.path().join("orch"), programs(&agent)).unwrap(); - let a = finished_committed_task(&orch, &repo, "a"); - assert!(orch - .start_merge_queue(&repo.display().to_string(), &[]) - .is_err()); - assert!(orch - .start_merge_queue(&other.display().to_string(), std::slice::from_ref(&a)) - .is_err()); - assert!(orch - .set_repo_test_command(&repo.display().to_string(), Some(&"x".repeat(5000))) - .is_err()); -} - -#[test] -fn queue_update_keeps_task_diffs_about_its_own_work() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = fake_agent(tmp.path(), "echo mine > \"own-$(basename \"$PWD\").txt\""); - let orch = Orchestrator::open(tmp.path().join("orch"), programs(&agent)).unwrap(); - let a = finished_committed_task(&orch, &repo, "a"); - let b = finished_committed_task(&orch, &repo, "b"); - let repo_s = repo.display().to_string(); - // b's tests fail, so its update merge stays on its branch. - orch.set_repo_test_command( - &repo_s, - Some(&format!("test \"$(basename \"$PWD\")\" != {b}")), - ) - .unwrap(); - orch.start_merge_queue(&repo_s, &[a.clone(), b.clone()]) - .unwrap(); - let done = orch.run_merge_queue(&repo_s); - assert_eq!(done.items[1].status, "failed"); - - // b now contains a's file, but b's diff and changed files are only b's own. - let b_task = orch.task(&b).unwrap(); - assert!(Path::new(&b_task.worktree_path) - .join(format!("own-{a}.txt")) - .exists()); - assert_eq!(b_task.changed_files, [format!("own-{b}.txt")]); - let diff = lore_orchestrator::diff(&orch.snapshot(&b).unwrap()).unwrap(); - assert!( - !diff.text.contains(&format!("own-{a}.txt")), - "{}", - diff.text - ); - - // A task with no commits of its own is refused rather than fast-forwarded. - let empty_agent = fake_agent(tmp.path(), "true"); - let orch2 = Orchestrator::open(tmp.path().join("orch2"), programs(&empty_agent)).unwrap(); - let e = orch2 - .create_task(&request(&repo, "empty")) - .unwrap() - .id() - .to_string(); - wait_for(&orch2, &e, TaskState::Finished); - orch2 - .start_merge_queue(&repo_s, std::slice::from_ref(&e)) - .unwrap(); - let done = orch2.run_merge_queue(&repo_s); - assert_eq!(done.items[0].status, "failed"); - assert!(done.items[0].detail.clone().unwrap().contains("no commits")); -} - -#[test] -fn shutdown_stops_a_running_queue_test() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = fake_agent(tmp.path(), "echo x > \"f-$(basename \"$PWD\").txt\""); - let orch = - std::sync::Arc::new(Orchestrator::open(tmp.path().join("orch"), programs(&agent)).unwrap()); - let a = finished_committed_task(&orch, &repo, "slow"); - let repo_s = repo.display().to_string(); - let marker = tmp.path().join("test.pid"); - orch.set_repo_test_command( - &repo_s, - Some(&format!("echo $$ > '{}'; sleep 300", marker.display())), - ) - .unwrap(); - orch.start_merge_queue(&repo_s, std::slice::from_ref(&a)) - .unwrap(); - let runner = { - let orch = orch.clone(); - let repo_s = repo_s.clone(); - std::thread::spawn(move || orch.run_merge_queue(&repo_s)) - }; - assert!(wait_until( - || marker.exists() && !fs::read_to_string(&marker).unwrap().trim().is_empty() - )); - let pid = fs::read_to_string(&marker).unwrap().trim().to_string(); - orch.shutdown(); - let done = runner.join().unwrap(); - assert!(!done.running); - assert_eq!(done.items[0].status, "cancelled"); - assert!( - wait_until(|| !pid_alive(&pid)), - "test command survived shutdown" - ); - assert!(orch.task(&a).unwrap().merged_into.is_none()); -} - -#[test] -fn merge_queue_ignores_bytecode_created_by_tests() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = fake_agent(tmp.path(), "echo x > \"f-$(basename \"$PWD\").txt\""); - let orch = Orchestrator::open(tmp.path().join("orch"), programs(&agent)).unwrap(); - let a = finished_committed_task(&orch, &repo, "py"); - let repo_s = repo.display().to_string(); - orch.set_repo_test_command( - &repo_s, - Some("mkdir -p __pycache__ && echo bytecode > __pycache__/mod.cpython-312.pyc"), - ) - .unwrap(); - orch.start_merge_queue(&repo_s, std::slice::from_ref(&a)) - .unwrap(); - let done = orch.run_merge_queue(&repo_s); - assert_eq!(done.items[0].status, "merged", "{:?}", done.items); - let wt = PathBuf::from(&orch.task(&a).unwrap().worktree_path); - assert!(!wt.join("__pycache__").exists()); -} - -#[test] -fn merge_queue_keeps_preexisting_untracked_user_files() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = fake_agent(tmp.path(), "echo x > \"f-$(basename \"$PWD\").txt\""); - let orch = Orchestrator::open(tmp.path().join("orch"), programs(&agent)).unwrap(); - let a = finished_committed_task(&orch, &repo, "notes"); - let wt = PathBuf::from(&orch.task(&a).unwrap().worktree_path); - fs::write(wt.join("user-notes.txt"), "keep me\n").unwrap(); - let repo_s = repo.display().to_string(); - orch.set_repo_test_command( - &repo_s, - Some("mkdir -p __pycache__; echo x > __pycache__/x.pyc"), - ) - .unwrap(); - orch.start_merge_queue(&repo_s, std::slice::from_ref(&a)) - .unwrap(); - let done = orch.run_merge_queue(&repo_s); - assert_eq!(done.items[0].status, "failed", "{:?}", done.items); - assert!( - done.items[0] - .detail - .clone() - .unwrap() - .contains("uncommitted"), - "{:?}", - done.items[0].detail - ); - assert_eq!( - fs::read_to_string(wt.join("user-notes.txt")).unwrap(), - "keep me\n" - ); -} - -#[test] -fn merge_queue_discards_new_artifacts_but_not_new_user_files() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = fake_agent(tmp.path(), "echo x > \"f-$(basename \"$PWD\").txt\""); - let orch = Orchestrator::open(tmp.path().join("orch"), programs(&agent)).unwrap(); - let a = finished_committed_task(&orch, &repo, "mix"); - let repo_s = repo.display().to_string(); - orch.set_repo_test_command( - &repo_s, - Some("mkdir -p __pycache__ && echo x > __pycache__/mod.pyc && echo leaked > user_note.txt"), - ) - .unwrap(); - orch.start_merge_queue(&repo_s, std::slice::from_ref(&a)) - .unwrap(); - let done = orch.run_merge_queue(&repo_s); - assert_eq!(done.items[0].status, "failed", "{:?}", done.items); - let detail = done.items[0].detail.clone().unwrap(); - assert!(detail.contains("user_note.txt"), "{detail}"); - assert!(!detail.contains("__pycache__"), "{detail}"); - let wt = PathBuf::from(&orch.task(&a).unwrap().worktree_path); - assert_eq!( - fs::read_to_string(wt.join("user_note.txt")).unwrap(), - "leaked\n" - ); - assert!(!wt.join("__pycache__").exists()); -} - -#[test] -fn merge_queue_refuses_overlapping_claims_before_running() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = fake_agent(tmp.path(), "echo x > \"f-$(basename \"$PWD\").txt\""); - let orch = Orchestrator::open(tmp.path().join("orch"), programs(&agent)).unwrap(); - let mut first = request(&repo, "Add multiply"); - first.claims = Some(vec!["calc.py".into()]); - let mut second = request(&repo, "Tweak calc"); - second.claims = Some(vec!["calc.py".into()]); - let a = orch.create_task(&first).unwrap().id().to_string(); - wait_for(&orch, &a, TaskState::Finished); - orch.commit_task(&a, "a").unwrap(); - let b = orch.create_task(&second).unwrap().id().to_string(); - wait_for(&orch, &b, TaskState::Finished); - orch.commit_task(&b, "b").unwrap(); - let err = orch - .start_merge_queue(&repo.display().to_string(), &[a, b]) - .unwrap_err() - .to_string(); - assert!(err.contains("cannot queue"), "{err}"); - assert!( - err.contains("Add multiply") && err.contains("Tweak calc"), - "{err}" - ); - assert!(err.contains("both claim calc.py"), "{err}"); - - let mut folder = request(&repo, "Folder owner"); - folder.claims = Some(vec!["src/".into()]); - let mut nested = request(&repo, "File owner"); - nested.claims = Some(vec!["src/auth.rs".into()]); - let c = orch.create_task(&folder).unwrap().id().to_string(); - wait_for(&orch, &c, TaskState::Finished); - orch.commit_task(&c, "c").unwrap(); - let d = orch.create_task(&nested).unwrap().id().to_string(); - wait_for(&orch, &d, TaskState::Finished); - orch.commit_task(&d, "d").unwrap(); - let err = orch - .start_merge_queue(&repo.display().to_string(), &[c, d]) - .unwrap_err() - .to_string(); - assert!(err.contains("overlaps"), "{err}"); -} - -#[test] -fn overlapping_claims_are_not_listed_as_do_not_edit() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = recording_agent(tmp.path()); - let root = tmp.path().join("orch"); - let orch = Orchestrator::open(&root, programs(&agent)).unwrap(); - let mut owner = request(&repo, "first calc"); - owner.claims = Some(vec!["calc.py".into(), "README.md".into()]); - wait_for( - &orch, - orch.create_task(&owner).unwrap().id(), - TaskState::Finished, - ); - let mut other = request(&repo, "second calc"); - other.claims = Some(vec!["calc.py".into()]); - wait_for( - &orch, - orch.create_task(&other).unwrap().id(), - TaskState::Finished, - ); - let prompt = call_args(&root, 1).join("\n"); - assert!(prompt.contains("You own: calc.py"), "{prompt}"); - assert!( - prompt.contains("overlaps \"first calc\"'s claim calc.py"), - "{prompt}" - ); - assert!( - prompt.contains("Do not edit files owned by another agent (README.md)"), - "{prompt}" - ); - assert!( - !prompt.contains("Do not edit files owned by another agent (calc.py"), - "{prompt}" - ); -} - -#[test] -fn shutdown_persists_a_cancelled_queue_across_relaunch() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = fake_agent(tmp.path(), "echo x > \"f-$(basename \"$PWD\").txt\""); - let root = tmp.path().join("orch"); - let orch = std::sync::Arc::new(Orchestrator::open(&root, programs(&agent)).unwrap()); - let a = finished_committed_task(&orch, &repo, "slow"); - let repo_s = repo.display().to_string(); - let marker = tmp.path().join("test.pid"); - orch.set_repo_test_command( - &repo_s, - Some(&format!("echo $$ > '{}'; sleep 300", marker.display())), - ) - .unwrap(); - orch.start_merge_queue(&repo_s, std::slice::from_ref(&a)) - .unwrap(); - let runner = { - let orch = orch.clone(); - let repo_s = repo_s.clone(); - std::thread::spawn(move || orch.run_merge_queue(&repo_s)) - }; - assert!(wait_until( - || marker.exists() && !fs::read_to_string(&marker).unwrap().trim().is_empty() - )); - let pid = fs::read_to_string(&marker).unwrap().trim().to_string(); - orch.shutdown(); - let _ = runner.join().unwrap(); - assert!( - wait_until(|| !pid_alive(&pid)), - "test command survived shutdown" - ); - drop(orch); - - let orch = Orchestrator::open(&root, programs(&agent)).unwrap(); - let queue = orch.merge_queue(&repo_s).expect("persisted queue"); - assert!(!queue.running); - assert_eq!(queue.items[0].status, "cancelled"); - assert!( - queue.items[0] - .detail - .as_deref() - .unwrap_or_default() - .contains("quit"), - "{:?}", - queue.items[0].detail - ); - assert!(orch - .decisions(None, 20) - .iter() - .any(|d| d.kind == "queue_cancelled" && d.detail.contains("quit"))); - assert!(!pid_alive(&pid)); -} - -#[test] -fn unclean_exit_marks_a_running_queue_interrupted_on_relaunch() { - let tmp = tempfile::tempdir().unwrap(); - let repo = commit_env_repo(tmp.path()); - let agent = fake_agent(tmp.path(), "echo x > \"f-$(basename \"$PWD\").txt\""); - let root = tmp.path().join("orch"); - let a = { - let orch = Orchestrator::open(&root, programs(&agent)).unwrap(); - let id = finished_committed_task(&orch, &repo, "left"); - orch.start_merge_queue(&repo.display().to_string(), std::slice::from_ref(&id)) - .unwrap(); - id - }; - - let orch = Orchestrator::open(&root, programs(&agent)).unwrap(); - let queue = orch - .merge_queue(&repo.display().to_string()) - .expect("persisted queue"); - assert!(!queue.running); - assert_eq!(queue.items[0].status, "interrupted"); - assert_eq!(queue.items[0].task_id, a); - assert!(queue.items[0] - .detail - .as_deref() - .unwrap_or_default() - .contains("interrupted")); - assert!(orch - .decisions(None, 20) - .iter() - .any(|d| d.kind == "queue_interrupted")); -} diff --git a/package-lock.json b/package-lock.json index 5705aec..28458e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,18 +9,10 @@ "version": "0.0.0", "license": "Apache-2.0", "dependencies": { - "@fontsource-variable/geist": "^5.3.0", - "@fontsource-variable/geist-mono": "^5.3.0", - "@fontsource-variable/inter": "^5.3.0", - "@fontsource-variable/jetbrains-mono": "^5.3.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.2", - "@tauri-apps/plugin-notification": "^2.4.0", "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-markdown": "^9.1.0", - "remark-gfm": "^4.0.1", - "sonner": "^2.0.8" + "react-dom": "^18.3.1" }, "devDependencies": { "@eslint/js": "^9", @@ -1052,42 +1044,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@fontsource-variable/geist": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz", - "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@fontsource-variable/geist-mono": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource-variable/geist-mono/-/geist-mono-5.3.0.tgz", - "integrity": "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@fontsource-variable/inter": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", - "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@fontsource-variable/jetbrains-mono": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", - "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1814,15 +1770,6 @@ "@tauri-apps/api": "^2.11.0" } }, - "node_modules/@tauri-apps/plugin-notification": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.4.0.tgz", - "integrity": "sha512-xlJXMcUoKOjNupzDue5wrEsa1wytf+l/2gCAPhafHyP683Y3N7J/8clUWLZ3vpnwkpT2C1zcLMMQFjjecIG2xg==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -1925,39 +1872,13 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/debug": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", - "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", - "license": "MIT", - "dependencies": { - "@types/ms": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, "license": "MIT" }, - "node_modules/@types/estree-jsx": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", - "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@types/hast": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", - "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1965,31 +1886,18 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -2006,12 +1914,6 @@ "@types/react": "^18.0.0" } }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", @@ -2307,12 +2209,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", - "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", - "license": "ISC" - }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -2532,16 +2428,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2662,16 +2548,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -2706,46 +2582,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-reference-invalid": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", - "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -2789,16 +2625,6 @@ "node": ">= 0.8" } }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2853,6 +2679,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, "node_modules/data-urls": { @@ -2873,6 +2700,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2893,19 +2721,6 @@ "dev": true, "license": "MIT" }, - "node_modules/decode-named-character-reference": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", - "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", - "license": "MIT", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2937,24 +2752,13 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -3276,16 +3080,6 @@ "node": ">=4.0" } }, - "node_modules/estree-util-is-identifier-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", - "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -3316,12 +3110,6 @@ "node": ">=12.0.0" } }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3594,46 +3382,6 @@ "node": ">= 0.4" } }, - "node_modules/hast-util-to-jsx-runtime": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", - "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "mdast-util-mdx-expression": "^2.0.0", - "mdast-util-mdx-jsx": "^3.0.0", - "mdast-util-mdxjs-esm": "^2.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "style-to-js": "^1.0.0", - "unist-util-position": "^5.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -3647,16 +3395,6 @@ "node": ">=18" } }, - "node_modules/html-url-attributes": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", - "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -3735,46 +3473,6 @@ "node": ">=0.8.19" } }, - "node_modules/inline-style-parser": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", - "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", - "license": "MIT" - }, - "node_modules/is-alphabetical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", - "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-alphanumerical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", - "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", - "license": "MIT", - "dependencies": { - "is-alphabetical": "^2.0.0", - "is-decimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-decimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", - "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3798,28 +3496,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-hexadecimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", - "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -3998,16 +3674,6 @@ "dev": true, "license": "MIT" }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -4058,16 +3724,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/markdown-table": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", - "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4078,892 +3734,48 @@ "node": ">= 0.4" } }, - "node_modules/mdast-util-find-and-replace": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", - "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "escape-string-regexp": "^5.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", - "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "node": ">= 0.6" } }, - "node_modules/mdast-util-gfm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", - "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "license": "MIT", "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-gfm-autolink-literal": "^2.0.0", - "mdast-util-gfm-footnote": "^2.0.0", - "mdast-util-gfm-strikethrough": "^2.0.0", - "mdast-util-gfm-table": "^2.0.0", - "mdast-util-gfm-task-list-item": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" + "mime-db": "1.52.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">= 0.6" } }, - "node_modules/mdast-util-gfm-autolink-literal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", - "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", - "license": "MIT", + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", "dependencies": { - "@types/mdast": "^4.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "micromark-util-character": "^2.0.0" + "brace-expansion": "^1.1.7" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": "*" } }, - "node_modules/mdast-util-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-strikethrough": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", - "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", - "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "markdown-table": "^3.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-task-list-item": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", - "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-expression": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", - "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-jsx": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", - "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "parse-entities": "^4.0.0", - "stringify-entities": "^4.0.0", - "unist-util-stringify-position": "^4.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdxjs-esm": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", - "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", - "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", - "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", - "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", - "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", - "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", - "license": "MIT", - "dependencies": { - "micromark-extension-gfm-autolink-literal": "^2.0.0", - "micromark-extension-gfm-footnote": "^2.0.0", - "micromark-extension-gfm-strikethrough": "^2.0.0", - "micromark-extension-gfm-table": "^2.0.0", - "micromark-extension-gfm-tagfilter": "^2.0.0", - "micromark-extension-gfm-task-list-item": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-autolink-literal": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", - "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-strikethrough": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", - "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-table": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", - "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-tagfilter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", - "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-task-list-item": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", - "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", - "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", - "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", - "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", - "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", - "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", - "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", - "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", - "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", - "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", - "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", - "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", - "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", - "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.18", @@ -5071,31 +3883,6 @@ "node": ">=6" } }, - "node_modules/parse-entities": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", - "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "character-entities-legacy": "^3.0.0", - "character-reference-invalid": "^2.0.0", - "decode-named-character-reference": "^1.0.0", - "is-alphanumerical": "^2.0.0", - "is-decimal": "^2.0.0", - "is-hexadecimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/parse-entities/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "license": "MIT" - }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -5235,16 +4022,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/property-information": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", - "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5288,33 +4065,6 @@ "license": "MIT", "peer": true }, - "node_modules/react-markdown": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", - "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "hast-util-to-jsx-runtime": "^2.0.0", - "html-url-attributes": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.0.0", - "unified": "^11.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - }, - "peerDependencies": { - "@types/react": ">=18", - "react": ">=18" - } - }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -5325,72 +4075,6 @@ "node": ">=0.10.0" } }, - "node_modules/remark-gfm": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", - "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-gfm": "^3.0.0", - "micromark-extension-gfm": "^3.0.0", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-rehype": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", - "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "mdast-util-to-hast": "^13.0.0", - "unified": "^11.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-stringify": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", - "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-to-markdown": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -5523,22 +4207,6 @@ "dev": true, "license": "ISC" }, - "node_modules/sonner": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", - "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", - "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5549,16 +4217,6 @@ "node": ">=0.10.0" } }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -5573,20 +4231,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "license": "MIT", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -5600,24 +4244,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/style-to-js": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", - "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", - "license": "MIT", - "dependencies": { - "style-to-object": "1.0.14" - } - }, - "node_modules/style-to-object": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", - "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", - "license": "MIT", - "dependencies": { - "inline-style-parser": "0.2.7" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -5745,26 +4371,6 @@ "node": ">=18" } }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/trough": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", - "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -5829,93 +4435,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/unified": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", - "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/update-browserslist-db": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", @@ -5957,34 +4476,6 @@ "punycode": "^2.1.0" } }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", @@ -7318,16 +5809,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } } } } diff --git a/package.json b/package.json index 25e095b..39045dc 100644 --- a/package.json +++ b/package.json @@ -18,18 +18,10 @@ "tauri": "tauri" }, "dependencies": { - "@fontsource-variable/geist": "^5.3.0", - "@fontsource-variable/geist-mono": "^5.3.0", - "@fontsource-variable/inter": "^5.3.0", - "@fontsource-variable/jetbrains-mono": "^5.3.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.2", - "@tauri-apps/plugin-notification": "^2.4.0", "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-markdown": "^9.1.0", - "remark-gfm": "^4.0.1", - "sonner": "^2.0.8" + "react-dom": "^18.3.1" }, "devDependencies": { "@eslint/js": "^9", diff --git a/scripts/p1-restore.sh b/scripts/p1-restore.sh new file mode 100755 index 0000000..bb0fa6c --- /dev/null +++ b/scripts/p1-restore.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Restore the session-archive desktop app from 502827d^ and delete the agent +# orchestrator that 502827d put in its place. Safe to rerun from the repo root: +# every step converges, so a second run leaves `git status` unchanged. +# +# Reviewers can rerun it on main and diff against the commit that added it. +set -euo pipefail + +ARCHIVE_REV="502827d^" + +if [[ ! -f Cargo.toml || ! -d src-tauri ]]; then + echo "run from the repo root" >&2 + exit 1 +fi + +git rev-parse --verify --quiet "${ARCHIVE_REV}^{commit}" >/dev/null || { + echo "missing ${ARCHIVE_REV}; fetch full history first" >&2 + exit 1 +} + +ORCHESTRATOR=( + crates/lore-orchestrator + src/components/workbench + src-tauri/src/commands/tasks.rs + src/mock/ipc-mock.ts +) + +ARCHIVE=( + src-tauri/src/commands/export.rs + src-tauri/src/commands/folders.rs + src-tauri/src/commands/mod.rs + src-tauri/src/commands/repositories.rs + src-tauri/src/commands/search.rs + src-tauri/src/commands/sessions.rs + src-tauri/src/commands/system.rs + src-tauri/src/lib.rs + src-tauri/src/state.rs + src/App.tsx + src/App.test.tsx + src/main.tsx + src/ipc.ts + src/ipc.test.ts + src/components + src/hooks + src/focus-trap.ts + src/focus-trap.test.tsx + src/virtual.ts + src/virtual.test.ts + src/styles.css +) + +git rm -r -q --ignore-unmatch -- "${ORCHESTRATOR[@]}" +rm -rf -- "${ORCHESTRATOR[@]}" + +git checkout "$ARCHIVE_REV" -- "${ARCHIVE[@]}" + +# Keep a helper module only while the restored UI still imports it. +for module in diff format; do + importers=$(grep -rlE "from \"(\./|\.\./)+${module}\"" src --include='*.ts' --include='*.tsx' \ + | grep -vE "^src/${module}(\.test)?\.ts$" || true) + if [[ -z "$importers" ]]; then + git rm -q --ignore-unmatch -- "src/${module}.ts" "src/${module}.test.ts" + fi +done diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index af0a22e..efe3301 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ # logic stays in lore-core, which keeps that guarantee. [package] name = "lore-app" -description = "Lore desktop shell (Tauri 2): parallel coding agents in git worktrees." +description = "Lore desktop shell (Tauri 2). Thin binary over lore-core; no archive logic lives here." version.workspace = true edition.workspace = true rust-version.workspace = true @@ -36,14 +36,13 @@ path = "src/main.rs" tauri-build = { version = "2", features = [] } [dependencies] +lore-core = { path = "../crates/lore-core" } lore-ipc = { path = "../crates/lore-ipc" } -lore-orchestrator = { path = "../crates/lore-orchestrator" } # Same version as lore-core so the Connection type unifies (features are additive # across the graph; lore-core already enables the bundled SQLite). +rusqlite = "0.32" tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" -# Local OS notifications when an agent finishes, fails, or needs the user. -tauri-plugin-notification = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c2f6fe9..ec0a4e2 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,15 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Minimal core permissions plus user-initiated folder selection, local notifications when an agent needs the user, and the dock badge count. No updater, filesystem API, shell, or network plugins.", + "description": "Minimal core permissions plus user-initiated folder selection. No updater, filesystem API, shell, or network plugins.", "windows": ["main"], - "permissions": [ - "core:default", - "core:window:allow-set-badge-count", - "dialog:allow-open", - "dialog:allow-save", - "notification:allow-is-permission-granted", - "notification:allow-request-permission", - "notification:allow-notify" - ] + "permissions": ["core:default", "dialog:allow-open", "dialog:allow-save"] } diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs new file mode 100644 index 0000000..6b5990f --- /dev/null +++ b/src-tauri/src/commands/export.rs @@ -0,0 +1,117 @@ +use std::path::{Path, PathBuf}; + +use lore_core::adapters::AdapterRegistry; +use lore_core::discovery::DiscoveryConfig; +use lore_core::is_invalid_text_token; +use tauri::{AppHandle, State}; + +use crate::state::AppState; + +/// Export a session as Markdown. `include_secrets` defaults off (masked); passing +/// true is an explicit opt-in to full-fidelity content. +#[tauri::command] +pub fn export_session_markdown( + state: State<'_, AppState>, + id: String, + include_secrets: bool, +) -> Result, String> { + if is_invalid_text_token(&id, 256) { + return Err("invalid session id".to_string()); + } + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::export::export_session_markdown(&conn, &id, include_secrets) + .map_err(|e| e.to_string()) +} + +pub fn validate_export_path( + path: &Path, + archive_dir: &Path, + registry: &AdapterRegistry, + config: &DiscoveryConfig, +) -> Result { + if !path.is_absolute() { + return Err("export path must be absolute".to_string()); + } + let path_str = path.to_string_lossy(); + if path_str.is_empty() + || path_str.len() > 4096 + || path_str + .chars() + .any(|c| c.is_control() || lore_core::is_zero_width(c)) + { + return Err("invalid export path".to_string()); + } + + let parent = path.parent().ok_or("invalid destination path")?; + let canonical_parent = parent + .canonicalize() + .map_err(|_| "destination directory does not exist".to_string())?; + let file_name = path.file_name().ok_or("invalid destination file name")?; + let target = canonical_parent.join(file_name); + + let canonical_archive = archive_dir + .canonicalize() + .unwrap_or_else(|_| archive_dir.to_path_buf()); + if target.starts_with(&canonical_archive) { + return Err("cannot export inside the Lore archive directory".to_string()); + } + + // Prohibit writing inside default or configured agent discovery and home roots + let forbidden_roots = lore_core::discovery::protected_roots(registry, config); + + for forbidden in forbidden_roots { + let canonical_forbidden = forbidden + .canonicalize() + .unwrap_or_else(|_| forbidden.clone()); + if target.starts_with(&canonical_forbidden) { + return Err("cannot export inside agent session directories".to_string()); + } + } + + Ok(target) +} + +/// Write a session's redacted Markdown export to a validated destination path. +/// If `path` is provided, it is validated against strict security boundaries +/// (must be absolute, cannot be inside archive dir or agent session dirs). +/// If `path` is omitted, the native save dialog is displayed in Rust. +#[tauri::command] +pub fn save_session_export( + app: AppHandle, + state: State<'_, AppState>, + id: String, + path: Option, + include_secrets: bool, +) -> Result { + if is_invalid_text_token(&id, 256) { + return Err("invalid session id".to_string()); + } + let config = state.config.lock().map_err(|_| "state lock poisoned")?; + + let target_path = match path { + Some(p) => { + validate_export_path(Path::new(&p), &state.archive_dir, &state.registry, &config)? + } + None => { + use tauri_plugin_dialog::DialogExt; + let file_path = app + .dialog() + .file() + .add_filter("Markdown", &["md"]) + .set_file_name("session.md") + .blocking_save_file(); + let Some(fp) = file_path else { + return Ok(false); + }; + let p = fp.as_path().ok_or("invalid file path from save dialog")?; + validate_export_path(p, &state.archive_dir, &state.registry, &config)? + } + }; + + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + let markdown = lore_core::export::export_session_markdown(&conn, &id, include_secrets) + .map_err(|e| e.to_string())? + .ok_or_else(|| "session not found".to_string())?; + std::fs::write(&target_path, markdown).map_err(|e| e.to_string())?; + Ok(true) +} diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs new file mode 100644 index 0000000..6dafcd1 --- /dev/null +++ b/src-tauri/src/commands/folders.rs @@ -0,0 +1,98 @@ +use lore_core::is_invalid_text_token; +use lore_ipc::{FolderSummary, SessionPage}; +use tauri::State; + +use crate::state::AppState; + +pub fn is_invalid_folder_name(name: &str) -> bool { + name.len() > 256 + || name + .chars() + .any(|c| c.is_control() || lore_core::is_zero_width(c)) +} + +pub fn is_invalid_folder_id(id: &str) -> bool { + is_invalid_text_token(id, 64) +} + +/// List the user-defined folders with their thread counts. +#[tauri::command] +pub fn list_folders(state: State<'_, AppState>) -> Result, String> { + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::folders::list_folders(&conn).map_err(|e| e.to_string()) +} + +/// Create a folder and return it (name is trimmed and length-capped). +#[tauri::command] +pub fn create_folder(state: State<'_, AppState>, name: String) -> Result { + if is_invalid_folder_name(&name) { + return Err("invalid folder name".to_string()); + } + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::folders::create_folder(&conn, &name).map_err(|e| e.to_string()) +} + +/// Rename a folder. +#[tauri::command] +pub fn rename_folder(state: State<'_, AppState>, id: String, name: String) -> Result<(), String> { + if is_invalid_folder_id(&id) { + return Err("invalid folder id".to_string()); + } + if is_invalid_folder_name(&name) { + return Err("invalid folder name".to_string()); + } + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::folders::rename_folder(&conn, &id, &name).map_err(|e| e.to_string()) +} + +/// Delete a folder; its threads become unfiled but are not removed from Lore. +#[tauri::command] +pub fn delete_folder(state: State<'_, AppState>, id: String) -> Result<(), String> { + if is_invalid_folder_id(&id) { + return Err("invalid folder id".to_string()); + } + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::folders::delete_folder(&conn, &id).map_err(|e| e.to_string()) +} + +/// File a thread into a folder, replacing any prior membership. A `null` +/// `folderId` unfiles the thread. +#[tauri::command] +pub fn set_session_folder( + state: State<'_, AppState>, + session_id: String, + folder_id: Option, +) -> Result<(), String> { + if is_invalid_text_token(&session_id, 256) { + return Err("invalid session id".to_string()); + } + if let Some(ref fid) = folder_id { + if is_invalid_folder_id(fid) { + return Err("invalid folder id".to_string()); + } + } + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::folders::set_session_folder(&conn, &session_id, folder_id.as_deref()) + .map_err(|e| e.to_string()) +} + +/// List one newest-first page of the threads filed in a folder. +#[tauri::command] +pub fn list_folder_sessions_page( + state: State<'_, AppState>, + id: String, + limit: i64, + cursor: Option, +) -> Result { + if is_invalid_folder_id(&id) { + return Err("invalid folder id".to_string()); + } + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::query::list_folder_sessions_page( + &conn, + &id, + limit.clamp(1, 10_000), + cursor.as_deref(), + ) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 14a4db6..f34620e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,13 @@ -pub mod tasks; +pub mod export; +pub mod folders; +pub mod repositories; +pub mod search; +pub mod sessions; +pub mod system; -pub use tasks::*; +pub use export::*; +pub use folders::*; +pub use repositories::*; +pub use search::*; +pub use sessions::*; +pub use system::*; diff --git a/src-tauri/src/commands/repositories.rs b/src-tauri/src/commands/repositories.rs new file mode 100644 index 0000000..3f1a8a1 --- /dev/null +++ b/src-tauri/src/commands/repositories.rs @@ -0,0 +1,63 @@ +use lore_core::is_invalid_text_token; +use lore_ipc::{RepositorySummary, SessionPage, SessionSummary}; +use tauri::State; + +use crate::state::AppState; + +/// List the repositories resolved by git enrichment. +#[tauri::command] +pub fn list_repositories(state: State<'_, AppState>) -> Result, String> { + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::query::list_repositories(&conn).map_err(|e| e.to_string()) +} + +/// List the most recent sessions that touched `repository_id` (newest first). +#[tauri::command] +pub fn list_repository_sessions( + state: State<'_, AppState>, + id: String, + limit: i64, +) -> Result, String> { + if is_invalid_text_token(&id, 256) { + return Err("invalid repository id".to_string()); + } + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::query::list_repository_sessions(&conn, &id, limit.clamp(1, 10_000)) + .map_err(|e| e.to_string()) +} + +/// List one newest-first page of sessions that touched a repository. +#[tauri::command] +pub fn list_repository_sessions_page( + state: State<'_, AppState>, + id: String, + limit: i64, + cursor: Option, +) -> Result { + if is_invalid_text_token(&id, 256) { + return Err("invalid repository id".to_string()); + } + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::query::list_repository_sessions_page( + &conn, + &id, + limit.clamp(1, 10_000), + cursor.as_deref(), + ) + .map_err(|e| e.to_string()) +} + +/// Relink a segment to a target repository (user correction / split-merge). +#[tauri::command] +pub fn relink_segment_repository( + state: State<'_, AppState>, + segment_id: String, + repository_id: String, +) -> Result<(), String> { + if is_invalid_text_token(&segment_id, 256) || is_invalid_text_token(&repository_id, 256) { + return Err("invalid id".to_string()); + } + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::enrich::relink_segment_repository(&conn, &segment_id, &repository_id) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/search.rs b/src-tauri/src/commands/search.rs new file mode 100644 index 0000000..ed6be68 --- /dev/null +++ b/src-tauri/src/commands/search.rs @@ -0,0 +1,45 @@ +use lore_ipc::{SearchHit, SearchPage}; +use tauri::State; + +use crate::state::AppState; + +/// Full-text search over the redacted projections (secret-safe by construction). +#[tauri::command] +pub fn search( + state: State<'_, AppState>, + query: String, + limit: i64, +) -> Result, String> { + if query.len() > 10_000 { + return Err("query exceeds maximum length".to_string()); + } + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::search::search(&conn, &query, limit.clamp(1, 10_000)).map_err(|e| e.to_string()) +} + +/// Paginated full-text search. `cursor` is `None` for the first page; pass the +/// returned `next_cursor` back verbatim for the next page (valid only for the +/// same query and sort). `sort` is `"relevance"` (default), `"newest"`, or +/// `"oldest"`. Keyset-based, so paging never drops or repeats a result. +#[tauri::command] +pub fn search_page( + state: State<'_, AppState>, + query: String, + limit: i64, + cursor: Option, + sort: Option, +) -> Result { + if query.len() > 10_000 { + return Err("query exceeds maximum length".to_string()); + } + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + let sort = lore_core::search::SortOrder::parse(sort.as_deref()); + lore_core::search::search_page( + &conn, + &query, + limit.clamp(1, 10_000), + cursor.as_deref(), + sort, + ) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/sessions.rs b/src-tauri/src/commands/sessions.rs new file mode 100644 index 0000000..459e5e9 --- /dev/null +++ b/src-tauri/src/commands/sessions.rs @@ -0,0 +1,132 @@ +use lore_core::is_invalid_text_token; +use lore_ipc::{ + ForgetReport, GitObservationDto, MessagePage, SessionDetail, SessionPage, SessionSummary, +}; +use tauri::State; + +use crate::state::AppState; + +/// List the most recent sessions (newest first), capped at `limit`. +#[tauri::command] +pub fn list_sessions( + state: State<'_, AppState>, + limit: i64, +) -> Result, String> { + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::query::list_sessions(&conn, limit.clamp(1, 10_000)).map_err(|e| e.to_string()) +} + +/// List one newest-first page of sessions using an opaque keyset cursor. +#[tauri::command] +pub fn list_sessions_page( + state: State<'_, AppState>, + limit: i64, + cursor: Option, +) -> Result { + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::query::list_sessions_page(&conn, limit.clamp(1, 10_000), cursor.as_deref()) + .map_err(|e| e.to_string()) +} + +/// Read one session in context (header, segments, ordered-part timeline, files). +#[tauri::command] +pub fn get_session( + state: State<'_, AppState>, + id: String, +) -> Result, String> { + if is_invalid_text_token(&id, 256) { + return Err("invalid session id".to_string()); + } + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::query::get_session(&conn, &id).map_err(|e| e.to_string()) +} + +/// Fetch the recorded patch text for a file event, or null when none is stored, +/// the payload is not valid UTF-8, or the blob is quarantined (its scan never +/// completed, so its content stays unavailable — SECRET_SCANNING.md §6). +#[tauri::command] +pub fn get_file_patch(state: State<'_, AppState>, id: String) -> Result, String> { + if is_invalid_text_token(&id, 256) { + return Err("invalid event id".to_string()); + } + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::query::file_patch_text(&conn, &state.blobs, &id).map_err(|e| e.to_string()) +} + +/// Read the provenance-labeled git observations for a session. +#[tauri::command] +pub fn get_git_snapshot( + state: State<'_, AppState>, + id: String, +) -> Result, String> { + if is_invalid_text_token(&id, 256) { + return Err("invalid session id".to_string()); + } + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::query::get_git_snapshot(&conn, &id).map_err(|e| e.to_string()) +} + +/// How many secrets were flagged in a session (all redacted from derived surfaces). +#[tauri::command] +pub fn session_secret_count(state: State<'_, AppState>, id: String) -> Result { + if is_invalid_text_token(&id, 256) { + return Err("invalid session id".to_string()); + } + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::query::secret_count(&conn, &id).map_err(|e| e.to_string()) +} + +/// List one stable page of messages for a session. +#[tauri::command] +pub fn list_session_messages_page( + state: State<'_, AppState>, + id: String, + limit: Option, + cursor: Option, +) -> Result { + if is_invalid_text_token(&id, 256) { + return Err("invalid session id".to_string()); + } + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::query::list_session_messages_page( + &conn, + &id, + limit.unwrap_or(200), + cursor.as_deref(), + ) + .map_err(|e| e.to_string()) +} + +/// Forget a session: remove its rows, projections, findings, and orphan blobs. +#[tauri::command] +pub fn forget_session(state: State<'_, AppState>, id: String) -> Result { + if is_invalid_text_token(&id, 256) { + return Err("invalid session id".to_string()); + } + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + let report = + lore_core::forget::forget_session(&conn, &state.blobs, &id).map_err(|e| e.to_string())?; + Ok(ForgetReport { + blobs_removed: i64::try_from(report.blobs_removed).unwrap_or(i64::MAX), + source_paths: report.source_paths, + }) +} + +/// Forget everything: wipe all archive content (sessions, repos, sources, +/// projections, findings, blobs) while keeping the database file open. Settings +/// and the job queue are preserved. +#[tauri::command] +pub fn forget_everything(state: State<'_, AppState>) -> Result { + // Wipe the live database rows and blobs… + let report = { + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::forget::forget_all(&conn, &state.blobs).map_err(|e| e.to_string())? + }; + // …then clear the on-disk stores from which that data could be recovered + // (backups hold whole-database copies), so "forget everything" truly forgets. + lore_core::forget::purge_recoverable_copies(&state.archive_dir).map_err(|e| e.to_string())?; + Ok(ForgetReport { + blobs_removed: i64::try_from(report.blobs_removed).unwrap_or(i64::MAX), + source_paths: report.source_paths, + }) +} diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs new file mode 100644 index 0000000..f3abef6 --- /dev/null +++ b/src-tauri/src/commands/system.rs @@ -0,0 +1,239 @@ +use lore_core::discovery::{watch_roots, DiscoveryConfig}; +use lore_core::is_invalid_text_token; +use lore_core::watcher::SessionWatcher; +use lore_ipc::{ + BackupScheduleDto, CheckUpdateResultDto, DetectedAgent, RescanResult, ScanProgress, + TokenTotalsDto, +}; +use tauri::{AppHandle, Emitter, State}; + +use crate::state::{app_config, AppState, WATCH_QUIET}; + +/// Report the archive core version. +#[tauri::command] +pub fn core_version() -> String { + lore_core::version().to_string() +} + +/// List every supported adapter using a cheap live root probe plus its +/// ingested-session count. This remains useful before the first scan. +#[tauri::command] +pub fn list_detected_agents(state: State<'_, AppState>) -> Result, String> { + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + let config = state.config.lock().map_err(|_| "state lock poisoned")?; + lore_core::source_roots::detected_agents(&conn, &state.registry, &config) + .map_err(|e| e.to_string()) +} + +/// Calculate cumulative token usage and estimated cost across the entire archive. +#[tauri::command] +pub fn get_token_totals(state: State<'_, AppState>) -> Result { + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::query::get_token_totals(&conn).map_err(|e| e.to_string()) +} + +/// Explicit, user-initiated update check (GAP-M8-02 / ADR-0005). +#[tauri::command] +pub fn check_for_updates(_state: State<'_, AppState>) -> Result { + Ok(CheckUpdateResultDto { + update_available: false, + current_version: env!("CARGO_PKG_VERSION").to_string(), + latest_version: None, + release_notes: None, + published_at: None, + }) +} + +pub fn is_invalid_setting_key(key: &str) -> bool { + is_invalid_text_token(key, 128) +} + +/// Read a setting's raw JSON value. +#[tauri::command] +pub fn get_setting(state: State<'_, AppState>, key: String) -> Result, String> { + if is_invalid_setting_key(&key) { + return Ok(None); + } + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::settings::get(&conn, &key).map_err(|e| e.to_string()) +} + +/// Persist a setting's raw JSON value (Lore-owned; archive clearing preserves it). +#[tauri::command] +pub fn set_setting( + state: State<'_, AppState>, + key: String, + value_json: String, +) -> Result<(), String> { + if is_invalid_setting_key(&key) { + return Err("invalid setting key".to_string()); + } + if key.starts_with("agent_roots.") { + return Err("agent root settings require the folder picker".to_string()); + } + if value_json.len() > 65_536 { + return Err("setting value exceeds maximum size".to_string()); + } + serde_json::from_str::(&value_json) + .map_err(|e| format!("invalid JSON value: {e}"))?; + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::settings::set(&conn, &key, &value_json).map_err(|e| e.to_string()) +} + +/// Read the automatic-backup schedule (interval + retention). +#[tauri::command] +pub fn get_backup_schedule(state: State<'_, AppState>) -> Result { + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + let s = lore_core::backup::read_schedule(&conn).map_err(|e| e.to_string())?; + Ok(BackupScheduleDto { + interval: s.interval.as_str().to_string(), + keep: i64::try_from(s.keep).unwrap_or(i64::MAX), + }) +} + +/// Persist the automatic-backup schedule. +#[tauri::command] +pub fn set_backup_schedule( + state: State<'_, AppState>, + interval: String, + keep: i64, +) -> Result<(), String> { + if is_invalid_text_token(&interval, 64) { + return Err("invalid backup interval".to_string()); + } + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::backup::write_schedule( + &conn, + lore_core::backup::BackupSchedule { + interval: lore_core::backup::BackupInterval::parse(&interval), + keep: usize::try_from(keep.clamp(1, 100)) + .unwrap_or(lore_core::backup::DEFAULT_BACKUP_RETENTION), + }, + ) + .map_err(|e| e.to_string()) +} + +/// Create a Lore-owned backup now, pruning to the configured retention. +#[tauri::command] +pub fn backup_now(state: State<'_, AppState>) -> Result<(), String> { + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + let keep = lore_core::backup::read_schedule(&conn) + .map(|s| s.keep) + .unwrap_or(lore_core::backup::DEFAULT_BACKUP_RETENTION); + lore_core::backup::create_backup( + &conn, + &state.archive_dir.join(lore_core::paths::BACKUPS_DIRNAME), + keep, + ) + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +fn replace_source_configuration(state: &AppState, config: DiscoveryConfig) -> Result<(), String> { + let watcher = match SessionWatcher::new(&watch_roots(&state.registry, &config), WATCH_QUIET) { + Ok(watcher) => Some(watcher), + Err(_) => { + eprintln!("warning: filesystem watcher unavailable; live updates disabled"); + None + } + }; + *state.config.lock().map_err(|_| "state lock poisoned")? = config.clone(); + let worker = state.worker.lock().map_err(|_| "state lock poisoned")?; + let handle = worker + .as_ref() + .ok_or_else(|| "background ingestion worker unavailable".to_string())?; + handle.reconfigure(config, watcher); + Ok(()) +} + +/// Persist a user-selected read-only source folder, rebuild live watches, and +/// queue an incremental scan without restarting Lore. +#[tauri::command] +pub fn add_agent_root( + state: State<'_, AppState>, + agent_id: String, + path: String, +) -> Result<(), String> { + if is_invalid_text_token(&agent_id, 64) { + return Err("invalid agent id".to_string()); + } + if is_invalid_text_token(&path, 4096) { + return Err("invalid root path".to_string()); + } + let config = { + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::source_roots::add_custom_root(&conn, &state.registry, &agent_id, &path) + .map_err(|e| e.to_string())?; + app_config(&conn, &state.registry).map_err(|e| e.to_string())? + }; + replace_source_configuration(&state, config) +} + +/// Stop scanning a user-selected folder. Previously archived sessions stay in +/// Lore until the user explicitly forgets them; original logs are untouched. +#[tauri::command] +pub fn remove_agent_root( + state: State<'_, AppState>, + agent_id: String, + path: String, +) -> Result<(), String> { + if is_invalid_text_token(&agent_id, 64) { + return Err("invalid agent id".to_string()); + } + if is_invalid_text_token(&path, 4096) { + return Err("invalid root path".to_string()); + } + let config = { + let conn = state.db.lock().map_err(|_| "state lock poisoned")?; + lore_core::source_roots::remove_custom_root(&conn, &state.registry, &agent_id, &path) + .map_err(|e| e.to_string())?; + app_config(&conn, &state.registry).map_err(|e| e.to_string())? + }; + replace_source_configuration(&state, config) +} + +/// Queue a discovery pass on the background worker and return immediately. +/// Ingestion progress continues through `scan_progress` events, so a manual +/// rescan never holds the UI database lock while parsing large histories. +#[tauri::command] +pub fn rescan(app: AppHandle, state: State<'_, AppState>) -> Result { + let discovered = { + let config = state.config.lock().map_err(|_| "state lock poisoned")?; + lore_core::discovery::discover(&state.registry, &config) + .sessions + .len() + }; + let progress = ScanProgress { + discovered: i64::try_from(discovered).unwrap_or(i64::MAX), + done: false, + ..ScanProgress::default() + }; + let _ = app.emit("scan_progress", progress); + let worker = state.worker.lock().map_err(|_| "state lock poisoned")?; + let handle = worker + .as_ref() + .ok_or_else(|| "background ingestion worker unavailable".to_string())?; + handle.trigger_rescan(); + + Ok(RescanResult { + discovered: progress.discovered, + ingested: 0, + skipped: 0, + failed: 0, + enriched: 0, + }) +} + +/// Ask the background worker to schedule a low-priority commit re-verification +/// pass (I2). Non-blocking: the worker enqueues and drains the coalesced jobs on +/// its own thread and connection, so this never holds the UI database lock while +/// reading git. +#[tauri::command] +pub fn reverify(state: State<'_, AppState>) -> Result<(), String> { + let worker = state.worker.lock().map_err(|_| "state lock poisoned")?; + let handle = worker + .as_ref() + .ok_or_else(|| "background ingestion worker unavailable".to_string())?; + handle.trigger_reverify(); + Ok(()) +} diff --git a/src-tauri/src/commands/tasks.rs b/src-tauri/src/commands/tasks.rs deleted file mode 100644 index 2aaff48..0000000 --- a/src-tauri/src/commands/tasks.rs +++ /dev/null @@ -1,417 +0,0 @@ -//! Parallel agent tasks (ADR-0007, `ORCHESTRATOR_PLAN.md` step 1). -//! -//! Thin wrappers over `lore-orchestrator`, run on Tauri's blocking pool. The -//! orchestrator locks only around bookkeeping; git, process waits, and status -//! derivation (`describe`) run without it. - -use std::path::PathBuf; -use std::process::{Command, Stdio}; -use std::sync::atomic::Ordering; -use std::time::{Duration, Instant}; - -use lore_ipc::{ - ActivityDto, ContinueTaskRequest, CreateTaskRequest, DecisionDto, DirEntryDto, FileContentDto, - MergeQueueDto, MergeResultDto, RepoSettingsDto, TaskDiffDto, TaskDto, TasksChangedEvent, -}; -use lore_orchestrator::{describe, AgentPrograms, TaskSnapshot}; -use tauri::async_runtime::spawn_blocking; -use tauri::{AppHandle, Emitter, Manager, State}; - -use crate::state::AppState; - -const RESOLVE_TIMEOUT: Duration = Duration::from_secs(5); - -fn check_id(id: &str) -> Result<(), String> { - if id.is_empty() || id.len() > 64 || !id.chars().all(|c| c.is_ascii_alphanumeric()) { - Err("invalid task id".to_string()) - } else { - Ok(()) - } -} - -/// Run `f` against the orchestrator on the blocking pool. The orchestrator -/// locks internally only around bookkeeping, so slow git and process work in -/// one command does not stall others. -async fn with_orchestrator(app: AppHandle, f: F) -> Result -where - T: Send + 'static, - F: FnOnce(&lore_orchestrator::Orchestrator) -> Result + Send + 'static, -{ - spawn_blocking(move || f(&app.state::().orchestrator)) - .await - .map_err(|e| e.to_string())? -} - -async fn describe_all(snapshots: Vec) -> Result, String> { - spawn_blocking(move || snapshots.iter().map(describe).collect()) - .await - .map_err(|e| e.to_string()) -} - -async fn describe_one(snapshot: TaskSnapshot) -> Result { - spawn_blocking(move || describe(&snapshot)) - .await - .map_err(|e| e.to_string()) -} - -/// List orchestrated tasks with live status, newest first. -#[tauri::command] -pub async fn list_tasks(app: AppHandle) -> Result, String> { - let snapshots = with_orchestrator(app, |o| o.snapshots().map_err(|e| e.to_string())).await?; - let mut tasks = describe_all(snapshots).await?; - lore_orchestrator::annotate_overlaps(&mut tasks); - Ok(tasks) -} - -/// Send more work to a stopped/finished task, optionally handing off to the -/// other agent. -#[tauri::command] -pub async fn continue_task( - app: AppHandle, - request: ContinueTaskRequest, -) -> Result { - check_id(&request.id)?; - let snapshot = with_orchestrator(app, move |o| { - o.continue_task(&request).map_err(|e| e.to_string()) - }) - .await?; - describe_one(snapshot).await -} - -/// Commit all uncommitted changes in a task's worktree. `force` commits even -/// when the task changed files another task owns. -#[tauri::command] -pub async fn commit_task( - app: AppHandle, - id: String, - message: String, - force: Option, -) -> Result { - check_id(&id)?; - let snapshot = with_orchestrator(app, move |o| { - o.commit_task_forced(&id, &message, force.unwrap_or(false)) - .map_err(|e| e.to_string()) - }) - .await?; - describe_one(snapshot).await -} - -/// One task with fresh status, for refreshing after a change event. -#[tauri::command] -pub async fn get_task(app: AppHandle, id: String) -> Result { - check_id(&id)?; - let snapshot = - with_orchestrator(app, move |o| o.snapshot(&id).map_err(|e| e.to_string())).await?; - describe_one(snapshot).await -} - -/// The shared decision log: what agents did across tasks, newest first. -#[tauri::command] -pub async fn list_decisions( - app: AppHandle, - repo_path: Option, - limit: Option, -) -> Result, String> { - with_orchestrator(app, move |o| { - Ok(o.decisions( - repo_path.as_deref(), - limit.unwrap_or(100).min(1_000) as usize, - )) - }) - .await -} - -/// Lore's settings for a repository (the merge-queue test command). -#[tauri::command] -pub async fn get_repo_settings( - app: AppHandle, - repo_path: String, -) -> Result { - with_orchestrator(app, move |o| { - o.repo_settings(&repo_path).map_err(|e| e.to_string()) - }) - .await -} - -/// Set or clear the command the merge queue runs before each merge. -#[tauri::command] -pub async fn set_repo_test_command( - app: AppHandle, - repo_path: String, - command: Option, -) -> Result { - with_orchestrator(app, move |o| { - o.set_repo_test_command(&repo_path, command.as_deref()) - .map_err(|e| e.to_string()) - }) - .await -} - -/// Start merging tasks in order on a background thread. Poll `get_merge_queue` -/// for progress; `tasks_changed` fires for every task when it finishes. -#[tauri::command] -pub async fn start_merge_queue( - app: AppHandle, - repo_path: String, - task_ids: Vec, -) -> Result { - for id in &task_ids { - check_id(id)?; - } - if task_ids.len() > 100 { - return Err("at most 100 tasks per queue".into()); - } - let ids = task_ids.clone(); - let path = repo_path.clone(); - let queue = with_orchestrator(app.clone(), move |o| { - o.start_merge_queue(&path, &ids).map_err(|e| e.to_string()) - }) - .await?; - let repo = queue.repo_path.clone(); - let runner_app = app.clone(); - let runner_repo = repo.clone(); - let spawned = std::thread::Builder::new() - .name("lore-merge-queue".into()) - .spawn(move || { - let state = runner_app.state::(); - state.orchestrator.run_merge_queue(&runner_repo); - let _ = runner_app.emit("tasks_changed", TasksChangedEvent { ids: task_ids }); - }); - if let Err(e) = spawned { - app.state::() - .orchestrator - .abandon_merge_queue(&repo); - return Err(format!("could not start the merge queue: {e}")); - } - Ok(queue) -} - -/// The latest merge queue for a repository, including one restored after relaunch. -#[tauri::command] -pub async fn get_merge_queue( - app: AppHandle, - repo_path: String, -) -> Result, String> { - with_orchestrator(app, move |o| Ok(o.merge_queue(&repo_path))).await -} - -/// Stop a running merge queue after its current step (a running test is stopped). -#[tauri::command] -pub async fn cancel_merge_queue(app: AppHandle, repo_path: String) -> Result<(), String> { - with_orchestrator(app, move |o| { - o.cancel_merge_queue(&repo_path).map_err(|e| e.to_string()) - }) - .await -} - -/// Merge a task's branch into the repository's checked-out branch. -#[tauri::command] -pub async fn merge_task(app: AppHandle, id: String) -> Result { - check_id(&id)?; - with_orchestrator(app, move |o| o.merge_task(&id).map_err(|e| e.to_string())).await -} - -/// Why saved tasks could not be loaded at startup, if that happened. -#[tauri::command] -pub fn task_load_warning(state: State<'_, AppState>) -> Result, String> { - Ok(state.orchestrator.load_warning()) -} - -/// Create a Lore-owned worktree for the request and launch its agent. -#[tauri::command] -pub async fn create_task(app: AppHandle, request: CreateTaskRequest) -> Result { - let needs_resolve = !app - .state::() - .agents_resolved - .load(Ordering::Relaxed); - let resolved = if needs_resolve { - Some( - spawn_blocking(resolve_agent_programs) - .await - .map_err(|e| e.to_string())?, - ) - } else { - None - }; - if let Some((programs, complete)) = resolved { - let state = app.state::(); - state.orchestrator.set_programs(programs); - // Only stop looking once both agents were found, so installing one - // later works without restarting Lore. - if complete { - state.agents_resolved.store(true, Ordering::Relaxed); - } - } - let snapshot = with_orchestrator(app, move |o| { - o.create_task(&request).map_err(|e| e.to_string()) - }) - .await?; - describe_one(snapshot).await -} - -/// Stop a running task's agent and everything it started. -#[tauri::command] -pub async fn stop_task(app: AppHandle, id: String) -> Result { - check_id(&id)?; - let snapshot = - with_orchestrator(app, move |o| o.stop_task(&id).map_err(|e| e.to_string())).await?; - describe_one(snapshot).await -} - -/// Stop the agent and delete the task's worktree and branch. -#[tauri::command] -pub async fn discard_task(app: AppHandle, id: String) -> Result<(), String> { - check_id(&id)?; - with_orchestrator(app, move |o| o.discard_task(&id).map_err(|e| e.to_string())).await -} - -/// Reveal a task's worktree in Finder. -#[tauri::command] -pub async fn open_task_worktree(app: AppHandle, id: String) -> Result<(), String> { - check_id(&id)?; - let snapshot = - with_orchestrator(app, move |o| o.snapshot(&id).map_err(|e| e.to_string())).await?; - let path = snapshot.worktree_path().to_path_buf(); - if !path.is_dir() { - return Err("the worktree no longer exists".into()); - } - let status = Command::new("/usr/bin/open") - .arg(&path) - .status() - .map_err(|e| e.to_string())?; - if status.success() { - Ok(()) - } else { - Err("could not open worktree".into()) - } -} - -/// List one directory of a workspace (a repository or task worktree root). -#[tauri::command] -pub async fn list_workspace_dir( - app: AppHandle, - root: String, - rel_path: String, -) -> Result, String> { - with_orchestrator(app, move |o| { - let root = o.browsable_root(&root).map_err(|e| e.to_string())?; - lore_orchestrator::workspace::list_dir(&root.to_string_lossy(), &rel_path) - .map_err(|e| e.to_string()) - }) - .await -} - -/// Read a workspace file for display (read-only, capped at 1 MB). -#[tauri::command] -pub async fn read_workspace_file( - app: AppHandle, - root: String, - rel_path: String, -) -> Result { - with_orchestrator(app, move |o| { - let root = o.browsable_root(&root).map_err(|e| e.to_string())?; - lore_orchestrator::workspace::read_file(&root.to_string_lossy(), &rel_path) - .map_err(|e| e.to_string()) - }) - .await -} - -/// Resolve a user-chosen folder to its repository top-level. -#[tauri::command] -pub async fn open_workspace(app: AppHandle, path: String) -> Result { - with_orchestrator(app, move |o| { - o.open_workspace(&path) - .map(|top| top.display().to_string()) - .map_err(|e| e.to_string()) - }) - .await -} - -/// Unified diff of a task's worktree against its base commit. -#[tauri::command] -pub async fn task_diff(app: AppHandle, id: String) -> Result { - check_id(&id)?; - let snapshot = - with_orchestrator(app, move |o| o.snapshot(&id).map_err(|e| e.to_string())).await?; - spawn_blocking(move || lore_orchestrator::diff(&snapshot)) - .await - .map_err(|e| e.to_string())? - .map_err(|e| e.to_string()) -} - -/// The task agent's recent activity timeline, oldest first. -#[tauri::command] -pub async fn task_activity(app: AppHandle, id: String) -> Result, String> { - check_id(&id)?; - let snapshot = - with_orchestrator(app, move |o| o.snapshot(&id).map_err(|e| e.to_string())).await?; - spawn_blocking(move || lore_orchestrator::activity(&snapshot, 500)) - .await - .map_err(|e| e.to_string()) -} - -/// Apps launched from Finder get a minimal `PATH`, so `claude` / `codex` -/// installed via npm, Homebrew, or their installers are usually not on it. -/// Ask the user's login shell (bounded by a timeout), then check common install -/// locations. Returns the programs and whether both agents were found. -fn resolve_agent_programs() -> (AgentPrograms, bool) { - let defaults = AgentPrograms::default(); - let claude = resolve("claude"); - let codex = resolve("codex"); - let complete = claude.is_some() && codex.is_some(); - ( - AgentPrograms { - claude_code: claude.unwrap_or(defaults.claude_code), - codex: codex.unwrap_or(defaults.codex), - }, - complete, - ) -} - -fn resolve(name: &str) -> Option { - from_login_shell(name).or_else(|| { - let home = PathBuf::from(std::env::var_os("HOME")?); - [ - home.join(".local/bin").join(name), - home.join(".claude/local").join(name), - home.join(".npm-global/bin").join(name), - PathBuf::from("/opt/homebrew/bin").join(name), - PathBuf::from("/usr/local/bin").join(name), - ] - .into_iter() - .find(|p| p.is_file()) - }) -} - -fn from_login_shell(name: &str) -> Option { - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".into()); - let dir = std::env::temp_dir().join(format!("lore-which-{}-{name}", std::process::id())); - let out_path = dir.with_extension("out"); - let out = std::fs::File::create(&out_path).ok()?; - let mut child = Command::new(&shell) - .args(["-lic", &format!("command -v {name}")]) - .stdin(Stdio::null()) - .stdout(out) - .stderr(Stdio::null()) - .spawn() - .ok()?; - let deadline = Instant::now() + RESOLVE_TIMEOUT; - let finished = loop { - match child.try_wait() { - Ok(Some(_)) => break true, - Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)), - _ => break false, - } - }; - if !finished { - let _ = child.kill(); - let _ = child.wait(); - } - let text = std::fs::read_to_string(&out_path).unwrap_or_default(); - let _ = std::fs::remove_file(&out_path); - text.lines() - .map(str::trim) - .rfind(|l| l.starts_with('/')) - .map(PathBuf::from) - .filter(|p| p.is_file()) -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4a47632..2e2d56e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,87 +1,65 @@ -//! Lore desktop shell (Tauri 2): runs coding agents in parallel, each in its -//! own git worktree, via `lore-orchestrator` (ADR-0007). +//! Lore desktop shell (Tauri 2) — a thin binary over `lore-core`. +//! +//! No archive logic lives here: commands lock the core's SQLite connection, +//! delegate to `lore-core`, and return `lore-ipc` DTOs. Progress is relayed as +//! content-free `scan_progress` events. The updater (the only network-capable +//! component) is behind the off-by-default `updater` feature and is not wired +//! in here. pub mod commands; pub mod state; pub use commands::*; -pub use state::{init_state, AppState}; - -use std::time::Duration; - -use lore_ipc::TasksChangedEvent; -use tauri::{AppHandle, Emitter, Manager, RunEvent}; - -/// Poll cheap change signals (state and log size) on a background thread and -/// emit `tasks_changed` so the UI refreshes only what moved, instead of asking -/// for every task's git status twice a second. Also performs automatic handoffs -/// when an agent stops on a usage limit. -fn watch_tasks(app: AppHandle) { - std::thread::spawn(move || { - let mut previous: Vec<(String, lore_ipc::TaskState, u64)> = Vec::new(); - loop { - std::thread::sleep(Duration::from_millis(900)); - let Some(state) = app.try_state::() else { - return; - }; - let current = state.orchestrator.change_signatures(); - let mut ids: Vec = current - .iter() - .filter(|(id, s, len)| { - !previous - .iter() - .any(|(pid, ps, plen)| pid == id && ps == s && plen == len) - }) - .map(|(id, _, _)| id.clone()) - .collect(); - // A task that disappeared also changed the list. - if current.len() != previous.len() { - ids.push(String::new()); - } - previous = current; - if !ids.is_empty() { - let _ = app.emit("tasks_changed", TasksChangedEvent { ids }); - } - for id in state.orchestrator.run_auto_handoffs() { - let _ = app.emit("tasks_changed", TasksChangedEvent { ids: vec![id] }); - } - } - }); -} +pub use state::{acquire_writer_lock, app_config, init_state, AppState, WorkerSink, WATCH_QUIET}; + +use tauri::{Manager, RunEvent}; /// Build and run the desktop application. pub fn run() { let app = tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_notification::init()) .setup(|app| { let state = init_state(app.handle())?; app.manage(state); - watch_tasks(app.handle().clone()); Ok(()) }) .invoke_handler(tauri::generate_handler![ - list_tasks, - create_task, - stop_task, - discard_task, - open_task_worktree, - task_load_warning, - list_workspace_dir, - read_workspace_file, - open_workspace, - task_diff, - task_activity, - continue_task, - commit_task, - merge_task, - get_task, - list_decisions, - get_repo_settings, - set_repo_test_command, - start_merge_queue, - get_merge_queue, - cancel_merge_queue + core_version, + list_detected_agents, + list_sessions, + list_sessions_page, + list_repositories, + list_repository_sessions, + list_repository_sessions_page, + get_session, + list_session_messages_page, + get_git_snapshot, + get_file_patch, + session_secret_count, + export_session_markdown, + save_session_export, + forget_session, + forget_everything, + search, + search_page, + list_folders, + create_folder, + rename_folder, + delete_folder, + set_session_folder, + list_folder_sessions_page, + get_setting, + set_setting, + get_backup_schedule, + set_backup_schedule, + backup_now, + add_agent_root, + remove_agent_root, + rescan, + reverify, + get_token_totals, + relink_segment_repository, + check_for_updates ]) .build(tauri::generate_context!()); let app = match app { @@ -93,11 +71,207 @@ pub fn run() { }; app.run(|app_handle, event| { - // Agents launched by Lore stop with it (ORCHESTRATOR_PLAN.md step 1). + // Shut the background worker down cleanly as the event loop exits: it + // finishes its current bounded step, then the thread joins. Interrupted + // work stays durable in SQLite and is recovered on the next launch, so + // checkpoints are never corrupted and no job is left unrecoverable. if let RunEvent::Exit = event { if let Some(state) = app_handle.try_state::() { - state.orchestrator.shutdown(); + if let Ok(mut guard) = state.worker.lock() { + if let Some(handle) = guard.take() { + handle.shutdown(); + } + } + // Fold the write-ahead log back now that the worker thread is + // joined. This is the only moment a `TRUNCATE` checkpoint can + // actually shrink the file: for the whole run this connection is + // an open reader, and a reader holds up truncation, so the + // in-scan checkpoint can only ever fall back to `PASSIVE`. An + // archive left running for weeks accumulated a 360 MB WAL beside + // a 1.25 GB database, and every read had to traverse it. + if let Ok(conn) = state.db.lock() { + lore_core::storage::checkpoint(&conn); + } } } }); } + +#[cfg(test)] +mod tests { + use super::*; + use lore_core::adapters::AdapterRegistry; + use lore_core::discovery::DiscoveryConfig; + use lore_core::is_invalid_text_token; + + /// The safety property the desktop app owes every other writer: it may run a + /// background worker only while it holds the archive's writer lock. + /// + /// `worker::spawn` recovers and scans immediately, and `jobs::recover_running` + /// returns every `running` job to `pending` with no way to tell whose it was, + /// so a worker started without the lock reclaims a live CLI scan's jobs and + /// both then ingest the same source. + #[test] + fn no_writer_lock_means_no_background_worker() { + let archive = tempfile::tempdir().unwrap(); + + // Nothing else holds it: the app gets the lock, so a worker is allowed. + let held = acquire_writer_lock(archive.path()); + assert!(held.is_some(), "the lock was free and was not taken"); + + // Something else holds it: the app must start read-only. `is_some()` on + // the returned lock is exactly the condition `init_state` uses to decide + // whether to spawn, so this is the real gate, not a proxy for it. + let contended = acquire_writer_lock(archive.path()); + assert!( + contended.is_none(), + "the app would have started a worker while another writer held the lock" + ); + + drop(held); + assert!( + acquire_writer_lock(archive.path()).is_some(), + "the lock was not released when the holder went away" + ); + } + + #[test] + fn an_unusable_archive_directory_also_means_no_worker() { + // A lock that cannot be taken for any reason — not just contention — + // must still withhold the worker rather than proceeding unlocked. + let parent = tempfile::tempdir().unwrap(); + let missing = parent.path().join("no-such-archive"); + assert!(acquire_writer_lock(&missing).is_none()); + } + + #[test] + fn the_app_and_the_cli_contend_for_the_same_lock() { + // Both surfaces must name the same file, or each would hold its own and + // believe it was alone. `lorectl` builds the path the same way, from + // `paths::SCAN_LOCK_FILENAME`. + let archive = tempfile::tempdir().unwrap(); + let app_hold = acquire_writer_lock(archive.path()).expect("app takes it"); + assert_eq!( + app_hold.path(), + archive.path().join(lore_core::paths::SCAN_LOCK_FILENAME) + ); + assert_eq!( + lore_core::lock::ScanLock::acquire(archive.path()).unwrap_err(), + lore_core::lock::LockError::Held, + "a CLI writer was not blocked by the app's lock" + ); + } + + /// The archive location is `dirs::data_dir()/`, and `lore-core` + /// holds that identifier as a constant so a CLI can resolve the same path + /// without Tauri. Editing `identifier` in `tauri.conf.json` alone would move + /// the app's archive and leave every other surface pointed at the old one — + /// a silently empty archive rather than an error. This pins them together. + #[test] + fn tauri_conf_identifier_matches_core_constant() { + let conf = include_str!("../tauri.conf.json"); + let needle = format!("\"identifier\": \"{}\"", lore_core::paths::APP_IDENTIFIER); + assert!( + conf.contains(&needle), + "tauri.conf.json identifier must equal lore_core::paths::APP_IDENTIFIER ({})", + lore_core::paths::APP_IDENTIFIER + ); + } + + #[test] + fn test_is_invalid_folder_name() { + assert!(!is_invalid_folder_name("My Projects")); + assert!(is_invalid_folder_name("Folder\x00Name")); + assert!(is_invalid_folder_name("Folder\u{200B}Name")); + assert!(is_invalid_folder_name(&"a".repeat(257))); + } + + #[test] + fn test_is_invalid_text_token() { + assert!(!is_invalid_text_token("valid_token_123", 64)); + assert!(is_invalid_text_token("", 64)); + assert!(is_invalid_text_token("invalid\x07", 64)); + assert!(is_invalid_text_token("invalid\u{200C}", 64)); + assert!(is_invalid_text_token(&"x".repeat(65), 64)); + } + + #[test] + fn test_is_invalid_setting_key_and_folder_id() { + assert!(!is_invalid_setting_key("ui.theme")); + assert!(is_invalid_setting_key("")); + assert!(is_invalid_setting_key("ui\0theme")); + assert!(is_invalid_setting_key(&"k".repeat(129))); + + assert!(!is_invalid_folder_id("0123456789abcdef0123456789abcdef")); + assert!(is_invalid_folder_id("")); + assert!(is_invalid_folder_id("0123\u{200d}456")); + assert!(is_invalid_folder_id(&"f".repeat(65))); + } + + struct TestTempDir(std::path::PathBuf); + impl TestTempDir { + fn new(name: &str) -> Self { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let path = std::env::temp_dir().join(format!( + "lore_test_{}_{}_{}", + std::process::id(), + name, + nanos + )); + let _ = std::fs::create_dir_all(&path); + Self(path) + } + fn path(&self) -> &std::path::Path { + &self.0 + } + } + impl Drop for TestTempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn test_validate_export_path() { + let temp = TestTempDir::new("export_guard"); + let archive_dir = temp.path().join("archive"); + let codex_home = temp.path().join("codex_home"); + let valid_export_dir = temp.path().join("exports"); + std::fs::create_dir_all(&archive_dir).unwrap(); + std::fs::create_dir_all(&codex_home).unwrap(); + std::fs::create_dir_all(&valid_export_dir).unwrap(); + + let config = DiscoveryConfig::new(); + let registry = AdapterRegistry::v0(); + + // 1. Relative path rejected + let rel = std::path::Path::new("export.md"); + assert_eq!( + validate_export_path(rel, &archive_dir, ®istry, &config).unwrap_err(), + "export path must be absolute" + ); + + // 2. Archive dir rejected + let inside_archive = archive_dir.join("export.md"); + assert_eq!( + validate_export_path(&inside_archive, &archive_dir, ®istry, &config).unwrap_err(), + "cannot export inside the Lore archive directory" + ); + + // 3. CODEX_HOME root rejected + std::env::set_var("CODEX_HOME", &codex_home); + let inside_codex = codex_home.join("export.md"); + let codex_err = + validate_export_path(&inside_codex, &archive_dir, ®istry, &config).unwrap_err(); + std::env::remove_var("CODEX_HOME"); + assert_eq!(codex_err, "cannot export inside agent session directories"); + + // 4. Valid destination accepted + let valid_path = valid_export_dir.canonicalize().unwrap().join("export.md"); + let res = validate_export_path(&valid_path, &archive_dir, ®istry, &config).unwrap(); + assert_eq!(res, valid_path); + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index fe5f7b3..3d59ef3 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,26 +1,260 @@ -use std::sync::atomic::AtomicBool; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::Duration; -use tauri::{AppHandle, Manager}; +use lore_core::adapters::AdapterRegistry; +use lore_core::discovery::{watch_roots, DiscoveryConfig}; +use lore_core::lock::{LockError, ScanLock}; +use lore_core::pipeline::{ProgressEvent, ProgressSink}; +use lore_core::storage::blob::BlobStore; +use lore_core::watcher::SessionWatcher; +use lore_core::worker::{self, WorkerConfig, WorkerHandle}; +use lore_ipc::{IndexUpdatedEvent, JobFailedEvent, ScanProgress, SessionIngestedEvent}; +use rusqlite::Connection; +use tauri::{AppHandle, Emitter}; -/// Process-wide application state: the agent orchestrator (ADR-0007). +/// Quiet period a source path must be idle before the watcher hands it to the +/// worker, coalescing partial writes and event storms. +pub const WATCH_QUIET: Duration = Duration::from_millis(400); + +/// Process-wide application state: the UI archive connection (guarded; rusqlite +/// `Connection` is `Send` but not `Sync`), the blob store, the adapter registry, +/// discovery configuration, and a handle to the background ingestion worker. /// -/// The session archive (background scanning, SQLite index, search) is no -/// longer part of the desktop app, so launching Lore does no disk-heavy work. +/// The worker runs on its own thread with its own connection, so continuous +/// background ingestion never blocks UI queries or holds this connection's lock +/// across file parsing or Git work. pub struct AppState { - /// Parallel agent tasks. Agent binaries are resolved lazily on the first - /// launch so startup does not pay for a login-shell lookup. - pub orchestrator: lore_orchestrator::Orchestrator, - pub agents_resolved: AtomicBool, + pub db: Mutex, + pub blobs: BlobStore, + pub registry: AdapterRegistry, + pub config: Mutex, + pub worker: Mutex>, + /// The Lore-owned archive root (`app_data_dir`); used to purge on-disk + /// backups/cache/quarantine on "forget everything". + pub archive_dir: PathBuf, + /// Exclusive writer hold on this archive, kept for the app's lifetime. + /// + /// Held because `jobs::recover_running` returns **every** `running` job to + /// `pending` and the `job` table has no owner column: a second writer that + /// recovers while this app is mid-ingest reclaims its in-flight work, and + /// both then run the same source. The lock is what makes "one writer at a + /// time" true across processes (`lore_core::lock`, `ARCHITECTURE.md` §3.2). + /// + /// `None` means the lock was unavailable at startup, in which case no worker + /// was spawned and this instance never writes. Dropping the guard with the + /// app releases the lock. + pub _scan_lock: Option, +} + +/// An owned, thread-safe progress sink for the background worker. Accumulates +/// content-free counts and relays them to the webview as `scan_progress` events. +/// Unlike [`EmitSink`] it owns a cloned [`AppHandle`], so it can live on the +/// worker thread for the whole app lifetime. +pub struct WorkerSink { + app: AppHandle, + progress: Mutex, +} + +impl WorkerSink { + pub fn new(app: AppHandle) -> Self { + Self { + app, + progress: Mutex::new(ScanProgress::default()), + } + } +} + +impl ProgressSink for WorkerSink { + fn emit(&self, event: ProgressEvent) { + if let Ok(mut progress) = self.progress.lock() { + match event { + ProgressEvent::ScanEnqueued { discovered, .. } => { + *progress = ScanProgress { + discovered: i64::try_from(discovered).unwrap_or(i64::MAX), + done: false, + ..ScanProgress::default() + }; + } + ProgressEvent::ScanFinished => progress.done = true, + ProgressEvent::Ingested { + agent_id, + session_id, + .. + } => { + progress.ingested += 1; + let _ = self.app.emit( + "session_ingested", + SessionIngestedEvent { + session_id: session_id.clone(), + agent_id, + title: None, + parse_status: "ok".to_string(), + }, + ); + let _ = self.app.emit( + "index_updated", + IndexUpdatedEvent { + session_id, + documents_indexed: 1, + }, + ); + } + ProgressEvent::Skipped { .. } => progress.skipped += 1, + ProgressEvent::Failed { agent_id, kind } => { + progress.failed += 1; + let _ = self.app.emit( + "job_failed", + JobFailedEvent { + job_id: "".to_string(), + kind: format!("{kind:?}"), + error: format!("Ingest failed for adapter {agent_id}"), + }, + ); + } + ProgressEvent::Requeued { .. } => {} + } + let _ = self.app.emit("scan_progress", *progress); + } + } +} + +/// Build the discovery configuration. +/// +/// In release builds this combines the adapters' documented defaults with +/// persisted user-selected roots. In **debug** builds only, either adapter can +/// instead be redirected to a synthetic profile via `LORE_DEV_CLAUDE_ROOT` / +/// `LORE_DEV_CODEX_ROOT` so `cargo tauri dev` can run against generated fixtures +/// (see `lore_core::synthetic`) instead of real history. Release builds ignore +/// these variables entirely, so shipped Lore never takes an env-driven root. +pub fn app_config( + conn: &Connection, + registry: &AdapterRegistry, +) -> lore_core::source_roots::Result { + let mut config = lore_core::source_roots::discovery_config(conn, registry)?; + #[cfg(debug_assertions)] + { + use lore_core::adapters::DiscoveryRoots; + for (var, agent) in [ + ("LORE_DEV_CLAUDE_ROOT", "claude-code"), + ("LORE_DEV_CODEX_ROOT", "codex"), + ] { + match std::env::var(var) { + Ok(path) if !path.is_empty() => { + eprintln!("dev: {agent} discovery root overridden by {var}"); + config.set_roots(agent, DiscoveryRoots::new(vec![path.into()])); + } + _ => {} + } + } + } + Ok(config) +} + +/// Take the archive writer lock, or explain why the app is starting read-only. +/// +/// Extracted from [`init_state`] so the decision is testable without a Tauri +/// `AppHandle`. The decision is the safety-relevant part: `worker::spawn` +/// immediately recovers and scans, and `jobs::recover_running` returns *every* +/// `running` job to `pending` with no owner column, so spawning a worker without +/// this lock reclaims another live writer's in-flight work +/// (`ARCHITECTURE.md` §3.2). +/// +/// `None` means no lock, which must mean no worker. It is deliberately not an +/// error: the archive is still readable, so the app starts and every +/// worker-backed command reports "background ingestion worker unavailable" +/// rather than the app refusing to launch because a CLI scan happens to be +/// running. +pub fn acquire_writer_lock(archive_dir: &Path) -> Option { + match ScanLock::acquire(archive_dir) { + Ok(lock) => Some(lock), + Err(LockError::Held) => { + eprintln!( + "warning: another Lore process is writing to this archive; \ + starting read-only (no background ingestion)" + ); + None + } + Err(error) => { + eprintln!( + "warning: could not take the archive writer lock ({error}); \ + starting read-only (no background ingestion)" + ); + None + } + } } pub fn init_state(app: &AppHandle) -> Result> { - let data_dir = app.path().app_data_dir()?; + // Resolved by lore-core, not by `app.path().app_data_dir()`, so a CLI over + // the same core opens the same archive. The default is byte-identical to + // Tauri's (`dirs::data_dir()` joined with the bundle identifier — asserted + // by `tauri_conf_identifier_matches_core_constant` below); an explicit + // `LORE_ARCHIVE_DIR` now moves both surfaces together rather than only one. + let data_dir = lore_core::paths::archive_dir(None)?; std::fs::create_dir_all(&data_dir)?; + + // Taken before the database is opened, and held for the whole run. Anything + // between opening and locking is a window in which two writers both believe + // they are alone — the same ordering `lorectl scan` uses. + // + // Unavailable is not fatal: the archive is still perfectly readable, so the + // app starts *without* a background worker rather than refusing to launch + // because a CLI scan happens to be running. Every worker-backed command + // already reports "background ingestion worker unavailable" for that state. + // What must never happen is starting a worker without the lock — that is the + // hijack this exists to prevent. + let scan_lock = acquire_writer_lock(&data_dir); + + let db_path = data_dir.join(lore_core::paths::ARCHIVE_DB_FILENAME); + let conn = lore_core::storage::open(&db_path)?; + let blobs = BlobStore::open(data_dir.join(lore_core::paths::BLOBS_DIRNAME))?; + let registry = AdapterRegistry::v0(); + let config = app_config(&conn, ®istry)?; + + // Run an automatic backup at launch if one is due per the user's schedule + // (a no-op when off or not yet due). Best-effort: a backup failure must never + // block the app from starting. + if let Err(e) = lore_core::backup::run_scheduled_backup( + &conn, + &data_dir.join(lore_core::paths::BACKUPS_DIRNAME), + lore_core::now_ms(), + ) { + eprintln!("warning: scheduled backup skipped: {e}"); + } + + // Background worker: independent connection + registry, watching the same + // roots the UI's discovery config resolves. + let worker = worker::open_worker( + &db_path, + AdapterRegistry::v0(), + blobs.clone(), + config.clone(), + WorkerConfig::default(), + )?; + let watcher = + match SessionWatcher::new(&watch_roots(&AdapterRegistry::v0(), &config), WATCH_QUIET) { + Ok(watcher) => Some(watcher), + // A watcher that cannot start (e.g. no roots yet) must not block the + // app; the initial scan and manual rescans still work without it. + Err(_) => { + eprintln!("warning: filesystem watcher unavailable; live updates disabled"); + None + } + }; + // Only with the lock in hand. `spawn` immediately recovers and scans, which + // is exactly the sequence that would steal another writer's jobs. + let handle = scan_lock + .as_ref() + .map(|_| worker::spawn(worker, watcher, WorkerSink::new(app.clone()))); + Ok(AppState { - orchestrator: lore_orchestrator::Orchestrator::open( - data_dir.join("orchestrator"), - lore_orchestrator::AgentPrograms::default(), - )?, - agents_resolved: AtomicBool::new(false), + db: Mutex::new(conn), + blobs, + registry, + config: Mutex::new(config), + worker: Mutex::new(handle), + archive_dir: data_dir, + _scan_lock: scan_lock, }) } diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..d405818 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,859 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const scan = vi.hoisted(() => ({ + handler: null as null | ((progress: { + discovered: number; + ingested: number; + skipped: number; + failed: number; + enriched: number; + done: boolean; + }) => void), +})); + +vi.mock("./ipc", () => ({ + listDetectedAgents: vi.fn().mockResolvedValue([]), + chooseAgentRootDirectory: vi.fn().mockResolvedValue(null), + addAgentRoot: vi.fn().mockResolvedValue(undefined), + removeAgentRoot: vi.fn().mockResolvedValue(undefined), + listRepositories: vi.fn().mockResolvedValue([]), + listSessionsPage: vi.fn().mockResolvedValue({ sessions: [], next_cursor: null }), + listRepositorySessionsPage: vi.fn().mockResolvedValue({ + sessions: [], + next_cursor: null, + }), + listFolders: vi.fn().mockResolvedValue([]), + listFolderSessionsPage: vi.fn().mockResolvedValue({ sessions: [], next_cursor: null }), + createFolder: vi.fn().mockResolvedValue({ id: "f", name: "f", session_count: 0, position: 0 }), + renameFolder: vi.fn().mockResolvedValue(undefined), + deleteFolder: vi.fn().mockResolvedValue(undefined), + setSessionFolder: vi.fn().mockResolvedValue(undefined), + getSession: vi.fn().mockResolvedValue(null), + getGitSnapshot: vi.fn().mockResolvedValue([]), + getFilePatch: vi.fn().mockResolvedValue(null), + sessionSecretCount: vi.fn().mockResolvedValue(0), + exportSessionMarkdown: vi.fn().mockResolvedValue(""), + forgetSession: vi.fn().mockResolvedValue({ blobs_removed: 0, source_paths: [] }), + forgetEverything: vi.fn().mockResolvedValue({ blobs_removed: 0, source_paths: [] }), + search: vi.fn().mockResolvedValue([]), + searchPage: vi.fn().mockResolvedValue({ hits: [], next_cursor: null }), + getSetting: vi.fn().mockResolvedValue(null), + setSetting: vi.fn().mockResolvedValue(undefined), + getBackupSchedule: vi.fn().mockResolvedValue({ interval: "off", keep: 7 }), + setBackupSchedule: vi.fn().mockResolvedValue(undefined), + backupNow: vi.fn().mockResolvedValue(undefined), + rescan: vi.fn().mockResolvedValue({}), + onScanProgress: vi.fn().mockImplementation(async (handler) => { + scan.handler = handler; + return () => {}; + }), + HIGHLIGHT_START: "\u{e000}", + HIGHLIGHT_END: "\u{e001}", +})); + +import App from "./App"; +import { + addAgentRoot, + chooseAgentRootDirectory, + forgetEverything, + getSession, + listDetectedAgents, + listFolders, + listRepositories, + listRepositorySessionsPage, + listSessionsPage, + rescan, + removeAgentRoot, + searchPage, + type DetectedAgent, + type RepositorySummary, + type SessionPage, + type SessionDetail, + type SessionSummary, +} from "./ipc"; + +function summary(id: string, title: string): SessionSummary { + return { + id, + agent_id: "codex", + title, + started_at: null, + ended_at: null, + message_count: 0, + tool_call_count: 0, + primary_model: null, + parse_status: "ok", + total_input_tokens: null, + total_output_tokens: null, + total_cache_tokens: null, + est_cost_usd: null, + }; +} + +function detail(session: SessionSummary): SessionDetail { + return { + summary: session, + parse_note: null, + segments: [], + messages: [], + file_events: [], + next_message_cursor: null, + }; +} + +function repository(id: string, name: string): RepositorySummary { + return { + id, + display_name: name, + identity_confidence: "confirmed", + primary_path: null, + is_missing: false, + session_count: 1, + worktree_count: 1, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +afterEach(() => { + vi.mocked(listDetectedAgents).mockResolvedValue([]); + vi.mocked(chooseAgentRootDirectory).mockResolvedValue(null); + vi.mocked(addAgentRoot).mockResolvedValue(undefined); + vi.mocked(removeAgentRoot).mockResolvedValue(undefined); + vi.mocked(listRepositories).mockResolvedValue([]); + vi.mocked(listSessionsPage).mockResolvedValue({ sessions: [], next_cursor: null }); + vi.mocked(listRepositorySessionsPage).mockResolvedValue({ + sessions: [], + next_cursor: null, + }); + vi.mocked(getSession).mockResolvedValue(null); + vi.mocked(rescan).mockClear(); + vi.mocked(searchPage).mockResolvedValue({ hits: [], next_cursor: null }); +}); + +describe("App shell", () => { + it("renders the three-pane shell heading and a rescan control", async () => { + render(); + expect( + await screen.findByRole("heading", { name: "Lore", level: 1 }), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: /rescan/i })).toBeTruthy(); + // Panes present: repositories nav and sessions listbox. + expect(screen.getByRole("navigation", { name: /repositories/i })).toBeTruthy(); + }); + + it("shows loading rather than a false empty archive while initial queries settle", async () => { + const agents = deferred(); + vi.mocked(listDetectedAgents).mockReturnValueOnce(agents.promise); + + render(); + + expect(screen.queryByRole("heading", { name: /your agent history/i })).toBeNull(); + expect(screen.getByText("Loading archive…")).toBeTruthy(); + + await act(async () => agents.resolve([])); + expect( + await screen.findByRole("heading", { name: "Your agent history, searchable." }), + ).toBeTruthy(); + }); + + it("offers a truthful local-first onboarding path for an empty archive", async () => { + vi.mocked(listDetectedAgents).mockResolvedValue([ + { + id: "claude-code", + display_name: "Claude Code", + installed: true, + version: null, + session_count: 0, + roots: ["/Users/dev/.claude/projects"], + custom_roots: [], + }, + { + id: "codex", + display_name: "Codex", + installed: false, + version: null, + session_count: 0, + roots: ["/Users/dev/.codex/sessions"], + custom_roots: [], + }, + ]); + + render(); + + expect( + await screen.findByRole("heading", { name: "Your agent history, searchable." }), + ).toBeTruthy(); + expect(screen.getByText("/Users/dev/.claude/projects")).toBeTruthy(); + expect(screen.getByText("/Users/dev/.codex/sessions")).toBeTruthy(); + expect(screen.getByText(/local and read-only/i)).toBeTruthy(); + expect(screen.getByText("Claude Code").closest("li")?.textContent).toContain("detected"); + expect(screen.getByText("Codex").closest("li")?.textContent).toContain("not detected"); + + vi.mocked(chooseAgentRootDirectory).mockResolvedValueOnce("/Volumes/archive/codex"); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Choose Codex folder" })); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(chooseAgentRootDirectory).toHaveBeenCalledWith("Codex"); + expect(addAgentRoot).toHaveBeenCalledWith("codex", "/Volumes/archive/codex"); + expect(screen.getByRole("status").textContent).toContain("Codex folder added"); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Scan agent history" })); + await Promise.resolve(); + }); + expect(rescan).toHaveBeenCalledTimes(1); + expect(screen.getByRole("button", { name: "Scanning agent history…" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Review privacy and agents" })); + expect(screen.getByRole("dialog", { name: "Settings" })).toBeTruthy(); + }); + + it("never relabels a failed initial load as an empty archive", async () => { + vi.mocked(listDetectedAgents).mockRejectedValueOnce(new Error("archive unavailable")); + + render(); + + expect((await screen.findByRole("alert")).textContent).toContain("archive unavailable"); + expect(screen.getByText("Sessions unavailable.")).toBeTruthy(); + expect(screen.queryByText("No sessions yet.")).toBeNull(); + fireEvent.change(screen.getByRole("searchbox", { name: /search/i }), { + target: { value: "retry" }, + }); + + expect(screen.queryByRole("heading", { name: /your agent history/i })).toBeNull(); + expect(screen.getByText("Archive unavailable.")).toBeTruthy(); + }); + + it("opens the command palette on ⌘K", async () => { + render(); + await screen.findByRole("heading", { name: "Lore", level: 1 }); + fireEvent.keyDown(window, { key: "k", metaKey: true }); + expect(screen.getByRole("dialog", { name: /command palette/i })).toBeTruthy(); + }); + + it("focuses the search box on / when not already typing", async () => { + render(); + await screen.findByRole("heading", { name: "Lore", level: 1 }); + fireEvent.keyDown(window, { key: "/" }); + expect(document.activeElement).toBe(screen.getByRole("searchbox", { name: /search/i })); + }); + + it("renders scan progress as a bar with a percentage", async () => { + render(); + await screen.findByRole("heading", { name: "Lore", level: 1 }); + act(() => { + scan.handler?.({ + discovered: 100, + ingested: 30, + skipped: 10, + failed: 10, + enriched: 5, + done: false, + }); + }); + const bar = screen.getByRole("progressbar", { name: /archive scan progress/i }); + expect(bar.getAttribute("aria-valuenow")).toBe("50"); + expect(screen.getByText(/50\/100 sessions · 50%/)).toBeTruthy(); + }); + + it("shows a completion summary once the scan is done", async () => { + render(); + await screen.findByRole("heading", { name: "Lore", level: 1 }); + act(() => { + scan.handler?.({ + discovered: 100, + ingested: 90, + skipped: 5, + failed: 5, + enriched: 40, + done: true, + }); + }); + expect(screen.getByText(/scan complete · 90 ingested/i)).toBeTruthy(); + }); + + it("returns focus to the palette trigger after Escape", async () => { + render(); + const trigger = await screen.findByRole("button", { name: /open command palette/i }); + trigger.focus(); + fireEvent.click(trigger); + const input = screen.getByRole("combobox"); + expect(document.activeElement).toBe(input); + + fireEvent.keyDown(input, { key: "Escape" }); + + expect(screen.queryByRole("dialog", { name: /command palette/i })).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it("finds and opens an archived session from the command palette", async () => { + const archived = summary("archive-session", "Retry with exponential backoff"); + vi.mocked(searchPage).mockResolvedValue({ + hits: [ + { + session_id: archived.id, + source_kind: "message_part", + source_id: "part-1", + field: "text", + snippet: "use exponential backoff", + rank: -1, + title: archived.title, + agent_id: archived.agent_id, + started_at: archived.started_at, + }, + ], + next_cursor: null, + }); + vi.mocked(getSession).mockResolvedValue(detail(archived)); + vi.useFakeTimers(); + + try { + render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + fireEvent.keyDown(window, { key: "k", metaKey: true }); + const palette = screen.getByRole("combobox"); + const searchPageMock = vi.mocked(searchPage); + searchPageMock.mockClear(); + + fireEvent.change(palette, { target: { value: "backoff" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + + expect(searchPageMock).toHaveBeenCalledWith("backoff", 50); + expect(screen.getByText("Retry with exponential backoff")).toBeTruthy(); + await act(async () => { + fireEvent.keyDown(palette, { key: "Enter" }); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(getSession).toHaveBeenCalledWith(archived.id); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps rescan busy until the worker reports completion", async () => { + render(); + const button = await screen.findByRole("button", { name: "Rescan" }); + fireEvent.click(button); + expect(await screen.findByRole("button", { name: "Scanning…" })).toBeTruthy(); + + act(() => { + scan.handler?.({ + discovered: 2, + ingested: 1, + skipped: 0, + failed: 0, + enriched: 1, + done: true, + }); + }); + expect(await screen.findByRole("button", { name: "Rescan" })).toBeTruthy(); + }); + + it("debounces rapid search input and only queries the latest text", async () => { + render(); + await screen.findByRole("heading", { name: "Lore", level: 1 }); + + const search = screen.getByRole("searchbox", { name: /search/i }); + const searchPageMock = vi.mocked(searchPage); + searchPageMock.mockClear(); + vi.useFakeTimers(); + + try { + fireEvent.change(search, { target: { value: "r" } }); + fireEvent.change(search, { target: { value: "ret" } }); + fireEvent.change(search, { target: { value: "retry" } }); + + expect(searchPageMock).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(200); + await Promise.resolve(); + }); + + expect(searchPageMock).toHaveBeenCalledTimes(1); + expect(searchPageMock).toHaveBeenCalledWith("retry", 50); + } finally { + vi.useRealTimers(); + } + }); + + it("moves keyboard focus from search into its results and back", async () => { + vi.mocked(searchPage).mockResolvedValue({ + hits: [ + { + session_id: "search-session", + source_kind: "message_part", + source_id: "search-part", + field: "text", + snippet: "retry result", + rank: -1, + title: "Retry result", + agent_id: "codex", + started_at: null, + }, + ], + next_cursor: null, + }); + render(); + await screen.findByRole("heading", { name: "Lore", level: 1 }); + const search = screen.getByRole("searchbox", { name: /search/i }); + vi.useFakeTimers(); + + try { + fireEvent.change(search, { target: { value: "retry" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + const results = screen.getByRole("listbox", { name: /search results/i }); + + search.focus(); + fireEvent.keyDown(search, { key: "ArrowDown" }); + expect(document.activeElement).toBe(results); + + fireEvent.keyDown(results, { key: "ArrowUp" }); + expect(document.activeElement).toBe(search); + } finally { + vi.useRealTimers(); + } + }); + + it("removes results from the previous query as soon as new text is entered", async () => { + render(); + await screen.findByRole("heading", { name: "Lore", level: 1 }); + + const search = screen.getByRole("searchbox", { name: /search/i }); + const searchPageMock = vi.mocked(searchPage); + searchPageMock.mockClear(); + searchPageMock.mockResolvedValueOnce({ + hits: [ + { + session_id: "s1", + source_kind: "message_part", + source_id: "p1", + field: "text", + snippet: "alpha result", + rank: -1, + title: "Old alpha result", + agent_id: "codex", + started_at: null, + }, + ], + next_cursor: null, + }); + vi.useFakeTimers(); + + try { + fireEvent.change(search, { target: { value: "alpha" } }); + await act(async () => { + vi.advanceTimersByTime(200); + await Promise.resolve(); + }); + expect(screen.getByText("Old alpha result")).toBeTruthy(); + + fireEvent.change(search, { target: { value: "beta" } }); + expect(screen.queryByText("Old alpha result")).toBeNull(); + expect(screen.getByRole("status").textContent).toContain("Searching"); + } finally { + vi.useRealTimers(); + searchPageMock.mockResolvedValue({ hits: [], next_cursor: null }); + } + }); + + it("cancels a pending search when the query is cleared", async () => { + render(); + await screen.findByRole("heading", { name: "Lore", level: 1 }); + + const search = screen.getByRole("searchbox", { name: /search/i }); + const searchPageMock = vi.mocked(searchPage); + searchPageMock.mockClear(); + vi.useFakeTimers(); + + try { + fireEvent.change(search, { target: { value: "retry" } }); + fireEvent.change(search, { target: { value: "" } }); + + await act(async () => { + vi.advanceTimersByTime(200); + await Promise.resolve(); + }); + + expect(searchPageMock).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("loads a newly selected repository only once", async () => { + vi.mocked(listRepositories).mockResolvedValue([repository("repo-a", "Repo A")]); + const repositorySessions = vi.mocked(listRepositorySessionsPage); + repositorySessions.mockResolvedValue({ sessions: [], next_cursor: null }); + + render(); + const repoButton = await screen.findByRole("button", { name: /Repo A/i }); + repositorySessions.mockClear(); + + fireEvent.click(repoButton); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(repositorySessions).toHaveBeenCalledTimes(1); + expect(repositorySessions).toHaveBeenCalledWith("repo-a", 100, null); + }); + + it("keeps sessions for the latest repository when responses finish out of order", async () => { + vi.mocked(listRepositories).mockResolvedValue([ + repository("repo-a", "Repo A"), + repository("repo-b", "Repo B"), + ]); + const repoA = deferred(); + const repoB = deferred(); + vi.mocked(listRepositorySessionsPage).mockImplementation((id) => + id === "repo-a" ? repoA.promise : repoB.promise, + ); + + render(); + const repoAButton = await screen.findByRole("button", { name: /Repo A/i }); + const repoBButton = screen.getByRole("button", { name: /Repo B/i }); + + fireEvent.click(repoAButton); + fireEvent.click(repoBButton); + expect(screen.getByRole("status").textContent).toContain("Loading sessions"); + await act(async () => + repoB.resolve({ + sessions: [summary("session-b", "B session")], + next_cursor: null, + }), + ); + expect(screen.getByText("B session")).toBeTruthy(); + + await act(async () => + repoA.resolve({ + sessions: [summary("session-a", "A session")], + next_cursor: null, + }), + ); + expect(screen.queryByText("A session")).toBeNull(); + expect(screen.getByText("B session")).toBeTruthy(); + }); + + it("ignores an error from a superseded repository request", async () => { + vi.mocked(listRepositories).mockResolvedValue([ + repository("repo-a", "Repo A"), + repository("repo-b", "Repo B"), + ]); + const repoA = deferred(); + const repoB = deferred(); + vi.mocked(listRepositorySessionsPage).mockImplementation((id) => + id === "repo-a" ? repoA.promise : repoB.promise, + ); + + render(); + fireEvent.click(await screen.findByRole("button", { name: /Repo A/i })); + fireEvent.click(screen.getByRole("button", { name: /Repo B/i })); + await act(async () => + repoB.resolve({ + sessions: [summary("session-b", "B session")], + next_cursor: null, + }), + ); + await act(async () => repoA.reject(new Error("stale repository failure"))); + + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.getByText("B session")).toBeTruthy(); + }); + + it("allows dismissing an active error banner", async () => { + vi.mocked(listRepositories).mockRejectedValueOnce(new Error("network failure")); + render(); + const alert = await screen.findByRole("alert"); + expect(alert).toBeTruthy(); + expect(screen.getByText("Error: network failure")).toBeTruthy(); + + fireEvent.click(screen.getByLabelText("Dismiss error")); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("settles repository loading when a background refresh supersedes it", async () => { + vi.mocked(listRepositories).mockResolvedValue([repository("repo-a", "Repo A")]); + const manual = deferred(); + const repositorySessions = vi.mocked(listRepositorySessionsPage); + repositorySessions + .mockImplementationOnce(() => manual.promise) + .mockResolvedValue({ + sessions: [summary("refreshed", "Refreshed session")], + next_cursor: null, + }); + + render(); + fireEvent.click(await screen.findByRole("button", { name: /Repo A/i })); + expect(screen.getByRole("status").textContent).toContain("Loading sessions"); + + vi.useFakeTimers(); + try { + act(() => { + scan.handler?.({ + discovered: 1, + ingested: 1, + skipped: 0, + failed: 0, + enriched: 1, + done: true, + }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(screen.queryByText("Loading sessions…")).toBeNull(); + expect(screen.getByText("Refreshed session")).toBeTruthy(); + + await act(async () => + manual.resolve({ + sessions: [summary("stale", "Stale session")], + next_cursor: null, + }), + ); + expect(screen.queryByText("Stale session")).toBeNull(); + expect(screen.getByText("Refreshed session")).toBeTruthy(); + } finally { + vi.useRealTimers(); + } + }); + + it("starts with a bounded session page and appends older sessions", async () => { + const pages = vi.mocked(listSessionsPage); + pages.mockClear(); + pages + .mockResolvedValueOnce({ + sessions: [summary("new-session", "New session")], + next_cursor: "older-cursor", + }) + .mockResolvedValueOnce({ + sessions: [summary("old-session", "Old session")], + next_cursor: null, + }); + + render(); + expect(await screen.findByText("New session")).toBeTruthy(); + expect(pages).toHaveBeenNthCalledWith(1, 100, null); + + fireEvent.click(screen.getByRole("button", { name: /load older sessions/i })); + expect(await screen.findByText("Old session")).toBeTruthy(); + expect(screen.getByText("New session")).toBeTruthy(); + expect(pages).toHaveBeenNthCalledWith(2, 100, "older-cursor"); + expect(screen.queryByRole("button", { name: /load older sessions/i })).toBeNull(); + }); + + it("does not append an older page after the repository changes", async () => { + vi.mocked(listRepositories).mockResolvedValue([ + repository("repo-a", "Repo A"), + repository("repo-b", "Repo B"), + ]); + const olderA = deferred<{ sessions: SessionSummary[]; next_cursor: string | null }>(); + vi.mocked(listRepositorySessionsPage).mockImplementation((id, _limit, cursor) => { + if (id === "repo-a" && cursor === null) { + return Promise.resolve({ + sessions: [summary("a-new", "A newest")], + next_cursor: "a-older", + }); + } + if (id === "repo-a") return olderA.promise; + return Promise.resolve({ + sessions: [summary("b-new", "B newest")], + next_cursor: null, + }); + }); + + render(); + fireEvent.click(await screen.findByRole("button", { name: /Repo A/i })); + expect(await screen.findByText("A newest")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /load older sessions/i })); + fireEvent.click(screen.getByRole("button", { name: /Repo B/i })); + expect(await screen.findByText("B newest")).toBeTruthy(); + + await act(async () => { + olderA.resolve({ + sessions: [summary("a-old", "A older")], + next_cursor: null, + }); + }); + expect(screen.queryByText("A older")).toBeNull(); + expect(screen.getByText("B newest")).toBeTruthy(); + }); + + it("preserves the loaded session depth during a background refresh", async () => { + const first = Array.from({ length: 100 }, (_, index) => + summary(`new-${index}`, `New ${index}`), + ); + const older = Array.from({ length: 20 }, (_, index) => + summary(`old-${index}`, `Old ${index}`), + ); + const pages = vi.mocked(listSessionsPage); + pages.mockClear(); + pages + .mockResolvedValueOnce({ sessions: first, next_cursor: "older" }) + .mockResolvedValueOnce({ sessions: older, next_cursor: null }) + .mockResolvedValueOnce({ sessions: [...first, ...older], next_cursor: null }); + + render(); + await screen.findByText("New 0"); + fireEvent.click(screen.getByRole("button", { name: /load older sessions/i })); + await screen.findByText("Old 19"); + + vi.useFakeTimers(); + try { + act(() => { + scan.handler?.({ + discovered: 1, + ingested: 1, + skipped: 0, + failed: 0, + enriched: 1, + done: true, + }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(pages).toHaveBeenLastCalledWith(120, null); + expect(screen.getByText("Old 19")).toBeTruthy(); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps the latest session detail when responses finish out of order", async () => { + const sessionA = summary("session-a", "A session"); + const sessionB = summary("session-b", "B session"); + vi.mocked(listSessionsPage).mockResolvedValue({ + sessions: [sessionA, sessionB], + next_cursor: null, + }); + const detailA = deferred(); + const detailB = deferred(); + vi.mocked(getSession).mockImplementation((id) => + id === sessionA.id ? detailA.promise : detailB.promise, + ); + + render(); + fireEvent.click(await screen.findByText("A session")); + fireEvent.click(screen.getByText("B session")); + expect(screen.getByRole("status").textContent).toContain("Loading session"); + + await act(async () => detailB.resolve(detail(sessionB))); + expect(screen.getByRole("heading", { name: "B session", level: 2 })).toBeTruthy(); + + await act(async () => detailA.resolve(detail(sessionA))); + expect(screen.queryByRole("heading", { name: "A session", level: 2 })).toBeNull(); + expect(screen.getByRole("heading", { name: "B session", level: 2 })).toBeTruthy(); + }); + + it("ignores an error from a superseded session request", async () => { + const sessionA = summary("session-a", "A session"); + const sessionB = summary("session-b", "B session"); + vi.mocked(listSessionsPage).mockResolvedValue({ + sessions: [sessionA, sessionB], + next_cursor: null, + }); + const detailA = deferred(); + const detailB = deferred(); + vi.mocked(getSession).mockImplementation((id) => + id === sessionA.id ? detailA.promise : detailB.promise, + ); + + render(); + fireEvent.click(await screen.findByText("A session")); + fireEvent.click(screen.getByText("B session")); + await act(async () => detailB.resolve(detail(sessionB))); + await act(async () => detailA.reject(new Error("stale session failure"))); + + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.getByRole("heading", { name: "B session", level: 2 })).toBeTruthy(); + }); + + it("resets active repo, folder, search, and session when forgetting everything", async () => { + const session = summary("session-1", "Initial session"); + vi.mocked(listSessionsPage).mockResolvedValue({ + sessions: [session], + next_cursor: null, + }); + vi.mocked(listRepositorySessionsPage).mockResolvedValue({ + sessions: [session], + next_cursor: null, + }); + vi.mocked(listRepositories).mockResolvedValue([ + { + id: "repo-1", + display_name: "lore", + session_count: 1, + worktree_count: 1, + identity_confidence: "confirmed", + primary_path: "/Users/dev/Lore", + is_missing: false, + }, + ]); + vi.mocked(listFolders).mockResolvedValue([ + { + id: "folder-1", + name: "Sprint 1", + session_count: 1, + position: 0, + }, + ]); + vi.mocked(getSession).mockResolvedValue(detail(session)); + vi.spyOn(window, "confirm").mockReturnValue(true); + + render(); + + // Select repository + fireEvent.click(await screen.findByText("lore")); + // Open session + fireEvent.click(await screen.findByText("Initial session")); + expect(await screen.findByRole("heading", { name: "Initial session", level: 2 })).toBeTruthy(); + + // Open settings and click Forget everything + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + expect(screen.getByRole("dialog", { name: "Settings" })).toBeTruthy(); + + // Mock empty response after forget + vi.mocked(listRepositories).mockResolvedValue([]); + vi.mocked(listFolders).mockResolvedValue([]); + vi.mocked(listSessionsPage).mockResolvedValue({ + sessions: [], + next_cursor: null, + }); + vi.mocked(forgetEverything).mockResolvedValueOnce({ + blobs_removed: 3, + source_paths: [], + }); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Forget everything" })); + }); + + expect(forgetEverything).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("dialog", { name: "Settings" })).toBeNull(); + expect(screen.getByRole("status").textContent).toContain("Archive cleared (3 blob(s) removed)."); + expect(screen.queryByRole("heading", { name: "Initial session", level: 2 })).toBeNull(); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index 5eb573a..eb2a86a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,647 @@ -import Workbench from "./components/workbench/Workbench"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -/** Lore: run coding agents in parallel, each in its own git worktree. */ +import ArchiveOnboarding from "./components/ArchiveOnboarding"; +import CommandPalette, { type Command } from "./components/CommandPalette"; +import ErrorBoundary from "./components/ErrorBoundary"; +import FolderList from "./components/FolderList"; +import RepositoryList from "./components/RepositoryList"; +import SearchResults from "./components/SearchResults"; +import SessionList from "./components/SessionList"; +import SessionView from "./components/SessionView"; +import SettingsPanel from "./components/SettingsPanel"; +import { agentLabel } from "./format"; +import { useFolderManager } from "./hooks/useFolderManager"; +import { SEARCH_PAGE, useSearchState } from "./hooks/useSearchState"; +import { useSessionBrowser } from "./hooks/useSessionBrowser"; +import { + addAgentRoot, + chooseAgentRootDirectory, + forgetEverything, + getFilePatch, + listDetectedAgents, + listFolders, + listRepositories, + onScanProgress, + removeAgentRoot, + rescan, + searchPage, + type DetectedAgent, + type RepositorySummary, + type ScanProgress, +} from "./ipc"; + +/** The Lore mark: a node linked to two overlapping rings. */ +function Mark() { + return ( + + ); +} + +/** + * The M5 three-pane shell: repositories (left), the sessions list (middle), and + * the session reader with its git rail (right). Under active development. + */ export default function App() { - return ; + const [archiveStatus, setArchiveStatus] = useState<"loading" | "ready" | "failed">( + "loading", + ); + const [agents, setAgents] = useState([]); + const [repositories, setRepositories] = useState([]); + const [selectedRepo, setSelectedRepo] = useState(null); + const selectedRepoRef = useRef(null); + + const [progress, setProgress] = useState(null); + const [scanning, setScanning] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [paletteOpen, setPaletteOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [rootBusy, setRootBusy] = useState(null); + + const refreshRequestRef = useRef(0); + + const clearError = useCallback(() => setError(null), []); + const clearNotice = useCallback(() => setNotice(null), []); + + const { + sessions, + sessionCursor, + loadingMoreSessions, + selectedSession, + detail, + git, + secretCount, + sessionsPendingRequestRef, + sessionPendingRequestRef, + loadSessions, + loadOlderSessions, + openSession, + handleExport, + handleSaveFile, + handleForget, + resetForgetAll, + } = useSessionBrowser({ + onError: setError, + onNotice: setNotice, + onClearError: clearError, + onClearNotice: clearNotice, + }); + + const selectRepo = useCallback( + async (repo: string | null) => { + selectedRepoRef.current = repo; + folderManager.clearFolderSelection(); + setSelectedRepo(repo); + setError(null); + try { + await loadSessions(repo, null, true); + } catch (e) { + setError(String(e)); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [loadSessions], + ); + + const folderManager = useFolderManager({ + onError: setError, + onNotice: setNotice, + onClearError: clearError, + onReloadSessions: async (repo, folder) => { + await loadSessions(repo, folder); + }, + onFallBackToAllSessions: async () => { + await selectRepo(null); + }, + }); + + const { + folders, + setFolders, + selectedFolder, + selectedFolderRef, + handleCreateFolder, + handleRenameFolder, + handleDeleteFolder, + fileSession: fileSessionInFolder, + } = folderManager; + + const selectFolder = useCallback( + async (folderId: string) => { + selectedRepoRef.current = null; + setSelectedRepo(null); + await folderManager.selectFolder(folderId); + }, + [folderManager], + ); + + const fileSession = useCallback( + async (sessionId: string, folderId: string | null) => { + await fileSessionInFolder(sessionId, folderId, selectedRepoRef.current); + }, + [fileSessionInFolder], + ); + + const { + query, + hits, + cursor, + loadingMore, + searchInputRef, + searchResultsRef, + updateSearch, + loadMore, + clearSearch, + } = useSearchState({ + onError: setError, + onClearError: clearError, + }); + + const refresh = useCallback(async () => { + const request = ++refreshRequestRef.current; + const repo = selectedRepoRef.current; + const folder = selectedFolderRef.current; + try { + const nextAgents = await listDetectedAgents(); + if (request !== refreshRequestRef.current) return; + setAgents(nextAgents); + const nextRepositories = await listRepositories(); + if (request !== refreshRequestRef.current) return; + setRepositories(nextRepositories); + const nextFolders = await listFolders(); + if (request !== refreshRequestRef.current) return; + setFolders(nextFolders); + // A manual repository/folder change owns its own load. Do not duplicate or + // supersede it if the selection changed while this refresh was running. + if (repo === selectedRepoRef.current && folder === selectedFolderRef.current) + await loadSessions(repo, folder); + if (request === refreshRequestRef.current) setArchiveStatus("ready"); + } catch (e) { + if (request === refreshRequestRef.current) { + setError(String(e)); + // A later background failure must not erase a previously successful + // archive load, but a failed first load is never an "empty archive". + setArchiveStatus((current) => (current === "ready" ? current : "failed")); + } + } + }, [loadSessions, selectedFolderRef, setFolders]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + let refreshTimer: ReturnType | null = null; + const unlisten = onScanProgress((next) => { + setProgress(next); + if (next.done) setScanning(false); + // Coalesce progress storms into one archive refresh. This makes results + // appear as the background worker commits them without issuing a query + // for every single ingested source. + if (next.ingested > 0 || next.skipped > 0 || next.failed > 0) { + if (refreshTimer !== null) clearTimeout(refreshTimer); + refreshTimer = setTimeout(() => void refresh(), 300); + } + }); + return () => { + if (refreshTimer !== null) clearTimeout(refreshTimer); + void unlisten.then((off) => off()); + }; + }, [refresh]); + + useEffect(() => { + function onKey(event: KeyboardEvent) { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + setPaletteOpen((open) => !open); + return; + } + // `/` focuses search (Linear/Notion convention) unless the user is already + // typing in a field, so `/` never hijacks a real keystroke. + if (event.key === "/" && !event.metaKey && !event.ctrlKey && !event.altKey) { + const target = event.target as HTMLElement | null; + const editable = + target?.isContentEditable || + target?.tagName === "INPUT" || + target?.tagName === "TEXTAREA" || + target?.tagName === "SELECT"; + if (!editable) { + event.preventDefault(); + searchInputRef.current?.focus(); + } + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [searchInputRef]); + + async function handleForgetEverything() { + if ( + !window.confirm( + "Forget everything? This permanently removes all sessions, repositories, and findings from Lore. Original agent logs are not touched.", + ) + ) { + return; + } + try { + resetForgetAll(); + const report = await forgetEverything(); + setSelectedRepo(null); + selectedRepoRef.current = null; + folderManager.clearFolderSelection(); + clearSearch(); + setSettingsOpen(false); + await refresh(); + setNotice(`Archive cleared (${report.blobs_removed} blob(s) removed).`); + } catch (e) { + setError(String(e)); + } + } + + const handleRescan = useCallback(async () => { + setScanning(true); + setError(null); + try { + await rescan(); + await refresh(); + } catch (e) { + setError(String(e)); + setScanning(false); + } + }, [refresh]); + + const handleAddAgentRoot = useCallback( + async (agentId: string, displayName: string) => { + setRootBusy(agentId); + setError(null); + try { + const selected = await chooseAgentRootDirectory(displayName); + if (selected === null) return; + await addAgentRoot(agentId, selected); + setNotice(`${displayName} folder added. Lore is scanning it now.`); + await refresh(); + } catch (e) { + setError(String(e)); + } finally { + setRootBusy(null); + } + }, + [refresh], + ); + + const handleRemoveAgentRoot = useCallback( + async (agentId: string, path: string) => { + setRootBusy(agentId); + setError(null); + try { + await removeAgentRoot(agentId, path); + setNotice("Custom folder removed. Already archived sessions are unchanged."); + await refresh(); + } catch (e) { + setError(String(e)); + } finally { + setRootBusy(null); + } + }, + [refresh], + ); + + const searchPaletteArchive = useCallback( + async (rawQuery: string): Promise => { + const page = await searchPage(rawQuery, SEARCH_PAGE); + const seen = new Set(); + const commands: Command[] = []; + for (const hit of page.hits) { + if (seen.has(hit.session_id)) continue; + seen.add(hit.session_id); + commands.push({ + id: `cmd-sess-${hit.session_id}`, + group: "Archive", + label: hit.title ?? "(untitled session)", + hint: `${agentLabel(hit.agent_id)} · archive match`, + run: () => void openSession(hit.session_id), + }); + } + return commands; + }, + [openSession], + ); + + const commands = useMemo(() => { + const actions: Command[] = [ + { id: "cmd-rescan", group: "Action", label: "Rescan", run: () => void handleRescan() }, + { id: "cmd-all", group: "Action", label: "All sessions", run: () => void selectRepo(null) }, + { + id: "cmd-new-folder", + group: "Action", + label: "New folder…", + run: () => { + const name = window.prompt("New folder name"); + if (name && name.trim()) void handleCreateFolder(name); + }, + }, + { + id: "cmd-agent-folders", + group: "Action", + label: "Manage agent folders", + hint: "Settings", + run: () => setSettingsOpen(true), + }, + ]; + const folderCommands: Command[] = folders.map((folder) => ({ + id: `cmd-folder-${folder.id}`, + group: "Folder", + label: folder.name, + hint: `${folder.session_count} threads`, + run: () => void selectFolder(folder.id), + })); + const repoCommands: Command[] = repositories.map((repo) => ({ + id: `cmd-repo-${repo.id}`, + group: "Repository", + label: repo.display_name, + hint: `${repo.session_count} sessions`, + run: () => void selectRepo(repo.id), + })); + const sessionCommands: Command[] = sessions.map((session) => ({ + id: `cmd-sess-${session.id}`, + group: "Session", + label: session.title ?? "(untitled)", + hint: agentLabel(session.agent_id), + run: () => void openSession(session.id), + })); + return [...actions, ...folderCommands, ...repoCommands, ...sessionCommands]; + }, [ + folders, + handleCreateFolder, + handleRescan, + openSession, + repositories, + selectFolder, + selectRepo, + sessions, + ]); + + const archiveEmpty = + archiveStatus === "ready" && + selectedRepo === null && + repositories.length === 0 && + sessions.length === 0 && + agents.every((agent) => agent.session_count === 0); + + // Scan progress: processed vs discovered, so the header strip can show a real + // fraction and bar rather than bare counters. Discovered is the candidate + // count; processed is how many of those candidates have been resolved. + const scanProcessed = progress ? progress.ingested + progress.skipped + progress.failed : 0; + const scanTotal = progress ? Math.max(progress.discovered, scanProcessed, 0) : 0; + const scanPercent = + scanTotal > 0 ? Math.min(100, Math.round((scanProcessed / scanTotal) * 100)) : 0; + + return ( +
+
+ +

Lore

+ git memory for coding agents + + preview build + + +
+ + {progress && ( +
+
+
+
+ + {progress.done + ? `Scan complete · ${progress.ingested} ingested · ${progress.skipped} skipped · ${progress.failed} failed` + : `${scanProcessed}/${scanTotal} sessions · ${scanPercent}% · ${progress.failed} failed`} + +
+ )} + {error && ( +
+ {error} + +
+ )} + {notice && ( +
+ {notice} + +
+ )} + +
+ + +
+ updateSearch(event.target.value)} + onKeyDown={(event) => { + if (event.key === "ArrowDown" && hits !== null && hits.length > 0) { + event.preventDefault(); + searchResultsRef.current?.focus(); + } + }} + /> + {query.trim() ? ( + void loadMore()} + onExitUp={() => searchInputRef.current?.focus()} + /> + ) : ( + archiveStatus === "loading" || sessionsPendingRequestRef.current !== null ? ( +
+

Loading sessions…

+
+ ) : archiveStatus === "failed" ? ( +
+

Sessions unavailable.

+

Fix the error above, then try Rescan.

+
+ ) : ( + + void loadOlderSessions(selectedRepoRef.current, selectedFolderRef.current) + } + /> + ) + )} +
+ +
+ {archiveStatus === "loading" ? ( +
+

Loading archive…

+
+ ) : archiveStatus === "failed" ? ( +
+
+

Archive unavailable.

+

No local agent logs were changed. Retry with Rescan.

+
+
+ ) : archiveEmpty ? ( + void handleRescan()} + onAddAgentRoot={(agentId, displayName) => + void handleAddAgentRoot(agentId, displayName) + } + onOpenSettings={() => setSettingsOpen(true)} + /> + ) : sessionPendingRequestRef.current !== null ? ( +
+

Loading session…

+
+ ) : ( + + void handleForget(refresh)} + /> + + )} +
+
+ + {paletteOpen && ( + setPaletteOpen(false)} + /> + )} + + void handleAddAgentRoot(agentId, displayName) + } + onRemoveAgentRoot={(agentId, path) => void handleRemoveAgentRoot(agentId, path)} + onForgetEverything={handleForgetEverything} + onClose={() => setSettingsOpen(false)} + /> +
+ ); } diff --git a/src/components/ArchiveOnboarding.tsx b/src/components/ArchiveOnboarding.tsx new file mode 100644 index 0000000..8490cb9 --- /dev/null +++ b/src/components/ArchiveOnboarding.tsx @@ -0,0 +1,96 @@ +import type { DetectedAgent } from "../ipc"; + +const SUPPORTED_SOURCES = [ + { id: "claude-code", name: "Claude Code", path: "~/.claude/projects" }, + { id: "codex", name: "Codex", path: "~/.codex/sessions" }, +] as const; + +function detectionLabel(agent: DetectedAgent | undefined) { + if (!agent) return "ready to scan"; + return agent.installed ? "detected" : "not detected"; +} + +/** + * First-run guidance for a settled, genuinely empty archive. It states the + * actual V0 privacy boundary and only offers actions the current build owns. + */ +export default function ArchiveOnboarding({ + agents, + scanning, + rootBusy, + onScan, + onAddAgentRoot, + onOpenSettings, +}: { + agents: DetectedAgent[]; + scanning: boolean; + rootBusy: string | null; + onScan: () => void; + onAddAgentRoot: (agentId: string, displayName: string) => void; + onOpenSettings: () => void; +}) { + return ( +
+
+

Empty archive

+

Your agent history, searchable.

+

+ Lore reads the sessions Claude Code and Codex already wrote to disk, then + organizes them by repository and Git evidence. +

+ +
+ Local and read-only. Archive content stays on this machine, and + Lore never changes your original agent logs. +
+ +
+

What Lore checks

+
    + {SUPPORTED_SOURCES.map((source) => { + const agent = agents.find((candidate) => candidate.id === source.id); + const status = detectionLabel(agent); + const preferredRoot = + agent && agent.custom_roots.length > 0 + ? agent.custom_roots[agent.custom_roots.length - 1] + : (agent?.roots[0] ?? source.path); + const otherRootCount = Math.max(0, (agent?.roots.length ?? 1) - 1); + const displayRoot = + otherRootCount > 0 ? `${preferredRoot} + ${otherRootCount} more` : preferredRoot; + const rootTitle = agent?.roots.join("\n") ?? source.path; + const busy = rootBusy === source.id; + return ( +
  • + {source.name} + {displayRoot} + + {status} + + +
  • + ); + })} +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/BackupSettings.test.tsx b/src/components/BackupSettings.test.tsx new file mode 100644 index 0000000..8ca7f96 --- /dev/null +++ b/src/components/BackupSettings.test.tsx @@ -0,0 +1,98 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getBackupSchedule = vi.fn(); +const setBackupSchedule = vi.fn(); +const backupNow = vi.fn(); + +vi.mock("../ipc", () => ({ + getBackupSchedule: () => getBackupSchedule(), + setBackupSchedule: (interval: string, keep: number) => setBackupSchedule(interval, keep), + backupNow: () => backupNow(), +})); + +import BackupSettings from "./BackupSettings"; + +describe("BackupSettings", () => { + beforeEach(() => { + getBackupSchedule.mockReset().mockResolvedValue({ interval: "weekly", keep: 3 }); + setBackupSchedule.mockReset().mockResolvedValue(undefined); + backupNow.mockReset().mockResolvedValue(undefined); + }); + + it("loads the current schedule and reflects it in the controls", async () => { + render(); + const select = (await screen.findByLabelText("Automatic backups")) as HTMLSelectElement; + expect(select.value).toBe("weekly"); + expect((screen.getByLabelText("Keep newest") as HTMLInputElement).value).toBe("3"); + }); + + it("persists a changed interval", async () => { + render(); + const select = await screen.findByLabelText("Automatic backups"); + fireEvent.change(select, { target: { value: "daily" } }); + await waitFor(() => expect(setBackupSchedule).toHaveBeenCalledWith("daily", 3)); + }); + + it("runs an on-demand backup and reports success", async () => { + let resolveBackup: () => void = () => {}; + backupNow.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveBackup = resolve; + }), + ); + render(); + await screen.findByLabelText("Automatic backups"); + const button = screen.getByRole("button", { name: /back up now/i }); + expect(button.getAttribute("aria-busy")).toBe("false"); + fireEvent.click(button); + expect(button.getAttribute("aria-busy")).toBe("true"); + resolveBackup(); + await waitFor(() => expect(backupNow).toHaveBeenCalled()); + expect(await screen.findByText(/backup created/i)).toBeTruthy(); + expect(button.getAttribute("aria-busy")).toBe("false"); + }); + + it("disables the retention input when backups are off", async () => { + getBackupSchedule.mockResolvedValue({ interval: "off", keep: 5 }); + render(); + const keep = (await screen.findByLabelText("Keep newest")) as HTMLInputElement; + expect(keep.disabled).toBe(true); + }); + + it("persists changed retention count and clamps out-of-bounds input", async () => { + render(); + const keep = await screen.findByLabelText("Keep newest"); + + // Valid input change + fireEvent.change(keep, { target: { value: "14" } }); + await waitFor(() => expect(setBackupSchedule).toHaveBeenCalledWith("weekly", 14)); + + // Below minimum (0) -> clamps to 1 + fireEvent.change(keep, { target: { value: "0" } }); + await waitFor(() => expect(setBackupSchedule).toHaveBeenCalledWith("weekly", 1)); + + // Above maximum (200) -> clamps to 100 + fireEvent.change(keep, { target: { value: "200" } }); + await waitFor(() => expect(setBackupSchedule).toHaveBeenCalledWith("weekly", 100)); + }); + + it("displays error messages when backupNow or setBackupSchedule fails", async () => { + backupNow.mockRejectedValueOnce(new Error("Disk full")); + setBackupSchedule.mockRejectedValueOnce(new Error("Write error")); + + render(); + await screen.findByLabelText("Automatic backups"); + + // backupNow failure + const button = screen.getByRole("button", { name: /back up now/i }); + fireEvent.click(button); + expect(await screen.findByText(/Backup failed: Error: Disk full/i)).toBeTruthy(); + + // setBackupSchedule failure + const select = screen.getByLabelText("Automatic backups"); + fireEvent.change(select, { target: { value: "daily" } }); + expect(await screen.findByText(/Could not save: Error: Write error/i)).toBeTruthy(); + }); +}); diff --git a/src/components/BackupSettings.tsx b/src/components/BackupSettings.tsx new file mode 100644 index 0000000..5bfed00 --- /dev/null +++ b/src/components/BackupSettings.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState } from "react"; + +import { clamp } from "../format"; +import { + backupNow, + getBackupSchedule, + setBackupSchedule, + type BackupInterval, +} from "../ipc"; + +const INTERVALS: { value: BackupInterval; label: string }[] = [ + { value: "off", label: "Off" }, + { value: "daily", label: "Daily" }, + { value: "weekly", label: "Weekly" }, +]; + +/** + * Backup cadence controls: automatic interval (off/daily/weekly), how many + * newest copies to retain, and an on-demand "Back up now". Reads and persists + * through the settings-backed schedule; backups are Lore-owned local snapshots, + * removed by "Forget everything". + */ +export default function BackupSettings() { + const [interval, setIntervalValue] = useState("off"); + const [keep, setKeep] = useState(7); + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let alive = true; + getBackupSchedule() + .then((s) => { + if (alive) { + setIntervalValue(s.interval as BackupInterval); + setKeep(s.keep); + } + }) + .catch(() => {}); + return () => { + alive = false; + }; + }, []); + + async function persist(nextInterval: BackupInterval, nextKeep: number) { + setIntervalValue(nextInterval); + setKeep(nextKeep); + setStatus(null); + try { + await setBackupSchedule(nextInterval, nextKeep); + } catch (e) { + setStatus(`Could not save: ${String(e)}`); + } + } + + async function runNow() { + setBusy(true); + setStatus(null); + try { + await backupNow(); + setStatus("Backup created."); + } catch (e) { + setStatus(`Backup failed: ${String(e)}`); + } finally { + setBusy(false); + } + } + + return ( +
+

+ Backups +

+

+ Local, Lore-owned snapshots of your archive, kept private on this machine and removed by + "Forget everything". +

+
+ + +
+
+ + + void persist(interval, clamp(Number(event.target.value) || 1, 1, 100)) + } + /> +
+ + {status && ( +

+ {status} +

+ )} +
+ ); +} diff --git a/src/components/CommandPalette.test.tsx b/src/components/CommandPalette.test.tsx new file mode 100644 index 0000000..487c90c --- /dev/null +++ b/src/components/CommandPalette.test.tsx @@ -0,0 +1,280 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import CommandPalette, { type Command } from "./CommandPalette"; + +function makeItems(): Command[] { + return [ + { id: "a", group: "Repository", label: "billing-service", hint: "12 sessions", run: vi.fn() }, + { id: "b", group: "Session", label: "Add webhook", hint: "Codex", run: vi.fn() }, + { id: "c", group: "Session", label: "Refactor retry", hint: "Claude", run: vi.fn() }, + ]; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function renderPalette(props: Partial[0]> = {}) { + const finalProps = { + items: makeItems(), + onClose: vi.fn(), + ...props, + }; + const result = render(); + return { ...result, props: finalProps }; +} + +describe("CommandPalette", () => { + it("focuses its search field when mounted", () => { + renderPalette(); + expect(document.activeElement).toBe( + screen.getByRole("combobox", { name: /search commands and sessions/i }), + ); + }); + + it("filters as you type and runs the top match on Enter", () => { + const items = makeItems(); + const onClose = vi.fn(); + renderPalette({ items, onClose }); + const input = screen.getByRole("combobox"); + + fireEvent.change(input, { target: { value: "refactor" } }); + expect(screen.queryByText("billing-service")).toBeNull(); + expect(screen.getByText("Refactor retry")).toBeTruthy(); + + fireEvent.keyDown(input, { key: "Enter" }); + expect(items[2].run).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("finds loaded commands with an ordered fuzzy match", () => { + const items = makeItems(); + renderPalette({ items }); + const input = screen.getByRole("combobox"); + + fireEvent.change(input, { target: { value: "rfrr" } }); + + expect(screen.queryByText("billing-service")).toBeNull(); + expect(screen.getByText("Refactor retry")).toBeTruthy(); + fireEvent.keyDown(input, { key: "Enter" }); + expect(items[2].run).toHaveBeenCalledTimes(1); + }); + + it("navigates with arrow keys", () => { + const items = makeItems(); + renderPalette({ items }); + const input = screen.getByRole("combobox"); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(items[1].run).toHaveBeenCalledTimes(1); + }); + + it("jumps to the first and last match with Home and End", () => { + const items = makeItems(); + renderPalette({ items }); + const input = screen.getByRole("combobox"); + + fireEvent.keyDown(input, { key: "End" }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(items[2].run).toHaveBeenCalledTimes(1); + }); + + it("closes on Escape", () => { + const { props } = renderPalette(); + fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" }); + expect(props.onClose).toHaveBeenCalledTimes(1); + }); + + it("shows an empty state when nothing matches", () => { + renderPalette(); + fireEvent.change(screen.getByRole("combobox"), { target: { value: "zzzz" } }); + expect(screen.getByText(/no matches/i)).toBeTruthy(); + }); + + it("debounces archive search and runs a returned session", async () => { + const run = vi.fn(); + const search = vi.fn().mockResolvedValue([ + { + id: "remote-session", + group: "Archive", + // The archive may match message content even when the title does not. + label: "Investigate checkout outage", + hint: "Codex · archive match", + run, + }, + ] satisfies Command[]); + vi.useFakeTimers(); + + try { + renderPalette({ search }); + const input = screen.getByRole("combobox"); + fireEvent.change(input, { target: { value: "backoff" } }); + + expect(search).not.toHaveBeenCalled(); + expect(screen.getByRole("status").textContent).toContain("Searching archive"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + + expect(search).toHaveBeenCalledWith("backoff"); + expect(screen.getByText("Investigate checkout outage")).toBeTruthy(); + fireEvent.keyDown(input, { key: "Enter" }); + expect(run).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it("handles synchronous search failure gracefully without throwing", async () => { + const search = vi.fn(() => { + throw new Error("sync failure"); + }); + vi.useFakeTimers(); + + try { + render( {}} />); + const input = screen.getByRole("combobox"); + fireEvent.change(input, { target: { value: "error-query" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + expect(search).toHaveBeenCalledWith("error-query"); + expect(screen.getByText(/archive search unavailable/i)).toBeTruthy(); + } finally { + vi.useRealTimers(); + } + }); + + it("ignores archive results from a superseded query", async () => { + const alpha = deferred(); + const beta = deferred(); + const search = vi.fn((query: string) => (query === "alpha" ? alpha.promise : beta.promise)); + vi.useFakeTimers(); + + try { + render( {}} />); + const input = screen.getByRole("combobox"); + + fireEvent.change(input, { target: { value: "alpha" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + fireEvent.change(input, { target: { value: "beta" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + + await act(async () => { + beta.resolve([ + { id: "beta", label: "Beta result", group: "Archive", run: vi.fn() }, + ]); + }); + expect(screen.getByText("Beta result")).toBeTruthy(); + + await act(async () => { + alpha.resolve([ + { id: "alpha", label: "Alpha result", group: "Archive", run: vi.fn() }, + ]); + }); + expect(screen.queryByText("Alpha result")).toBeNull(); + expect(screen.getByText("Beta result")).toBeTruthy(); + } finally { + vi.useRealTimers(); + } + }); + + it("closes when clicking the backdrop", () => { + const { props, container } = renderPalette(); + const backdrop = container.querySelector(".palette__backdrop"); + expect(backdrop).toBeTruthy(); + if (backdrop) fireEvent.click(backdrop); + expect(props.onClose).toHaveBeenCalledTimes(1); + }); + + it("executes an option and closes when clicked directly", () => { + const items = makeItems(); + const onClose = vi.fn(); + renderPalette({ items, onClose }); + + const option = screen.getByText("Add webhook"); + fireEvent.click(option); + expect(items[1].run).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("updates active option on mouse enter", () => { + const items = makeItems(); + renderPalette({ items }); + + const option = screen.getByText("Refactor retry").closest("li"); + expect(option).toBeTruthy(); + if (option) fireEvent.mouseEnter(option); + + expect(option?.className).toContain("is-active"); + }); + + it("sets aria-setsize and aria-posinset on listbox options", () => { + const items = makeItems(); + renderPalette({ items }); + + const options = screen.getAllByRole("option"); + expect(options).toHaveLength(3); + expect(options[0].getAttribute("aria-setsize")).toBe("3"); + expect(options[0].getAttribute("aria-posinset")).toBe("1"); + expect(options[1].getAttribute("aria-setsize")).toBe("3"); + expect(options[1].getAttribute("aria-posinset")).toBe("2"); + expect(options[2].getAttribute("aria-setsize")).toBe("3"); + expect(options[2].getAttribute("aria-posinset")).toBe("3"); + }); + + it("restores focus to previous element on unmount", () => { + const trigger = document.createElement("button"); + document.body.appendChild(trigger); + trigger.focus(); + expect(document.activeElement).toBe(trigger); + + const { unmount } = renderPalette(); + expect(document.activeElement).toBe( + screen.getByRole("combobox", { name: /search commands and sessions/i }), + ); + + unmount(); + expect(document.activeElement).toBe(trigger); + trigger.remove(); + }); + + it("does not throw or close when Enter is pressed on an empty results list", () => { + const onClose = vi.fn(); + renderPalette({ items: [], onClose }); + const input = screen.getByRole("combobox"); + + fireEvent.keyDown(input, { key: "Enter" }); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("closes on Escape key press without executing any item", () => { + const onClose = vi.fn(); + const items = makeItems(); + renderPalette({ items, onClose }); + const input = screen.getByRole("combobox"); + + fireEvent.keyDown(input, { key: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(1); + expect(items[0].run).not.toHaveBeenCalled(); + }); + + it("renders command items without hints cleanly", () => { + const itemsWithoutHint: Command[] = [ + { id: "nohint", group: "Actions", label: "Toggle Dark Mode", run: vi.fn() }, + ]; + renderPalette({ items: itemsWithoutHint }); + expect(screen.getByText("Toggle Dark Mode")).toBeTruthy(); + expect(screen.queryByText("12 sessions")).toBeNull(); + }); +}); diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx new file mode 100644 index 0000000..540210b --- /dev/null +++ b/src/components/CommandPalette.tsx @@ -0,0 +1,283 @@ +import { useEffect, useMemo, useRef, useState } from "react"; + +import { useFocusTrap } from "../focus-trap"; + +export interface Command { + id: string; + label: string; + hint?: string; + group?: string; + run: () => void; +} + +const MAX_RESULTS = 50; +const ARCHIVE_SEARCH_DEBOUNCE_MS = 160; +const ARCHIVE_SEARCH_MIN_LENGTH = 2; + +type ArchiveSearch = (query: string) => Promise; + +interface ArchiveState { + query: string; + items: Command[]; + status: "idle" | "loading" | "settled" | "failed"; +} + +const IDLE_ARCHIVE: ArchiveState = { query: "", items: [], status: "idle" }; + +/** + * Rank a compact, ordered fuzzy match. Contiguous and word-boundary matches + * win, while wide gaps lose points. `null` means the query is not a + * subsequence of the candidate at all. + */ +function fuzzyScore(query: string, candidate: string): number | null { + const needle = query.trim().slice(0, 256).toLowerCase(); + const haystack = candidate.toLowerCase(); + if (needle === "") return 0; + + const substringAt = haystack.indexOf(needle); + if (substringAt >= 0) { + return 1_000 - substringAt * 2 - (haystack.length - needle.length) * 0.1; + } + + let score = 0; + let previous = -1; + let first = -1; + for (const character of needle) { + const at = haystack.indexOf(character, previous + 1); + if (at < 0) return null; + if (first < 0) first = at; + + const atBoundary = at === 0 || /[\s/_.:-]/.test(haystack[at - 1] ?? ""); + score += atBoundary ? 16 : 8; + if (previous >= 0) { + const gap = at - previous - 1; + score += gap === 0 ? 12 : Math.max(0, 6 - gap); + } + previous = at; + } + + return score - first - (haystack.length - needle.length) * 0.1; +} + +function commandScore(query: string, command: Command): number | null { + const label = fuzzyScore(query, command.label); + const hint = command.hint ? fuzzyScore(query, command.hint) : null; + const group = command.group ? fuzzyScore(query, command.group) : null; + const scores = [ + label, + hint === null ? null : hint - 30, + group === null ? null : group - 50, + ].filter((score): score is number => score !== null); + return scores.length > 0 ? Math.max(...scores) : null; +} + +/** + * A ⌘K command palette (Raycast/Linear pattern): fuzzy-filter over + * repositories, sessions, and actions, keyboard-driven (↑/↓, Enter, Esc). + */ +export default function CommandPalette({ + items, + search, + onClose, +}: { + items: Command[]; + search?: ArchiveSearch; + onClose: () => void; +}) { + const [query, setQuery] = useState(""); + const [active, setActive] = useState(0); + const [archive, setArchive] = useState(IDLE_ARCHIVE); + const inputRef = useRef(null); + const dialogRef = useRef(null); + const activeOptionRef = useRef(null); + const archiveTimerRef = useRef | null>(null); + const archiveRequestRef = useRef(0); + const searchRef = useRef(search); + useFocusTrap(dialogRef, true); + + const filtered = useMemo(() => { + const q = query.trim(); + if (!q) return items.slice(0, MAX_RESULTS); + const localMatches = items + .map((item, index) => ({ item, index, score: commandScore(q, item) })) + .filter((match): match is typeof match & { score: number } => match.score !== null); + const archiveMatches = archive.query === q + ? archive.items.map((item, index) => ({ + item, + index: items.length + index, + // FTS can match a session body even when its title does not fuzzy + // match. Keep those matches, after direct command-label matches. + score: commandScore(q, item) ?? -100 - index, + })) + : []; + const deduplicated = new Map(); + for (const match of [...localMatches, ...archiveMatches]) { + const previous = deduplicated.get(match.item.id); + if (!previous || match.score > previous.score) deduplicated.set(match.item.id, match); + } + return [...deduplicated.values()] + .sort((a, b) => b.score - a.score || a.index - b.index) + .slice(0, MAX_RESULTS) + .map(({ item }) => item); + }, [archive, items, query]); + + const activeIndex = filtered.length === 0 ? 0 : Math.min(active, filtered.length - 1); + const archiveLoading = archive.query === query.trim() && archive.status === "loading"; + const archiveFailed = archive.query === query.trim() && archive.status === "failed"; + + useEffect(() => { + searchRef.current = search; + }, [search]); + + useEffect(() => { + const previousFocus = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + inputRef.current?.focus(); + return () => { + if (previousFocus?.isConnected) previousFocus.focus(); + }; + }, []); + + useEffect( + () => () => { + archiveRequestRef.current += 1; + if (archiveTimerRef.current !== null) clearTimeout(archiveTimerRef.current); + }, + [], + ); + + useEffect(() => { + activeOptionRef.current?.scrollIntoView?.({ block: "nearest" }); + }, [activeIndex, filtered]); + + function run(index: number) { + const command = filtered[index]; + if (command) { + command.run(); + onClose(); + } + } + + function updateQuery(next: string) { + setQuery(next); + setActive(0); + archiveRequestRef.current += 1; + const request = archiveRequestRef.current; + if (archiveTimerRef.current !== null) clearTimeout(archiveTimerRef.current); + + const normalized = next.trim(); + if (!searchRef.current || normalized.length < ARCHIVE_SEARCH_MIN_LENGTH) { + setArchive(IDLE_ARCHIVE); + return; + } + + setArchive({ query: normalized, items: [], status: "loading" }); + archiveTimerRef.current = setTimeout(() => { + archiveTimerRef.current = null; + try { + void Promise.resolve(searchRef.current?.(normalized)).then( + (nextItems) => { + if (request !== archiveRequestRef.current) return; + setArchive({ query: normalized, items: nextItems ?? [], status: "settled" }); + }, + () => { + if (request !== archiveRequestRef.current) return; + setArchive({ query: normalized, items: [], status: "failed" }); + }, + ); + } catch { + if (request !== archiveRequestRef.current) return; + setArchive({ query: normalized, items: [], status: "failed" }); + } + }, ARCHIVE_SEARCH_DEBOUNCE_MS); + } + + function onKeyDown(event: React.KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + } else if (event.key === "ArrowDown") { + event.preventDefault(); + if (filtered.length > 0) setActive(Math.min(activeIndex + 1, filtered.length - 1)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + if (filtered.length > 0) setActive(Math.max(activeIndex - 1, 0)); + } else if (event.key === "Home") { + event.preventDefault(); + if (filtered.length > 0) setActive(0); + } else if (event.key === "End") { + event.preventDefault(); + if (filtered.length > 0) setActive(filtered.length - 1); + } else if (event.key === "Enter") { + event.preventDefault(); + run(activeIndex); + } + } + + return ( +
+
event.stopPropagation()} + > + updateQuery(event.target.value)} + onKeyDown={onKeyDown} + role="combobox" + aria-label="Search commands and sessions" + aria-autocomplete="list" + aria-expanded="true" + aria-controls="palette-list" + aria-activedescendant={filtered[activeIndex]?.id} + /> +
    + {filtered.length === 0 && !archiveLoading ? ( +
  • + No matches +
  • + ) : ( + filtered.map((command, index) => ( +
  • setActive(index)} + onClick={() => run(index)} + > + {command.group && {command.group}} + {command.label} + {command.hint && {command.hint}} +
  • + )) + )} +
+ {(archiveLoading || archiveFailed) && ( +

+ {archiveLoading ? "Searching archive…" : "Archive search unavailable."} +

+ )} +
+
+ ); +} diff --git a/src/components/FolderList.test.tsx b/src/components/FolderList.test.tsx new file mode 100644 index 0000000..71025e0 --- /dev/null +++ b/src/components/FolderList.test.tsx @@ -0,0 +1,160 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { FolderSummary } from "../ipc"; +import FolderList from "./FolderList"; + +function folder(id: string, name: string, count = 0): FolderSummary { + return { id, name, session_count: count, position: 0 }; +} + +function setup(overrides: Partial[0]> = {}) { + const props = { + folders: [folder("f1", "Inbox", 2)], + selectedId: null, + onSelect: vi.fn(), + onCreate: vi.fn(), + onRename: vi.fn(), + onDelete: vi.fn(), + onDropSession: vi.fn(), + ...overrides, + }; + render(); + return props; +} + +describe("FolderList", () => { + it("renders folders with their thread counts", () => { + setup(); + expect(screen.getByText("Inbox")).toBeTruthy(); + expect(screen.getByText("2")).toBeTruthy(); + }); + + it("shows an empty hint when there are no folders", () => { + setup({ folders: [] }); + expect(screen.getByText(/No folders yet/i)).toBeTruthy(); + }); + + it("creates a folder on Enter and ignores blank names", () => { + const props = setup({ folders: [] }); + fireEvent.click(screen.getByLabelText("New folder")); + const field = screen.getByLabelText("New folder name"); + + // Blank input commits nothing. + fireEvent.keyDown(field, { key: "Enter" }); + expect(props.onCreate).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByLabelText("New folder")); + const field2 = screen.getByLabelText("New folder name"); + fireEvent.change(field2, { target: { value: " Later " } }); + fireEvent.keyDown(field2, { key: "Enter" }); + expect(props.onCreate).toHaveBeenCalledWith("Later"); + }); + + it("selects a folder on click and deletes on the ✕", () => { + const props = setup(); + fireEvent.click(screen.getByRole("button", { name: /^Inbox/ })); + expect(props.onSelect).toHaveBeenCalledWith("f1"); + + fireEvent.click(screen.getByLabelText("Delete folder Inbox")); + expect(props.onDelete).toHaveBeenCalledWith("f1"); + }); + + it("renames a folder on F2 keyboard shortcut", () => { + const props = setup(); + const folderBtn = screen.getByRole("button", { name: /^Inbox/ }); + fireEvent.keyDown(folderBtn, { key: "F2" }); + + const editField = screen.getByLabelText("Rename folder Inbox"); + expect(editField).toBeTruthy(); + fireEvent.change(editField, { target: { value: "Archive" } }); + fireEvent.keyDown(editField, { key: "Enter" }); + expect(props.onRename).toHaveBeenCalledWith("f1", "Archive"); + }); + + it("deletes a folder on Delete or Cmd+Backspace keyboard shortcut", () => { + const props = setup(); + const folderBtn = screen.getByRole("button", { name: /^Inbox/ }); + fireEvent.keyDown(folderBtn, { key: "Delete" }); + expect(props.onDelete).toHaveBeenCalledWith("f1"); + + fireEvent.keyDown(folderBtn, { key: "Backspace", metaKey: true }); + expect(props.onDelete).toHaveBeenCalledTimes(2); + }); + + it("files a dropped thread into the folder", () => { + const props = setup(); + const row = screen.getByText("Inbox").closest("li")!; + const data: Record = { + "application/x-lore-session": "sess-9", + }; + const dataTransfer = { + types: Object.keys(data), + getData: (type: string) => data[type] ?? "", + dropEffect: "", + }; + fireEvent.drop(row, { dataTransfer }); + expect(props.onDropSession).toHaveBeenCalledWith("f1", "sess-9"); + }); + + it("toggles is-dragover class on dragOver and dragLeave with valid session mime", () => { + setup(); + const row = screen.getByText("Inbox").closest("li")!; + const dataTransfer = { + types: ["application/x-lore-session"], + dropEffect: "", + }; + fireEvent.dragOver(row, { dataTransfer }); + expect(row.classList.contains("is-dragover")).toBe(true); + expect(dataTransfer.dropEffect).toBe("move"); + + fireEvent.dragLeave(row); + expect(row.classList.contains("is-dragover")).toBe(false); + }); + + it("cancels folder creation and renaming on Escape key", () => { + const props = setup(); + // Cancel create + fireEvent.click(screen.getByLabelText("New folder")); + const createField = screen.getByLabelText("New folder name"); + fireEvent.change(createField, { target: { value: "Abandoned" } }); + fireEvent.keyDown(createField, { key: "Escape" }); + expect(props.onCreate).not.toHaveBeenCalled(); + expect(screen.queryByLabelText("New folder name")).toBeNull(); + + expect(props.onRename).not.toHaveBeenCalled(); + expect(screen.queryByLabelText("Rename folder Inbox")).toBeNull(); + }); + + it("ignores dragOver events with non-session MIME types", () => { + setup(); + const row = screen.getByText("Inbox").closest("li")!; + const dataTransfer = { + types: ["text/plain"], + dropEffect: "", + }; + fireEvent.dragOver(row, { dataTransfer }); + expect(row.classList.contains("is-dragover")).toBe(false); + expect(dataTransfer.dropEffect).toBe(""); + }); + + it("commits folder rename on blur", () => { + const props = setup(); + const folderBtn = screen.getByRole("button", { name: /^Inbox/ }); + fireEvent.keyDown(folderBtn, { key: "F2" }); + const editField = screen.getByLabelText("Rename folder Inbox"); + fireEvent.change(editField, { target: { value: "Inbox Renamed" } }); + fireEvent.blur(editField); + expect(props.onRename).toHaveBeenCalledWith("f1", "Inbox Renamed"); + }); + + it("does not call onRename when submitted name is blank", () => { + const props = setup(); + const folderBtn = screen.getByRole("button", { name: /^Inbox/ }); + fireEvent.keyDown(folderBtn, { key: "F2" }); + const editField = screen.getByLabelText("Rename folder Inbox"); + fireEvent.change(editField, { target: { value: " " } }); + fireEvent.keyDown(editField, { key: "Enter" }); + expect(props.onRename).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/FolderList.tsx b/src/components/FolderList.tsx new file mode 100644 index 0000000..3548e6d --- /dev/null +++ b/src/components/FolderList.tsx @@ -0,0 +1,211 @@ +import { useRef, useState } from "react"; + +import type { FolderSummary } from "../ipc"; +import { SESSION_DND_MIME } from "./SessionList"; + +/** + * The user's folders in the left rail. Folders are created inline, selected to + * filter the session list, renamed by double-click, and deleted with the ✕. + * Dragging a thread from the session list onto a folder files it there (one + * folder per thread). + */ +export default function FolderList({ + folders, + selectedId, + onSelect, + onCreate, + onRename, + onDelete, + onDropSession, +}: { + folders: FolderSummary[]; + selectedId: string | null; + onSelect: (id: string) => void; + onCreate: (name: string) => void; + onRename: (id: string, name: string) => void; + onDelete: (id: string) => void; + onDropSession: (folderId: string, sessionId: string) => void; +}) { + const [creating, setCreating] = useState(false); + const [newName, setNewName] = useState(""); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(""); + const [dragOverId, setDragOverId] = useState(null); + // Enter and blur both fire commit; the guard keeps that from double-creating. + const committedRef = useRef(false); + + function openCreate() { + committedRef.current = false; + setNewName(""); + setCreating(true); + } + function commitCreate() { + if (committedRef.current) return; + committedRef.current = true; + const name = newName.trim(); + setCreating(false); + setNewName(""); + if (name) onCreate(name); + } + function cancelCreate() { + committedRef.current = true; + setCreating(false); + setNewName(""); + } + + function startEdit(folder: FolderSummary) { + committedRef.current = false; + setEditName(folder.name); + setEditingId(folder.id); + } + function commitEdit() { + if (committedRef.current) return; + committedRef.current = true; + const name = editName.trim(); + const id = editingId; + setEditingId(null); + if (id && name) onRename(id, name); + } + function cancelEdit() { + committedRef.current = true; + setEditingId(null); + } + + function hasSession(event: React.DragEvent) { + return event.dataTransfer.types.includes(SESSION_DND_MIME); + } + + return ( + + ); +} diff --git a/src/components/RepositoryList.tsx b/src/components/RepositoryList.tsx new file mode 100644 index 0000000..ee177c4 --- /dev/null +++ b/src/components/RepositoryList.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; + +import type { RepositorySummary } from "../ipc"; +import { SESSION_DND_MIME } from "./SessionList"; + +export default function RepositoryList({ + repositories, + selectedId, + allSelected, + onSelect, + onUnfileSession, +}: { + repositories: RepositorySummary[]; + selectedId: string | null; + /** + * True when the unfiltered "All sessions" view is active (no repo or folder). + * Defaults to "no repo selected" for callers that have no folder dimension. + */ + allSelected?: boolean; + onSelect: (id: string | null) => void; + /** Drop a thread here to remove it from its folder. */ + onUnfileSession?: (sessionId: string) => void; +}) { + const isAll = allSelected ?? selectedId === null; + const [unfileHover, setUnfileHover] = useState(false); + const hasSession = (event: React.DragEvent) => + event.dataTransfer.types.includes(SESSION_DND_MIME); + + return ( + + ); +} diff --git a/src/components/SearchResults.test.tsx b/src/components/SearchResults.test.tsx new file mode 100644 index 0000000..2e24e99 --- /dev/null +++ b/src/components/SearchResults.test.tsx @@ -0,0 +1,446 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import SearchResults from "./SearchResults"; +import { HIGHLIGHT_END, HIGHLIGHT_START, type SearchHit } from "../ipc"; + +function hit(overrides: Partial = {}): SearchHit { + return { + session_id: "s1", + source_kind: "message_part", + source_id: "p1", + field: "text", + snippet: `deploy the ${HIGHLIGHT_START}retryBackoff${HIGHLIGHT_END} helper`, + rank: -1.2, + title: "Add retry backoff", + agent_id: "codex", + started_at: null, + ...overrides, + }; +} + +const idle = { + searching: false, + hasMore: false, + loadingMore: false, + onLoadMore: () => {}, +}; + +describe("SearchResults", () => { + it("renders nothing for an empty query", () => { + const { container } = render( + {}} {...idle} />, + ); + expect(container.firstChild).toBeNull(); + }); + + it("shows an empty state when a query has no matches", () => { + render( + {}} {...idle} />, + ); + expect(screen.getByText(/no matches/i)).toBeTruthy(); + expect(screen.getByRole("status").textContent).toContain("No matches"); + }); + + it("announces an in-flight search instead of showing a false empty state", () => { + render( + {}} + {...idle} + searching + />, + ); + expect(screen.getByRole("status").textContent).toContain("Searching"); + expect(screen.queryByText(/no matches/i)).toBeNull(); + }); + + it("highlights matched terms and opens the session on click", () => { + const onOpen = vi.fn(); + render( + , + ); + + // The matched term is wrapped in , and the markers themselves are gone. + const mark = screen.getByText("retryBackoff"); + expect(mark.tagName).toBe("MARK"); + expect(screen.getByText("Add retry backoff")).toBeTruthy(); + expect(document.body.textContent).not.toContain(HIGHLIGHT_START); + + fireEvent.click(screen.getByRole("option")); + expect(onOpen).toHaveBeenCalledWith("s1"); + + fireEvent.click(screen.getByText("Add retry backoff")); + expect(onOpen).toHaveBeenCalledTimes(2); + expect(onOpen).toHaveBeenLastCalledWith("s1"); + }); + + it("shows a Load more control only when more pages exist and fires it on click", () => { + const onLoadMore = vi.fn(); + const { rerender } = render( + {}} {...idle} />, + ); + // No further pages: no button. + expect(screen.queryByRole("button", { name: /load more/i })).toBeNull(); + + // More pages available: the button appears and invokes the callback. + rerender( + {}} + searching={false} + hasMore + loadingMore={false} + onLoadMore={onLoadMore} + />, + ); + fireEvent.click(screen.getByRole("button", { name: /load more/i })); + expect(onLoadMore).toHaveBeenCalledTimes(1); + + // While a page is loading the control is disabled to prevent double fetches. + rerender( + {}} + searching={false} + hasMore + loadingMore + onLoadMore={onLoadMore} + />, + ); + expect(screen.getByRole("button", { name: /loading/i }).hasAttribute("disabled")).toBe(true); + }); + + it("exposes a keyboard-operable listbox with a roving active option", () => { + render( + {}} + {...idle} + />, + ); + // The listbox is focusable and the ARIA listbox pattern is complete. + const listbox = screen.getByRole("listbox", { name: /search results/i }); + expect(listbox.getAttribute("tabindex")).toBe("0"); + // First option is active by default. + expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-0"); + const options = screen.getAllByRole("option"); + expect(options[0].getAttribute("aria-posinset")).toBe("1"); + expect(options[0].getAttribute("aria-setsize")).toBe("2"); + expect(options[0].className).toContain("is-active"); + + // ArrowDown/Up move the roving active option (aria-activedescendant tracks it). + fireEvent.keyDown(listbox, { key: "ArrowDown" }); + expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-1"); + expect(screen.getAllByRole("option")[1].className).toContain("is-active"); + fireEvent.keyDown(listbox, { key: "ArrowUp" }); + expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-0"); + + // j and k also navigate down and up. + fireEvent.keyDown(listbox, { key: "j" }); + expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-1"); + fireEvent.keyDown(listbox, { key: "k" }); + expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-0"); + + // Home/End jump to the ends; movement is clamped at the boundaries. + fireEvent.keyDown(listbox, { key: "End" }); + expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-1"); + fireEvent.keyDown(listbox, { key: "ArrowDown" }); + expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-1"); + fireEvent.keyDown(listbox, { key: "Home" }); + expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-0"); + }); + + it("calls onExitUp when pressing ArrowUp or k on the first result", () => { + const onExitUp = vi.fn(); + render( + {}} + onExitUp={onExitUp} + {...idle} + />, + ); + const listbox = screen.getByRole("listbox", { name: /search results/i }); + fireEvent.keyDown(listbox, { key: "ArrowUp" }); + expect(onExitUp).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(listbox, { key: "k" }); + expect(onExitUp).toHaveBeenCalledTimes(2); + }); + + it("navigates with j, k, Home and End keys", () => { + render( + {}} + {...idle} + />, + ); + const listbox = screen.getByRole("listbox", { name: /search results/i }); + expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-0"); + + // 'j' moves down + fireEvent.keyDown(listbox, { key: "j" }); + expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-1"); + + // 'End' jumps to the end + fireEvent.keyDown(listbox, { key: "End" }); + expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-2"); + + // 'k' moves up + fireEvent.keyDown(listbox, { key: "k" }); + expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-1"); + + // 'Home' jumps to the start + fireEvent.keyDown(listbox, { key: "Home" }); + expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-0"); + }); + + it("opens the active result on Enter", () => { + const onOpen = vi.fn(); + render( + , + ); + const listbox = screen.getByRole("listbox", { name: /search results/i }); + fireEvent.keyDown(listbox, { key: "ArrowDown" }); + fireEvent.keyDown(listbox, { key: "Enter" }); + expect(onOpen).toHaveBeenCalledWith("b"); + }); + + it("resets the active option to the top when the query changes", () => { + const hits = [ + hit({ session_id: "a", source_id: "pa" }), + hit({ session_id: "b", source_id: "pb" }), + ]; + const { rerender } = render( + {}} {...idle} />, + ); + const listbox = screen.getByRole("listbox", { name: /search results/i }); + fireEvent.keyDown(listbox, { key: "End" }); + expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-1"); + // A new query is a fresh context: the active option returns to the top. + rerender( + {}} {...idle} />, + ); + expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-0"); + }); + + it("shows how long ago a result was recorded", () => { + const { container } = render( + {}} + {...idle} + />, + ); + // A recent result carries its relative time; a result with no timestamp + // renders no