From bedaccbc46d0ced7a01a5f29df3477ee4f5eb7be Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sun, 30 Aug 2026 11:48:30 -0300 Subject: [PATCH 01/23] test(session): stabilize force-kill timing Keep the assertion tied to the two-second supervisor interval while using the production-like one-second reap deadline from the shared test configuration. This avoids treating a loaded macOS runner's inability to reap within 50 ms as a runtime failure. --- crates/session/tests/pty_sessions/lifecycle.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/session/tests/pty_sessions/lifecycle.rs b/crates/session/tests/pty_sessions/lifecycle.rs index 0010d70..bee8e27 100644 --- a/crates/session/tests/pty_sessions/lifecycle.rs +++ b/crates/session/tests/pty_sessions/lifecycle.rs @@ -71,9 +71,9 @@ fn stop_force_kills_a_process_that_ignores_graceful_signals() { #[test] fn force_kill_reaps_without_waiting_for_the_supervisor_interval() { + let supervisor_interval = Duration::from_secs(2); let config = SessionManagerConfig { - supervisor_interval: Duration::from_secs(2), - kill_wait: Duration::from_millis(50), + supervisor_interval, ..test_config() }; let runtime = TestRuntime::with_config(config); @@ -83,8 +83,12 @@ fn force_kill_reaps_without_waiting_for_the_supervisor_interval() { let started = Instant::now(); let snapshot = runtime.manager.kill(session_id).unwrap(); + let elapsed = started.elapsed(); - assert!(started.elapsed() < Duration::from_millis(500)); + assert!( + elapsed < supervisor_interval, + "force kill took {elapsed:?}, so it may have waited for the supervisor" + ); assert_eq!(snapshot.status, SessionStatus::Exited); let (terminal_transitions, exit_events) = count_terminal_events(&mut events); assert_eq!(terminal_transitions, 1); From c69be7b63cb00dedfe5885a408d84636d28a85d7 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Thu, 3 Sep 2026 10:28:38 -0300 Subject: [PATCH 02/23] fix(session): keep process snapshots monotonic --- crates/session/src/runtime/process_tree.rs | 87 +++++++++++++++++++++- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/crates/session/src/runtime/process_tree.rs b/crates/session/src/runtime/process_tree.rs index 50eca30..d2b14b6 100644 --- a/crates/session/src/runtime/process_tree.rs +++ b/crates/session/src/runtime/process_tree.rs @@ -1,7 +1,10 @@ use std::{ collections::{BTreeMap, BTreeSet, HashMap}, io, - sync::{Arc, OnceLock}, + sync::{ + Arc, OnceLock, + atomic::{AtomicU64, Ordering}, + }, time::{Duration, Instant}, }; @@ -12,11 +15,13 @@ const SNAPSHOT_CACHE_TTL: Duration = Duration::from_millis(20); #[derive(Clone)] struct CachedSnapshot { + sequence: u64, captured_at: Instant, records: Arc<[ProcessRecord]>, } static PROCESS_SNAPSHOT_CACHE: OnceLock>> = OnceLock::new(); +static NEXT_SNAPSHOT_SEQUENCE: AtomicU64 = AtomicU64::new(1); #[derive(Clone, Debug, Eq, PartialEq)] struct ProcessIdentity { @@ -40,6 +45,7 @@ pub(super) struct TrackedProcess { pub(super) struct ProcessTree { known: BTreeMap, + root_snapshot_sequence: u64, scan_timeout: Duration, max_tracked_processes: usize, } @@ -54,6 +60,7 @@ impl ProcessTree { let root_pid = root_pid.as_raw(); let mut tree = Self { known: BTreeMap::new(), + root_snapshot_sequence: records.sequence, scan_timeout, max_tracked_processes, }; @@ -69,7 +76,10 @@ impl ProcessTree { } pub fn refresh(&mut self) -> io::Result> { - let snapshot = process_snapshot(self.scan_timeout, false)?; + // A global scan can begin before this tree's root exists and publish + // after the root-proving scan. Never let that older view prune the root. + let snapshot = + process_snapshot_after(self.scan_timeout, false, Some(self.root_snapshot_sequence))?; self.absorb(&snapshot.records) } @@ -144,6 +154,7 @@ impl ProcessTree { fn with_root_for_test(root: ProcessRecord, max_tracked_processes: usize) -> Self { Self { known: BTreeMap::from([(root.identity.pid, root.identity)]), + root_snapshot_sequence: 1, scan_timeout: Duration::from_secs(1), max_tracked_processes, } @@ -151,7 +162,16 @@ impl ProcessTree { } fn process_snapshot(timeout: Duration, force: bool) -> io::Result { + process_snapshot_after(timeout, force, None) +} + +fn process_snapshot_after( + timeout: Duration, + force: bool, + minimum_sequence: Option, +) -> io::Result { let started = Instant::now(); + let sequence = next_snapshot_sequence()?; let cache = PROCESS_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(None)); if !force { let Some(cached) = cache.try_lock_for(timeout) else { @@ -161,7 +181,7 @@ fn process_snapshot(timeout: Duration, force: bool) -> io::Result io::Result io::Result io::Result { + // The counter is only an ordering token; the cache mutex publishes the + // snapshot data, so no cross-thread memory ordering is required here. + NEXT_SNAPSHOT_SEQUENCE + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |sequence| { + sequence.checked_add(1) + }) + .map_err(|_| io::Error::other("process-tree snapshot sequence exhausted")) +} + +fn cached_snapshot_is_usable(snapshot: &CachedSnapshot, minimum_sequence: Option) -> bool { + snapshot.captured_at.elapsed() <= SNAPSHOT_CACHE_TTL + && minimum_sequence.is_none_or(|minimum| snapshot.sequence >= minimum) +} + +fn publish_snapshot(cache: &mut Option, snapshot: CachedSnapshot) { + let should_publish = cache + .as_ref() + .is_none_or(|cached| snapshot.sequence > cached.sequence); + if should_publish { + *cache = Some(snapshot); + } +} + #[cfg(target_os = "linux")] fn scan_processes_uncached(timeout: Duration) -> io::Result> { const MAX_PROCESS_SNAPSHOT_RECORDS: usize = 262_144; @@ -365,6 +412,38 @@ mod tests { } } + fn snapshot( + sequence: u64, + captured_at: Instant, + records: impl Into>, + ) -> CachedSnapshot { + CachedSnapshot { + sequence, + captured_at, + records: records.into(), + } + } + + #[test] + fn out_of_order_scan_cannot_replace_a_newer_cached_snapshot() { + let newer = snapshot(2, Instant::now(), vec![record(200, 1, 200, "newer")]); + let older = snapshot(1, Instant::now(), vec![record(100, 1, 100, "older")]); + let mut cache = Some(newer); + + publish_snapshot(&mut cache, older); + + let cached = cache.expect("newer cache entry should be retained"); + assert_eq!(cached.sequence, 2); + assert_eq!(cached.records[0].identity.pid, 200); + } + + #[test] + fn cached_snapshot_from_before_root_proof_is_not_usable() { + let stale = snapshot(1, Instant::now(), Vec::::new()); + + assert!(!cached_snapshot_is_usable(&stale, Some(2))); + } + #[test] fn only_proven_descendants_are_retained_across_group_changes() { let root = record(100, 1, 100, "root"); From b843c1975f59b3633f1c065a0779f6b51143be43 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Thu, 3 Sep 2026 10:32:05 -0300 Subject: [PATCH 03/23] fix(session): advance process snapshot floor --- crates/session/src/runtime/process_tree.rs | 54 ++++++++++++++++------ 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/crates/session/src/runtime/process_tree.rs b/crates/session/src/runtime/process_tree.rs index d2b14b6..04bc9b5 100644 --- a/crates/session/src/runtime/process_tree.rs +++ b/crates/session/src/runtime/process_tree.rs @@ -45,7 +45,7 @@ pub(super) struct TrackedProcess { pub(super) struct ProcessTree { known: BTreeMap, - root_snapshot_sequence: u64, + latest_snapshot_sequence: u64, scan_timeout: Duration, max_tracked_processes: usize, } @@ -56,36 +56,45 @@ impl ProcessTree { scan_timeout: Duration, max_tracked_processes: usize, ) -> io::Result { - let records = process_snapshot(scan_timeout, true)?; + let snapshot = process_snapshot(scan_timeout, true)?; let root_pid = root_pid.as_raw(); let mut tree = Self { known: BTreeMap::new(), - root_snapshot_sequence: records.sequence, + latest_snapshot_sequence: snapshot.sequence, scan_timeout, max_tracked_processes, }; - if let Some(root) = records + if let Some(root) = snapshot .records .iter() .find(|record| record.identity.pid == root_pid && !record.zombie) { tree.known.insert(root_pid, root.identity.clone()); } - let _ = tree.absorb(&records.records)?; + let _ = tree.absorb_snapshot(&snapshot)?; Ok(tree) } pub fn refresh(&mut self) -> io::Result> { - // A global scan can begin before this tree's root exists and publish - // after the root-proving scan. Never let that older view prune the root. - let snapshot = - process_snapshot_after(self.scan_timeout, false, Some(self.root_snapshot_sequence))?; - self.absorb(&snapshot.records) + // A global scan can begin before this tree's latest evidence and + // publish afterward. Never let that older view prune a proven process. + let snapshot = process_snapshot_after( + self.scan_timeout, + false, + Some(self.latest_snapshot_sequence), + )?; + self.absorb_snapshot(&snapshot) } pub fn refresh_fresh(&mut self) -> io::Result> { let snapshot = process_snapshot(self.scan_timeout, true)?; - self.absorb(&snapshot.records) + self.absorb_snapshot(&snapshot) + } + + fn absorb_snapshot(&mut self, snapshot: &CachedSnapshot) -> io::Result> { + let processes = self.absorb(&snapshot.records)?; + self.latest_snapshot_sequence = self.latest_snapshot_sequence.max(snapshot.sequence); + Ok(processes) } fn absorb(&mut self, records: &[ProcessRecord]) -> io::Result> { @@ -154,7 +163,7 @@ impl ProcessTree { fn with_root_for_test(root: ProcessRecord, max_tracked_processes: usize) -> Self { Self { known: BTreeMap::from([(root.identity.pid, root.identity)]), - root_snapshot_sequence: 1, + latest_snapshot_sequence: 1, scan_timeout: Duration::from_secs(1), max_tracked_processes, } @@ -438,12 +447,31 @@ mod tests { } #[test] - fn cached_snapshot_from_before_root_proof_is_not_usable() { + fn cached_snapshot_from_before_latest_tree_evidence_is_not_usable() { let stale = snapshot(1, Instant::now(), Vec::::new()); assert!(!cached_snapshot_is_usable(&stale, Some(2))); } + #[test] + fn fresh_snapshot_advances_the_tree_cache_floor() { + let root = record(100, 1, 100, "root"); + let child = record(101, 100, 101, "child"); + let mut tree = ProcessTree::with_root_for_test(root.clone(), 8); + let newer = snapshot(3, Instant::now(), vec![root.clone(), child]); + + tree.absorb_snapshot(&newer) + .expect("newer process-tree evidence should be accepted"); + + let stale = snapshot(2, Instant::now(), vec![root]); + assert_eq!(tree.latest_snapshot_sequence, 3); + assert!(tree.known.contains_key(&101)); + assert!(!cached_snapshot_is_usable( + &stale, + Some(tree.latest_snapshot_sequence) + )); + } + #[test] fn only_proven_descendants_are_retained_across_group_changes() { let root = record(100, 1, 100, "root"); From 569449ff3c2e2fd9942ccc89ac75b65bee79e1dd Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 21:36:15 -0300 Subject: [PATCH 04/23] docs(xirp): record local scope and integration contracts --- docs/adr/0005-local-knowledge.md | 74 ++++++++++++++++++++++++++ docs/codex/xirp-context-report.md | 37 +++++++++++++ docs/codex/xirp-integration-request.md | 25 +++++++++ docs/xirp-local-parity.md | 36 +++++++++++++ 4 files changed, 172 insertions(+) create mode 100644 docs/adr/0005-local-knowledge.md create mode 100644 docs/codex/xirp-context-report.md create mode 100644 docs/codex/xirp-integration-request.md create mode 100644 docs/xirp-local-parity.md diff --git a/docs/adr/0005-local-knowledge.md b/docs/adr/0005-local-knowledge.md new file mode 100644 index 0000000..abdea73 --- /dev/null +++ b/docs/adr/0005-local-knowledge.md @@ -0,0 +1,74 @@ +# ADR 0005: Local prompts, reusable context, and organization + +Status: proposed; shared contract and migration registration await S2 agreement. + +## Context + +CLI Master needs global/project prompts, reusable local context, and work +organization without assuming ownership of coding agents. The XIRP reference +separates optional Portal workspaces from local project/terminal operations. +The canvas remains the primary UI. Linux and macOS remain first-class. + +## Decision + +`knowledge` modules hold explicit user-authored saved prompts and context +records. Core types are pure. Storage uses the existing SQLite database; +daemon handlers expose the agreed versioned wire contract. No additional +Tauri client, file service, process executor, or database is introduced. + +A record has a UUIDv7 identity, a `prompt` or `context` kind, a nullable project +ID (null means global), title/body, monotonically increasing integer revision, +and epoch-millisecond timestamps. Project queries include project and global +records; global queries include only global records. Project deletion must +have a documented foreign-key policy, while archive preserves all records. + +Creation omits ID and expected revision. Updates/deletes supply both identity +and the last observed revision. A concurrent edit fails with a stable conflict +error and preserves the editor draft. Save-as-copy is explicit. Input and list +response sizes are bounded to fit the daemon's transport limits. Content must +not appear in Debug, error details, or logs. + +Selecting a saved prompt or context inserts editable text in the composer. +Only a separate explicit runtime action delivers it to an agent. Saving text +never writes terminal input. Reusing context is not restoring a vendor +conversation and does not persist a terminal transcript. The initial objective +must eventually be delivered through SessionManager/verified adapter options; +a stored text field alone is not completion of that requirement. + +Project/session pin/archive and session workflow are organization metadata. +They neither replace process status nor signal a process. Workflow states are +`backlog`, `in_progress`, `in_review`, `blocked`, and `done`; archive is a +separate flag. The runtime owner coordinates their shared types/storage. + +Rule/skill discovery inventories explicitly supported locations with origin, +format, scope, and precedence explanations. It reuses the runtime owner's +file-access boundary; discovered content is data and is never executed. +Credential paths are excluded. Symlinks, external changes, size/count limits, +encoding, and edit conflicts must have explicit behavior and real filesystem +tests. Reference support for symlinked skill directories is a parity item, +not permission to traverse arbitrary links or read unrelated home files. + +Agent-specific resume/fork/options require verified adapter capabilities. +Attention needs explicit trustworthy events. Token/cost usage needs native +records with provenance; unknown data is unavailable, never inferred from +terminal byte counts, silence, or process duration. + +## Integration and dependencies + +S2 owns shared Rust wire registration, catalog mirrors, migrations, daemon +composition/dispatch, runtime/adapters, and the generic file service. S3 sends +small contracts before editing those boundaries. S1 owns mounting the isolated +components in the canvas/palette and composer draft insertion. Working +components and green unit tests do not prove their integrated application flow. + +Portal authentication, shared workspaces, MCP exposure and transcript sharing +remain separate capabilities requiring real contracts/account verification. +Local context is a CLI Master extension and is not branded as Portal support. + +## Verification + +Contract tests reject malformed inputs. Persistence tests use real SQLite +files, reopen, competing connections, project scope, and FK behavior. Socket +roundtrips prove handlers and effects. Frontend tests use typed callbacks/the +project IPC client. Canvas integration, Linux/macOS CI and runtime evidence +are tracked separately from isolated component tests. diff --git a/docs/codex/xirp-context-report.md b/docs/codex/xirp-context-report.md new file mode 100644 index 0000000..2fe3b56 --- /dev/null +++ b/docs/codex/xirp-context-report.md @@ -0,0 +1,37 @@ +# XIRP local workflows report + +Branch: `feat/xirp-local-workflows`. +Worktree: `/Users/eguimacs/cli-master-xirp`. +Base: `0ac8dd7`, fetched from `origin/refactor/canvas-only-shell` on 2026-09-05. + +## Scope and evidence + +The source/acceptance inventory is [xirp-local-parity.md](../xirp-local-parity.md). +The architecture proposal is [ADR 0005](../adr/0005-local-knowledge.md). +The full user request remains open; documentation and isolated components are +intermediate deliverables. + +## Coordination + +A concrete proposal was placed in `docs/codex/xirp-integration-request.md` in +S1, S2 and S3 worktrees. S2 acknowledgment is pending for shared contracts, +module registrations, migration allocation and dispatch. S1 acknowledgment is +pending for canvas mounting and composer insertion. No shared code has been +changed by S3 at this point. + +Baseline drift reported to owners: active daemon dispatch is `server.rs`; +`client.rs` is uncompiled. Frontend currently has `types.ts`/`schema.ts` rather +than the mirrors named in AGENTS. Runtime registry/adapters need consolidation +by S2; S3 will not introduce another registry or Tauri client. + +## Work in progress + +- Pure validated knowledge types and contract tests (new module only). +- Isolated saved prompt/context editor/picker with typed callbacks and tests. +- Persisted handlers and integration follow the agreed shared boundaries. + +## Commits and checks + +Initial documentation commit: source review completed; `git diff --check`. +Implementation tests and runtime/platform evidence will be recorded here as +executed. No Linux/macOS runtime parity is claimed yet. diff --git a/docs/codex/xirp-integration-request.md b/docs/codex/xirp-integration-request.md new file mode 100644 index 0000000..0522dbf --- /dev/null +++ b/docs/codex/xirp-integration-request.md @@ -0,0 +1,25 @@ +# XIRP S3 integration request — 2026-09-05 + +S3 is active in `/Users/eguimacs/cli-master-xirp`, branch `feat/xirp-local-workflows`, based on latest `origin/refactor/canvas-only-shell` (`0ac8dd7`). The user explicitly requested coordination before shared changes. This file is a coordination message, not part of your commit unless useful. + +## S2 runtime / shared-contract owner + +Please reply in `/Users/eguimacs/cli-master-xirp/docs/codex/xirp-coordination-reply.md` or append below in this copy. I will inspect both main and runtime copies. + +Proposed S3 ownership: new `knowledge/` modules under core/storage/daemon; isolated React components under `app/features/knowledge/`; domain tests and XIRP report. No parallel process executor, file service, database, or Tauri client. + +First vertical increment: global/project saved prompts (UUIDv7, title/body/projectId nullable, created/updated epoch-ms, integer revision) and reusable local context documents (same scope/revision plus kind). Explicit insert creates composer draft, never hidden send. Suggest `knowledge.list`, `knowledge.save`, `knowledge.delete` with tagged kind `prompt|context`; optimistic revision on update/delete; `knowledge.updated` event for refresh. Stable existing `invalid_input`/not_found + agreed conflict error. Storage tables in current SQLite with project FK; no runtime side effects. + +Next: `knowledge.discover`/`knowledge.read` for explicitly known rule/skill paths, bounded UTF-8/no credentials/no symlink traversal; edit via your file service once available (no duplicate generic file service). Please advise your file contract/reusable safe reader. + +Organization metadata: propose separate project/session organization records for pinned/archived, session workflow (backlog/in_progress/in_review/blocked/done) separate from process status, initial objective + explicit local context references. Do you own these fields as planned? Please reserve/implement contracts or delegate an agreed narrow patch. Initial objective execution must use SessionManager/adapters; S3 can own drafting/context composition. + +Please confirm migration allocation, shared lib.rs registration, wire/mirrors/dispatch ownership. Can you delegate a small additive S3 patch to these files after review of this proposal, or supply a commit? No shared files have been changed by S3. + +## S1 canvas owner + +Will deliver isolated components with typed callbacks for saved prompts/context library, rules/skills inspector, and organization controls. Please identify preferred integration surface (canvas contextual panel/toolbar/palette) and coordinate real mounting/composer insertion. Your AppShell/canvas/global styles remain yours. S3 will publish commits and integration API in `docs/codex/xirp-context-report.md`; main canvas remains Maestri. + +## Responses + +Pending owner acknowledgment. diff --git a/docs/xirp-local-parity.md b/docs/xirp-local-parity.md new file mode 100644 index 0000000..4e56b21 --- /dev/null +++ b/docs/xirp-local-parity.md @@ -0,0 +1,36 @@ +# XIRP local parity + +Sources checked 2026-09-05 against XIRP changelog through v0.25.0 (2026-09-03). +Baseline: `0ac8dd7`, `origin/refactor/canvas-only-shell`. +This matrix covers the user's local XIRP request. It complements the main +session's `docs/maestri-xirp-parity.md`; Portal product features are not silently +added to this implementation's scope. + +| ID | Requirement and source | Owner / integration | Evidence / remaining work | +| --- | --- | --- | --- | +| X01 | Local project pin, rename, remove registration, non-Git folders, discover child repositories. [Projects](https://backstage.spotify.com/docs/xirp/projects) | S2 project base, S3 organization UI, S1 canvas | Baseline registers Git projects; pin/archive metadata, non-Git support and child discovery pending. Project archive is a user-requested extension. | +| X02 | Initial objective delivered to agent, attachments, checkout/worktree and agent-specific options. [Sessions](https://backstage.spotify.com/docs/xirp/sessions) | S2 execution/contracts, S3 draft/context selection, S1 composer | Goal delivery requires verified runtime path, not metadata only. Attachments/options pending runtime contract. | +| X03 | Resume/fork, agent switch preserving supported history, linked shell. [Sessions](https://backstage.spotify.com/docs/xirp/sessions), [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S2 adapters/runtime, S1 canvas | Current adapter capabilities do not expose verified conversation IDs, fork or usage. Await trusted native contracts and runtime integration. Local context reuse is a separate feature. | +| X04 | Working/idle/needs-input attention separate from process lifecycle. [Sessions](https://backstage.spotify.com/docs/xirp/sessions) | S2 event source, S3 indicators, S1 canvas | PTY silence is not proof of approval/input need. Explicit hooks or native signals required. | +| X05 | Local preferences, shortcuts, supported native settings, diagnostics. [Settings](https://backstage.spotify.com/docs/xirp/settings) | S2 settings/runtime, S3 rules/skills, S1 UI | Existing diagnostics/custom-agent creation are baseline only; do not expose unsupported options. Credentials excluded from editors. | +| X06 | Global/project rules and skills, including symlinked skill directories. [Projects](https://backstage.spotify.com/docs/xirp/projects), [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S3 discovery, S2 file boundary | Need verified location inventory and safe bounded reads with origins/scopes; docs do not enumerate complete formats or precedence. Symlink policy and editing depend on file service. | +| X07a | Saved global/project prompts, searchable picker and insertion. [Changelog v0.19.1](https://backstage.spotify.com/docs/xirp/changelog) | S3 knowledge, S2 shared registration, S1 canvas insertion | Isolated module implementation underway; persistence, socket, UI integration and cross-platform evidence pending. | +| X07b | Session archive and workflow: backlog/in_progress/in_review/blocked/done. [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S2 organization metadata, S3 controls, S1 filtering | Workflow must not mutate lifecycle. Session pin is a user-requested extension. | +| X07c | Tokens/spend per session/day. [Changelog v0.18.0](https://backstage.spotify.com/docs/xirp/changelog) | S2 verified native data, S3 presentation | Source documents feature existence, not a collector/format/pricing algorithm. Currently unavailable, not zero. No proxy or PTY-based estimates. | +| X07d | PR review, Doctor, cleanup. [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S2 runtime/Git, S1 UI | Existing Git status/diagnostics/removal-token safety are baseline; review and expanded cleanup require their real contracts. | +| X08 | Agent-specific options, including Cursor. [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S2 adapter registry, S1 UI | No flags invented from agent display name; support requires installed-version tests and authoritative CLI sources. | +| X09-local | Explicit reusable local context between sessions (user request). | S3 knowledge, S1 composer, S2 delivery | Local text records and explicit draft insertion planned. No transcript capture, autonomous summarization, or Portal connection implied. | +| X09-Portal | Shared workspaces, members, catalog links, resources, decisions/wiki. [Workspaces](https://backstage.spotify.com/docs/xirp/workspaces) | Separate authenticated connector | Portal-dependent; no account/API verification supplied. Not implemented or simulated. | +| X10 | Workspace context via MCP, manual transcript sharing, configured external integrations. [Workspaces](https://backstage.spotify.com/docs/xirp/workspaces) | Separate authenticated connector + S2 transport | Requires real account, scopes/API and explicit outbound preview. No silent publishing, account linking or telemetry. | + +The [XIRP overview](https://backstage.spotify.com/docs/xirp) confirms local +projects, terminals, files, rules, skills and worktrees can operate without +Portal. XIRP's macOS platform statement does not replace CLI Master's Linux +requirement. A macOS test does not count as Linux verification. + +## Completion evidence required + +Each implemented row must link its commit, relevant automated tests, persistent +or filesystem effect, real socket behavior and canvas consumption. Portable +source code alone does not demonstrate execution on both supported platforms. +Unknown capabilities remain open dependencies rather than claimed parity. From 7693468ed7dba150a0d8fc5ea56e37f18bf85e45 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 21:47:04 -0300 Subject: [PATCH 05/23] refactor(session): separate worktree preparation from process launch --- crates/session/src/create.rs | 227 ++++++++++++---- crates/session/src/lib.rs | 5 +- crates/session/src/remove.rs | 20 ++ crates/session/src/saga.rs | 115 ++++++-- crates/session/tests/prepare_saga.rs | 328 +++++++++++++++++++++++ crates/storage/src/sessions.rs | 65 +++-- crates/storage/src/worktrees.rs | 45 +++- crates/storage/tests/agents_worktrees.rs | 78 ++++++ 8 files changed, 773 insertions(+), 110 deletions(-) create mode 100644 crates/session/tests/prepare_saga.rs diff --git a/crates/session/src/create.rs b/crates/session/src/create.rs index c64f9d9..2504449 100644 --- a/crates/session/src/create.rs +++ b/crates/session/src/create.rs @@ -1,7 +1,7 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; -use cli_master_core::wire::SessionIsolation; +use cli_master_core::wire::{RelativeDirectory, SessionIsolation}; use cli_master_core::{ AgentId, ProjectId, Session, SessionId, SessionStatus, Worktree, WorktreeId, }; @@ -18,6 +18,25 @@ use crate::token::now_ms; const DEFAULT_PTY_COLS: u16 = 80; const DEFAULT_PTY_ROWS: u16 = 24; +#[derive(Clone, Copy)] +pub(crate) enum Launch<'a> { + Prepare(Option<&'a RelativeDirectory>), + Start, +} + +impl<'a> Launch<'a> { + fn relative_directory(self) -> Option<&'a RelativeDirectory> { + match self { + Self::Prepare(relative) => relative, + Self::Start => None, + } + } + + fn starts_process(self) -> bool { + matches!(self, Self::Start) + } +} + /// Named saga effect after which tests may inject a failure. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CreateStep { @@ -82,7 +101,7 @@ pub struct CreateSession { /// Durable result of a completed create saga. #[derive(Clone, Debug, Eq, PartialEq)] pub struct CreatedSession { - /// Persisted session, including daemon-observed pid after spawn. + /// Persisted session; preparing metadata alone never assigns a pid. pub session: Session, /// Managed worktree when isolation requested one. pub worktree: Option, @@ -94,10 +113,11 @@ pub(crate) fn create( saga: &SessionWorktreeSaga, request: &CreateSession, faults: &CreateFaults, + launch: Launch<'_>, ) -> Result { match request.isolation { - SessionIsolation::Current => create_current(saga, request, faults), - SessionIsolation::NewWorktree => create_worktree(saga, request, faults), + SessionIsolation::Current => create_current(saga, request, faults, launch), + SessionIsolation::NewWorktree => create_worktree(saga, request, faults, launch), } } @@ -105,21 +125,31 @@ fn create_current( saga: &SessionWorktreeSaga, request: &CreateSession, faults: &CreateFaults, + launch: Launch<'_>, ) -> Result { let now = now_ms(); let project = require_project(saga, request.project_id)?; let agent = require_agent(saga, request.agent_id)?; + let cwd = resolve_session_directory(&project.path, launch.relative_directory())?; + let command = agent + .command_for_cwd(cwd.clone()) + .map_err(SagaError::from)?; maybe_fail(faults, CreateStep::Plan)?; maybe_fail(faults, CreateStep::PersistCreating)?; maybe_fail(faults, CreateStep::GitAdd)?; let session_id = SessionId::new(); - insert_starting_session(saga, session_id, request, project.path.clone(), now)?; + let session = session_row(saga, session_id, request, cwd, now, launch); + saga.storage().insert_session(&session)?; maybe_fail(faults, CreateStep::PersistActive).inspect_err(|_| { discard_session(saga, session_id); })?; - let command = agent - .command_for_cwd(project.path.clone()) - .map_err(SagaError::from)?; + if !launch.starts_process() { + return Ok(CreatedSession { + session: session_dto(session, None, None), + worktree: None, + plan: None, + }); + } let spawned = saga .spawner .spawn(SpawnRequest { @@ -159,10 +189,11 @@ fn create_worktree( saga: &SessionWorktreeSaga, request: &CreateSession, faults: &CreateFaults, + launch: Launch<'_>, ) -> Result { let now = now_ms(); let project = require_project(saga, request.project_id)?; - let agent = require_agent(saga, request.agent_id)?; + require_agent(saga, request.agent_id)?; let worktree_id = WorktreeId::new(); let session_id = SessionId::new(); let short_id = request @@ -170,11 +201,22 @@ fn create_worktree( .clone() .unwrap_or_else(|| short_id_for(worktree_id)); let plan = saga.git.plan_worktree( - &project.path, + project.repository_root.as_ref().unwrap_or(&project.path), &request.managed_root, &request.name, &short_id, )?; + let selected_directory = canonical_directory(&project.path)?; + let project_relative = selected_directory + .strip_prefix(plan.repository_root()) + .map_err(|_| { + SagaError::new( + SagaErrorKind::InvalidInput, + "selected project directory is outside its Git repository root", + "Register a directory inside the repository and try again", + ) + .with_path(&project.path) + })?; if let Some(hook) = &faults.after_plan { hook(&plan); } @@ -208,10 +250,10 @@ fn create_worktree( saga, request, faults, - &agent, &plan, - (worktree_id, session_id), + (worktree_id, session_id, project_relative), now, + launch, ) } @@ -219,33 +261,50 @@ fn persist_spawn_and_run( saga: &SessionWorktreeSaga, request: &CreateSession, faults: &CreateFaults, - agent: &cli_master_storage::StoredAgent, plan: &WorktreePlan, - ids: (WorktreeId, SessionId), + ids: (WorktreeId, SessionId, &Path), now: i64, + launch: Launch<'_>, ) -> Result { - let (worktree_id, session_id) = ids; - if let Err(error) = insert_starting_session( - saga, - session_id, - request, - plan.destination().to_path_buf(), - now, - ) { - return Err(compensate(saga, plan, worktree_id, None, error)); - } - if let Err(error) = activate_worktree(saga, worktree_id, session_id, now) { - discard_session(saga, session_id); - return Err(compensate(saga, plan, worktree_id, None, error)); + let (worktree_id, session_id, project_relative) = ids; + let selected_root = resolve_directory( + plan.destination(), + &plan.destination().join(project_relative), + ) + .map_err(|error| compensate(saga, plan, worktree_id, None, error))?; + let cwd = resolve_session_directory(&selected_root, launch.relative_directory()) + .map_err(|error| compensate(saga, plan, worktree_id, None, error))?; + let agent = require_agent(saga, request.agent_id) + .map_err(|error| compensate(saga, plan, worktree_id, None, error))?; + let command = agent + .command_for_cwd(cwd.clone()) + .map_err(|error| compensate(saga, plan, worktree_id, None, SagaError::from(error)))?; + let session = session_row(saga, session_id, request, cwd, now, launch); + let persisted = + saga.storage() + .insert_prepared_session_with_worktree(&session, worktree_id, now); + if let Err(error) = persisted { + return Err(compensate( + saga, + plan, + worktree_id, + None, + SagaError::from(error), + )); } if let Err(error) = maybe_fail(faults, CreateStep::PersistActive) { - discard_session(saga, session_id); return Err(compensate(saga, plan, worktree_id, Some(session_id), error)); } - let command = agent - .command_for_cwd(plan.destination().to_path_buf()) - .map_err(SagaError::from)?; + if !launch.starts_process() { + let worktree = require_worktree(saga, worktree_id) + .map_err(|error| compensate(saga, plan, worktree_id, Some(session_id), error))?; + return Ok(CreatedSession { + session: session_dto(session, Some(&worktree), None), + worktree: Some(worktree_dto(worktree)), + plan: Some(plan.clone()), + }); + } let spawned = match saga.spawner.spawn(SpawnRequest { session_id, project_id: request.project_id, @@ -311,46 +370,35 @@ fn persist_creating( .map_err(SagaError::from) } -fn insert_starting_session( +fn session_row( saga: &SessionWorktreeSaga, session_id: SessionId, request: &CreateSession, cwd: PathBuf, now: i64, -) -> Result<(), SagaError> { - let row = StoredSession { + launch: Launch<'_>, +) -> StoredSession { + StoredSession { id: session_id, project_id: request.project_id, agent_id: request.agent_id, name: request.name.clone(), cwd, - status: SessionStatus::Starting, + status: if launch.starts_process() { + SessionStatus::Starting + } else { + SessionStatus::Unknown + }, runtime_pid: None, - daemon_instance_id: Some(saga.daemon_instance_id.clone()), + daemon_instance_id: launch + .starts_process() + .then(|| saga.daemon_instance_id.clone()), exit_code: None, error_code: None, created_at_ms: now, updated_at_ms: now, - last_activity_at_ms: Some(now), - }; - saga.storage().insert_session(&row).map_err(SagaError::from) -} - -fn activate_worktree( - saga: &SessionWorktreeSaga, - worktree_id: WorktreeId, - session_id: SessionId, - now: i64, -) -> Result<(), SagaError> { - saga.storage() - .update_worktree_state( - worktree_id, - WorktreeState::Active, - false, - Some(session_id), - now, - ) - .map_err(SagaError::from) + last_activity_at_ms: launch.starts_process().then_some(now), + } } fn persist_running( @@ -424,7 +472,15 @@ fn compensate( original } Err(error) => { - mark_orphaned(saga, worktree_id, session_id); + // Successful session deletion clears the foreign-key association; + // do not try to reattach an identifier that no longer exists. + let remaining_session = saga + .storage() + .get_worktree(worktree_id) + .ok() + .flatten() + .and_then(|worktree| worktree.session_id); + mark_orphaned(saga, worktree_id, remaining_session); if error.kind() == cli_master_git::GitErrorKind::PartialWorktree { return SagaError::from(error).with_worktree_id(worktree_id); } @@ -510,3 +566,58 @@ pub(crate) fn require_worktree( .with_worktree_id(worktree_id) }) } + +/// Resolves an existing session directory inside its daemon-selected root. +/// +/// Both paths are canonicalized so a child symlink cannot escape the project +/// or managed worktree. The typed relative directory has already passed wire +/// validation for parent traversal and absolute paths. +/// +/// # Errors +/// +/// Returns an error when either directory is unavailable or the resolved child +/// is outside the canonical root. +pub fn resolve_session_directory( + root: &Path, + relative_directory: Option<&RelativeDirectory>, +) -> Result { + let directory = relative_directory.map_or_else( + || root.to_path_buf(), + |relative| root.join(relative.as_str()), + ); + resolve_directory(root, &directory) +} + +fn resolve_directory(root: &Path, directory: &Path) -> Result { + let canonical_root = canonical_directory(root)?; + let canonical_directory = canonical_directory(directory)?; + if !canonical_directory.starts_with(&canonical_root) { + return Err(SagaError::new( + SagaErrorKind::InvalidInput, + "session directory resolves outside its project or worktree root", + "Choose an existing directory inside the session root", + ) + .with_path(directory)); + } + Ok(canonical_directory) +} + +fn canonical_directory(directory: &Path) -> Result { + let resolved = directory.canonicalize().map_err(|error| { + SagaError::new( + SagaErrorKind::InvalidInput, + format!("session directory could not be opened: {error}"), + "Choose an existing directory inside the session root and check its permissions", + ) + .with_path(directory) + })?; + if !resolved.is_dir() { + return Err(SagaError::new( + SagaErrorKind::InvalidInput, + "session working directory is not a directory", + "Choose an existing directory inside the session root", + ) + .with_path(directory)); + } + Ok(resolved) +} diff --git a/crates/session/src/lib.rs b/crates/session/src/lib.rs index cc56796..3d57e91 100644 --- a/crates/session/src/lib.rs +++ b/crates/session/src/lib.rs @@ -30,7 +30,10 @@ pub use config::{ DEFAULT_REPLAY_MAX_BYTES, DEFAULT_REPLAY_MAX_CHUNKS, MAX_EVENT_CAPACITY, MAX_TRACKED_PROCESSES, SessionManagerConfig, }; -pub use create::{CreateFaults, CreateSession, CreateStep, CreatedSession, LockHook, PlanHook}; +pub use create::{ + CreateFaults, CreateSession, CreateStep, CreatedSession, LockHook, PlanHook, + resolve_session_directory, +}; pub use error::{SagaError, SagaErrorKind, SessionError}; pub use event::{ IoOperation, OutputChunk, ReconnectSnapshot, SessionEvent, SessionHandle, SessionSnapshot, diff --git a/crates/session/src/remove.rs b/crates/session/src/remove.rs index 8d9a54e..ae309f0 100644 --- a/crates/session/src/remove.rs +++ b/crates/session/src/remove.rs @@ -15,6 +15,26 @@ use crate::saga::{SessionWorktreeSaga, require_project}; use crate::spawn::SessionSpawner; use crate::token::now_ms; +pub(crate) fn cancel_pending_removal( + saga: &SessionWorktreeSaga, + worktree_id: WorktreeId, +) -> Result<(), SagaError> { + let _mutation = lock_mutation(&saga.mutations, worktree_id)?; + let stored = require_worktree(saga, worktree_id)?; + let _destination = lock_destination(&saga.destinations, stored.path.clone())?; + saga.tokens.discard_for(worktree_id); + if stored.state == WorktreeState::RemovePending { + saga.storage().update_worktree_state( + worktree_id, + WorktreeState::Active, + stored.is_dirty, + stored.session_id, + now_ms(), + )?; + } + Ok(()) +} + pub(crate) fn prepare_remove( saga: &SessionWorktreeSaga, worktree_id: WorktreeId, diff --git a/crates/session/src/saga.rs b/crates/session/src/saga.rs index 3be54aa..3b4cab2 100644 --- a/crates/session/src/saga.rs +++ b/crates/session/src/saga.rs @@ -1,6 +1,8 @@ -use std::sync::{Mutex, MutexGuard, PoisonError}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; -use cli_master_core::wire::{ConfirmationToken, SessionIsolation, WorktreePrepareRemoveResponse}; +use cli_master_core::wire::{ + ConfirmationToken, RelativeDirectory, SessionIsolation, WorktreePrepareRemoveResponse, +}; use cli_master_core::{AgentId, Project, ProjectId, SessionId, WorktreeId}; use cli_master_git::Git; use cli_master_storage::{Storage, StoredAgent}; @@ -15,7 +17,7 @@ use crate::{ /// Orchestrates recoverable worktree-backed session creation and removal. pub struct SessionWorktreeSaga { pub(crate) git: Git, - storage: Mutex, + storage: Arc>, pub(crate) spawner: S, pub(crate) daemon_instance_id: String, pub(crate) destinations: DestinationLocks, @@ -34,6 +36,25 @@ impl SessionWorktreeSaga { storage: Storage, spawner: S, daemon_instance_id: impl Into, + ) -> Result { + Self::new_with_shared_storage( + git, + Arc::new(Mutex::new(storage)), + spawner, + daemon_instance_id, + ) + } + + /// Builds a saga sharing the daemon's single metadata owner. + /// + /// # Errors + /// + /// Returns an error when `daemon_instance_id` is blank. + pub fn new_with_shared_storage( + git: Git, + storage: Arc>, + spawner: S, + daemon_instance_id: impl Into, ) -> Result { let daemon_instance_id = daemon_instance_id.into(); if daemon_instance_id.trim().is_empty() { @@ -45,7 +66,7 @@ impl SessionWorktreeSaga { } Ok(Self { git, - storage: Mutex::new(storage), + storage, spawner, daemon_instance_id, destinations: DestinationLocks::default(), @@ -73,23 +94,44 @@ impl SessionWorktreeSaga { request: &CreateSession, faults: &CreateFaults, ) -> Result { - if request.name.trim().is_empty() { - return Err(SagaError::new( - SagaErrorKind::InvalidInput, - "session name must not be blank", - "Provide a user-facing session name", - )); - } - if request.isolation == SessionIsolation::NewWorktree && !request.managed_root.is_absolute() - { - return Err(SagaError::new( - SagaErrorKind::InvalidInput, - "managed worktree root must be an absolute path", - "Pass the daemon-owned managed worktree root", - ) - .with_path(&request.managed_root)); - } - crate::create::create(self, request, faults) + validate_create(request)?; + crate::create::create(self, request, faults, crate::create::Launch::Start) + } + + /// Prepares durable session metadata and optional Git isolation without launching a process. + /// + /// The returned session has `unknown` status and no runtime owner until the + /// daemon starts it through `SessionManager`. + /// + /// # Errors + /// + /// Returns an error when directory validation, persistence, or Git preparation fails. + pub fn prepare_session( + &self, + request: &CreateSession, + relative_directory: Option<&RelativeDirectory>, + ) -> Result { + self.prepare_session_injected(request, relative_directory, &CreateFaults::default()) + } + + /// Prepares session metadata with deterministic fault hooks for compensation tests. + /// + /// # Errors + /// + /// Returns the same failures as [`Self::prepare_session`] plus injected faults. + pub fn prepare_session_injected( + &self, + request: &CreateSession, + relative_directory: Option<&RelativeDirectory>, + faults: &CreateFaults, + ) -> Result { + validate_create(request)?; + crate::create::create( + self, + request, + faults, + crate::create::Launch::Prepare(relative_directory), + ) } /// Inspects whether a managed worktree can be removed and issues a token when safe. @@ -104,6 +146,18 @@ impl SessionWorktreeSaga { crate::remove::prepare_remove(self, worktree_id) } + /// Invalidates removal confirmation before a daemon-owned session starts again. + /// + /// The daemon serializes this operation with session launch and removal. + /// + /// # Errors + /// + /// Returns an error when the worktree is missing, a mutation is already in + /// progress, or the pending state cannot be restored. + pub fn cancel_pending_removal(&self, worktree_id: WorktreeId) -> Result<(), SagaError> { + crate::remove::cancel_pending_removal(self, worktree_id) + } + /// Removes a clean unused worktree using a matching confirmation token. /// /// # Errors @@ -195,3 +249,22 @@ pub(crate) fn require_agent( } Ok(agent) } + +fn validate_create(request: &CreateSession) -> Result<(), SagaError> { + if request.name.trim().is_empty() { + return Err(SagaError::new( + SagaErrorKind::InvalidInput, + "session name must not be blank", + "Provide a user-facing session name", + )); + } + if request.isolation == SessionIsolation::NewWorktree && !request.managed_root.is_absolute() { + return Err(SagaError::new( + SagaErrorKind::InvalidInput, + "managed worktree root must be an absolute path", + "Pass the daemon-owned managed worktree root", + ) + .with_path(&request.managed_root)); + } + Ok(()) +} diff --git a/crates/session/tests/prepare_saga.rs b/crates/session/tests/prepare_saga.rs new file mode 100644 index 0000000..0bf8522 --- /dev/null +++ b/crates/session/tests/prepare_saga.rs @@ -0,0 +1,328 @@ +mod support; + +use std::fs; +use std::os::unix::fs::symlink; +use std::sync::{Arc, Mutex}; + +use cli_master_core::{ + SessionStatus, + wire::{RelativeDirectory, SessionIsolation}, +}; +use cli_master_git::Git; +use cli_master_session::{ + CreateFaults, CreateStep, FakeSpawner, SagaErrorKind, SessionWorktreeSaga, +}; +use cli_master_storage::{Storage, WorktreeState}; +use support::{Fixture, git}; + +#[test] +fn prepared_worktree_has_no_runtime_and_survives_recovery() { + let fixture = Fixture::new(); + let shared = Arc::new(Mutex::new( + Storage::open_migrated(&fixture.database).unwrap(), + )); + let saga = SessionWorktreeSaga::new_with_shared_storage( + Git::discover().unwrap(), + Arc::clone(&shared), + FakeSpawner::failing(), + support::DAEMON_ID, + ) + .unwrap(); + let prepared = saga + .prepare_session(&fixture.request("Prepare", None), None) + .unwrap(); + let worktree = prepared.worktree.unwrap(); + assert_eq!(prepared.session.status, SessionStatus::Unknown); + assert!(prepared.session.pid.is_none()); + assert!(prepared.session.pty_id.is_none()); + assert!(worktree.path.is_dir()); + assert_eq!(worktree.session_id, Some(prepared.session.id)); + assert_eq!(worktree.state, cli_master_core::WorktreeState::Active); + let storage = shared.lock().unwrap(); + let stored = storage.get_session(prepared.session.id).unwrap().unwrap(); + assert!(stored.daemon_instance_id.is_none()); + assert!(stored.last_activity_at_ms.is_none()); + assert_eq!( + storage + .recover_stale_sessions_for_daemon("next-daemon", stored.updated_at_ms + 1) + .unwrap(), + 0 + ); + drop(storage); + assert_eq!(saga.recover().unwrap(), Default::default()); + saga.delete_session(prepared.session.id).unwrap(); + assert!( + worktree.path.is_dir(), + "deleting metadata must preserve the worktree" + ); + assert!( + shared + .lock() + .unwrap() + .get_worktree(worktree.id) + .unwrap() + .unwrap() + .session_id + .is_none() + ); +} + +#[test] +fn relative_directory_is_resolved_in_the_new_checkout() { + let fixture = Fixture::new(); + fs::create_dir_all(fixture.repository.join("apps/api")).unwrap(); + fs::write(fixture.repository.join("apps/api/README.md"), "api\n").unwrap(); + git(&fixture.repository, ["add", "."]); + git(&fixture.repository, ["commit", "-m", "api directory"]); + let saga = fixture.saga(FakeSpawner::failing()); + let relative = RelativeDirectory::try_new("apps/api").unwrap(); + let prepared = saga + .prepare_session(&fixture.request("API", None), Some(&relative)) + .unwrap(); + let root = prepared.worktree.unwrap().path; + assert_eq!( + prepared.session.cwd, + root.join("apps/api").canonicalize().unwrap() + ); + assert!( + !prepared + .session + .cwd + .starts_with(fixture.repository.canonicalize().unwrap()) + ); +} + +#[test] +fn project_subdirectory_is_preserved_for_current_and_worktree_sessions() { + for isolation in [SessionIsolation::Current, SessionIsolation::NewWorktree] { + let fixture = Fixture::new(); + fs::create_dir_all(fixture.repository.join("apps/api")).unwrap(); + fs::write(fixture.repository.join("apps/api/README.md"), "api\n").unwrap(); + git(&fixture.repository, ["add", "."]); + git(&fixture.repository, ["commit", "-m", "api directory"]); + let storage = Storage::open(&fixture.database).unwrap(); + let mut project = storage.get_project(fixture.project_id).unwrap().unwrap(); + storage.remove_project_metadata(project.id).unwrap(); + project.path = fixture.repository.join("apps"); + project.repository_root = Some(fixture.repository.canonicalize().unwrap()); + storage.insert_project(&project).unwrap(); + let saga = fixture.saga(FakeSpawner::failing()); + let mut request = fixture.request("API", None); + request.isolation = isolation; + let prepared = saga + .prepare_session(&request, Some(&RelativeDirectory::try_new("api").unwrap())) + .unwrap(); + let root = prepared + .worktree + .map_or(fixture.repository, |worktree| worktree.path); + assert_eq!( + prepared.session.cwd, + root.join("apps/api").canonicalize().unwrap() + ); + } +} + +#[test] +fn cancelled_removal_invalidates_the_old_token_and_restores_active_metadata() { + let fixture = Fixture::new(); + let saga = fixture.saga(FakeSpawner::failing()); + let prepared = saga + .prepare_session(&fixture.request("Cancel removal", None), None) + .unwrap(); + let worktree = prepared.worktree.unwrap(); + let cli_master_core::wire::WorktreePrepareRemoveResponse::Ready { + confirmation_token, .. + } = saga.prepare_remove(worktree.id).unwrap() + else { + panic!("prepared clean worktree should be removable"); + }; + saga.cancel_pending_removal(worktree.id).unwrap(); + let stored = Storage::open(&fixture.database) + .unwrap() + .get_worktree(worktree.id) + .unwrap() + .unwrap(); + assert_eq!(stored.state, WorktreeState::Active); + assert_eq!(stored.session_id, Some(prepared.session.id)); + assert_eq!( + saga.remove_worktree(worktree.id, &confirmation_token) + .unwrap_err() + .kind(), + SagaErrorKind::InvalidToken + ); + assert!(worktree.path.is_dir()); + let cli_master_core::wire::WorktreePrepareRemoveResponse::Ready { + confirmation_token: new_token, + .. + } = saga.prepare_remove(worktree.id).unwrap() + else { + panic!("a fresh confirmation should still be available"); + }; + saga.remove_worktree(worktree.id, &new_token).unwrap(); + assert!(!worktree.path.exists()); +} + +#[test] +fn current_directory_can_be_prepared_without_a_git_repository() { + let fixture = Fixture::new(); + fs::remove_dir_all(fixture.repository.join(".git")).unwrap(); + fs::create_dir(fixture.repository.join("child")).unwrap(); + let saga = fixture.saga(FakeSpawner::failing()); + let mut request = fixture.request("Current", None); + request.isolation = SessionIsolation::Current; + let prepared = saga + .prepare_session( + &request, + Some(&RelativeDirectory::try_new("child").unwrap()), + ) + .unwrap(); + assert_eq!( + prepared.session.cwd, + fixture.repository.join("child").canonicalize().unwrap() + ); + assert!(prepared.worktree.is_none()); + assert!(!fixture.managed.exists()); +} + +#[test] +fn current_directory_rejects_symlink_escape_without_metadata() { + let fixture = Fixture::new(); + symlink(fixture.temp.path(), fixture.repository.join("outside")).unwrap(); + let saga = fixture.saga(FakeSpawner::failing()); + let mut request = fixture.request("Escape", None); + request.isolation = SessionIsolation::Current; + let error = saga + .prepare_session( + &request, + Some(&RelativeDirectory::try_new("outside").unwrap()), + ) + .unwrap_err(); + assert_eq!(error.kind(), SagaErrorKind::InvalidInput); + assert!( + Storage::open(&fixture.database) + .unwrap() + .list_sessions() + .unwrap() + .is_empty() + ); +} + +#[test] +fn worktree_symlink_escape_is_compensated_without_deleting_the_target() { + let fixture = Fixture::new(); + let outside = fixture.temp.path().join("outside"); + fs::create_dir(&outside).unwrap(); + fs::write(outside.join("keep.txt"), "keep\n").unwrap(); + symlink(&outside, fixture.repository.join("outside")).unwrap(); + git(&fixture.repository, ["add", "."]); + git(&fixture.repository, ["commit", "-m", "linked directory"]); + let saga = fixture.saga(FakeSpawner::failing()); + let error = saga + .prepare_session( + &fixture.request("Escape", None), + Some(&RelativeDirectory::try_new("outside").unwrap()), + ) + .unwrap_err(); + assert_eq!(error.kind(), SagaErrorKind::InvalidInput); + assert_eq!( + fs::read_to_string(outside.join("keep.txt")).unwrap(), + "keep\n" + ); + let storage = Storage::open(&fixture.database).unwrap(); + assert!(storage.list_sessions().unwrap().is_empty()); + assert!(storage.list_worktrees().unwrap().is_empty()); + assert_eq!( + Git::discover() + .unwrap() + .list_worktrees(&fixture.repository) + .unwrap() + .len(), + 1 + ); +} + +#[test] +fn missing_directory_in_checkout_rolls_back_git_and_metadata() { + let fixture = Fixture::new(); + fs::create_dir(fixture.repository.join("untracked-directory")).unwrap(); + let saga = fixture.saga(FakeSpawner::failing()); + let error = saga + .prepare_session( + &fixture.request("Missing", None), + Some(&RelativeDirectory::try_new("untracked-directory").unwrap()), + ) + .unwrap_err(); + assert_eq!(error.kind(), SagaErrorKind::InvalidInput); + let storage = Storage::open(&fixture.database).unwrap(); + assert!(storage.list_sessions().unwrap().is_empty()); + assert!(storage.list_worktrees().unwrap().is_empty()); + assert_eq!( + Git::discover() + .unwrap() + .list_worktrees(&fixture.repository) + .unwrap() + .len(), + 1 + ); +} + +#[test] +fn metadata_only_faults_compensate_each_durable_effect() { + for step in [ + CreateStep::Plan, + CreateStep::PersistCreating, + CreateStep::GitAdd, + CreateStep::PersistActive, + ] { + let fixture = Fixture::new(); + let saga = fixture.saga(FakeSpawner::failing()); + let error = saga + .prepare_session_injected( + &fixture.request("Fault", None), + None, + &CreateFaults { + fail_after: Some(step), + ..CreateFaults::default() + }, + ) + .unwrap_err(); + assert_eq!(error.kind(), SagaErrorKind::InjectedFailure); + let storage = Storage::open(&fixture.database).unwrap(); + assert!(storage.list_sessions().unwrap().is_empty(), "{step:?}"); + assert!(storage.list_worktrees().unwrap().is_empty(), "{step:?}"); + assert_eq!( + Git::discover() + .unwrap() + .list_worktrees(&fixture.repository) + .unwrap() + .len(), + 1 + ); + } +} + +#[test] +fn compensation_preserves_dirty_user_data_after_metadata_preparation() { + let fixture = Fixture::new(); + let saga = fixture.saga(FakeSpawner::failing()); + let error = saga + .prepare_session_injected( + &fixture.request("Dirty", None), + None, + &CreateFaults { + fail_after: Some(CreateStep::PersistActive), + after_git_add: Some(Arc::new(|plan| { + fs::write(plan.destination().join("keep.txt"), "keep\n").unwrap(); + })), + ..CreateFaults::default() + }, + ) + .unwrap_err(); + assert_eq!(error.kind(), SagaErrorKind::PartialWorktree); + let storage = Storage::open(&fixture.database).unwrap(); + assert!(storage.list_sessions().unwrap().is_empty()); + let worktrees = storage.list_worktrees().unwrap(); + assert_eq!(worktrees.len(), 1); + assert_eq!(worktrees[0].state, WorktreeState::Orphaned); + assert!(worktrees[0].path.join("keep.txt").is_file()); +} diff --git a/crates/storage/src/sessions.rs b/crates/storage/src/sessions.rs index ad3633d..0a3c1f2 100644 --- a/crates/storage/src/sessions.rs +++ b/crates/storage/src/sessions.rs @@ -25,36 +25,8 @@ impl Storage { /// Returns an error for invalid metadata, missing project/agent references, /// duplicate IDs, or database failures. pub fn insert_session(&self, session: &StoredSession) -> Result<(), StorageError> { - session.validate()?; - let cwd = path_to_sql_value(&session.cwd, "session cwd")?; self.with_connection("insert session", |connection| { - connection - .execute( - "INSERT INTO sessions ( - id, project_id, agent_id, name, cwd, status, runtime_pid, - daemon_instance_id, exit_code, error_code, created_at, - updated_at, last_activity_at - ) VALUES ( - ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13 - )", - params![ - session.id.to_string(), - session.project_id.to_string(), - session.agent_id.to_string(), - session.name, - cwd, - session_status_to_database(session.status), - session.runtime_pid.map(i64::from), - session.daemon_instance_id, - session.exit_code, - session.error_code, - session.created_at_ms, - session.updated_at_ms, - session.last_activity_at_ms, - ], - ) - .map_err(|error| map_write_error(error, "session"))?; - Ok(()) + insert_session_on_connection(connection, session) }) } @@ -269,3 +241,38 @@ fn require_changed(changed: usize, id: SessionId) -> Result<(), StorageError> { Ok(()) } } + +pub(crate) fn insert_session_on_connection( + connection: &Connection, + session: &StoredSession, +) -> Result<(), StorageError> { + session.validate()?; + let cwd = path_to_sql_value(&session.cwd, "session cwd")?; + connection + .execute( + "INSERT INTO sessions ( + id, project_id, agent_id, name, cwd, status, runtime_pid, + daemon_instance_id, exit_code, error_code, created_at, + updated_at, last_activity_at + ) VALUES ( + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13 + )", + params![ + session.id.to_string(), + session.project_id.to_string(), + session.agent_id.to_string(), + session.name, + cwd, + session_status_to_database(session.status), + session.runtime_pid.map(i64::from), + session.daemon_instance_id, + session.exit_code, + session.error_code, + session.created_at_ms, + session.updated_at_ms, + session.last_activity_at_ms, + ], + ) + .map_err(|error| map_write_error(error, "session"))?; + Ok(()) +} diff --git a/crates/storage/src/worktrees.rs b/crates/storage/src/worktrees.rs index 913299f..bcd1fa7 100644 --- a/crates/storage/src/worktrees.rs +++ b/crates/storage/src/worktrees.rs @@ -7,7 +7,7 @@ use rusqlite::{Row, params}; use crate::Storage; use crate::error::{StorageError, corrupt_data, map_write_error, persisted_validation}; -use crate::models::{StoredWorktree, WorktreeState, validate_timestamp}; +use crate::models::{StoredSession, StoredWorktree, WorktreeState, validate_timestamp}; use crate::paths::{path_from_sql_value, path_to_sql_value}; use crate::values::timestamp_from_sql_value; @@ -15,6 +15,49 @@ const WORKTREE_COLUMNS: &str = "id, project_id, session_id, path, branch, state, is_dirty, created_at, updated_at"; impl Storage { + /// Inserts a session and activates its previously prepared worktree atomically. + /// + /// Git has already created the working copy. A failed association must leave + /// no unassociated session row, while the `creating` worktree remains available + /// for compensation or crash recovery. + /// + /// # Errors + /// + /// Returns an error for invalid session metadata, missing or non-creating + /// worktrees, cross-project associations, or database failures. + pub fn insert_prepared_session_with_worktree( + &self, + session: &StoredSession, + worktree_id: WorktreeId, + updated_at_ms: i64, + ) -> Result<(), StorageError> { + validate_timestamp("worktree updated_at_ms", updated_at_ms)?; + self.transaction(|transaction| { + crate::sessions::insert_session_on_connection(transaction, session)?; + let changed = transaction + .execute( + "UPDATE worktrees SET state = 'active', session_id = ?1, updated_at = ?2 + WHERE id = ?3 AND project_id = ?4 AND state = 'creating' AND session_id IS NULL", + params![ + session.id.to_string(), + updated_at_ms, + worktree_id.to_string(), + session.project_id.to_string(), + ], + ) + .map_err(|error| map_write_error(error, "worktree"))?; + if changed == 0 { + return Err(StorageError::InvalidInput { + field: "worktree association", + reason: + "worktree must be unassociated, creating, and belong to the session project" + .to_owned(), + }); + } + Ok(()) + }) + } + /// Inserts worktree metadata without running Git or creating directories. /// /// # Errors diff --git a/crates/storage/tests/agents_worktrees.rs b/crates/storage/tests/agents_worktrees.rs index 575ed6c..9a9e501 100644 --- a/crates/storage/tests/agents_worktrees.rs +++ b/crates/storage/tests/agents_worktrees.rs @@ -336,6 +336,84 @@ struct WorktreeScenario { worktree: StoredWorktree, } +#[test] +fn prepared_session_and_worktree_commit_together_or_roll_back() { + let scenario = seeded_worktree_scenario(); + let candidate = session( + scenario.first_project_id, + scenario.agent_id, + SessionStatus::Unknown, + None, + None, + ); + let mut prepared = worktree( + scenario.first_project_id, + None, + std::path::Path::new("/tmp/prepared-worktree"), + "agent/prepared", + ); + scenario.storage.insert_worktree(&prepared).unwrap(); + scenario + .storage + .insert_prepared_session_with_worktree(&candidate, prepared.id, CREATED_AT_MS + 1) + .unwrap(); + assert_eq!( + scenario.storage.get_session(candidate.id).unwrap(), + Some(candidate.clone()) + ); + prepared.state = WorktreeState::Active; + prepared.session_id = Some(candidate.id); + prepared.updated_at_ms = CREATED_AT_MS + 1; + assert_eq!( + scenario.storage.get_worktree(prepared.id).unwrap(), + Some(prepared.clone()) + ); + + let replacement = session( + scenario.first_project_id, + scenario.agent_id, + SessionStatus::Unknown, + None, + None, + ); + assert!( + scenario + .storage + .insert_prepared_session_with_worktree(&replacement, prepared.id, CREATED_AT_MS + 2) + .is_err() + ); + assert!( + scenario + .storage + .get_session(replacement.id) + .unwrap() + .is_none() + ); + assert_eq!( + scenario.storage.get_worktree(prepared.id).unwrap(), + Some(prepared) + ); + + let invalid = session( + scenario.first_project_id, + scenario.agent_id, + SessionStatus::Unknown, + None, + None, + ); + assert!( + scenario + .storage + .insert_prepared_session_with_worktree( + &invalid, + cli_master_core::WorktreeId::new(), + CREATED_AT_MS + 2 + ) + .is_err() + ); + assert!(scenario.storage.get_session(invalid.id).unwrap().is_none()); +} + fn seeded_worktree_scenario() -> WorktreeScenario { let storage = Storage::open_in_memory().expect("database should open"); storage.migrate().expect("database should migrate"); From 3c25a85eeceaeb4d924784c681e301191b2bc6f8 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 21:49:11 -0300 Subject: [PATCH 06/23] feat(daemon): expose isolated session and safe worktree lifecycle over IPC --- apps/desktop/src/ipc/domain.ts | 12 + apps/desktop/src/ipc/methods.ts | 34 + crates/core/src/protocol.rs | 19 + crates/core/src/wire/method.rs | 3 + crates/core/src/wire/mod.rs | 4 +- crates/core/src/wire/request.rs | 9 + crates/core/src/wire/response.rs | 8 + crates/daemon/src/server.rs | 94 ++- crates/daemon/src/sessions.rs | 155 ++++- crates/daemon/src/sessions/worktrees.rs | 267 ++++++++ crates/daemon/tests/worktree_ipc.rs | 831 ++++++++++++++++++++++++ protocol/catalog.json | 1 + 12 files changed, 1389 insertions(+), 48 deletions(-) create mode 100644 apps/desktop/src/ipc/domain.ts create mode 100644 apps/desktop/src/ipc/methods.ts create mode 100644 crates/daemon/src/sessions/worktrees.rs create mode 100644 crates/daemon/tests/worktree_ipc.rs diff --git a/apps/desktop/src/ipc/domain.ts b/apps/desktop/src/ipc/domain.ts new file mode 100644 index 0000000..7cb4aa6 --- /dev/null +++ b/apps/desktop/src/ipc/domain.ts @@ -0,0 +1,12 @@ +/** Additive daemon contracts. Existing UI DTOs remain owned by types.ts. */ +export type * from "./types"; +import type { Worktree } from "./types"; + +/** List managed worktrees; omission includes every registered project. */ +export interface WorktreeListRequest { + readonly projectId?: string; +} + +export interface WorktreeListResponse { + readonly worktrees: readonly Worktree[]; +} diff --git a/apps/desktop/src/ipc/methods.ts b/apps/desktop/src/ipc/methods.ts new file mode 100644 index 0000000..7b00a06 --- /dev/null +++ b/apps/desktop/src/ipc/methods.ts @@ -0,0 +1,34 @@ +/** Mirror of cli_master_core::wire::method; protocol/catalog.json is checked in tests. */ +export const IPC_METHODS = [ + "system.hello", + "state.snapshot", + "project.add", + "project.list", + "project.rename", + "project.remove", + "agent.list", + "agent.detect", + "agent.set_enabled", + "agent.custom.create", + "agent.custom.update", + "agent.custom.remove", + "session.create", + "session.list", + "session.rename", + "session.start", + "session.restart", + "session.stop", + "session.delete", + "session.write", + "session.resize", + "session.subscribe", + "session.unsubscribe", + "git.status", + "git.diff", + "worktree.list", + "worktree.prepare_remove", + "worktree.remove", + "diagnostics.get" +] as const; + +export type IpcMethod = (typeof IPC_METHODS)[number]; diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs index 254ee50..49b3cd4 100644 --- a/crates/core/src/protocol.rs +++ b/crates/core/src/protocol.rs @@ -278,6 +278,25 @@ mod tests { assert_eq!(catalog["protocolVersion"], PROTOCOL_V1); assert_eq!(catalog["applicationVersion"], APPLICATION_VERSION); assert_eq!(APPLICATION_VERSION, env!("CARGO_PKG_VERSION")); + assert_eq!( + catalog["methods"], + serde_json::json!(crate::wire::method::ALL) + ); + assert_eq!( + catalog["events"], + serde_json::json!(crate::wire::event_name::ALL) + ); + } + + #[test] + fn typescript_method_mirror_matches_the_rust_catalog() { + let mirror = include_str!("../../../apps/desktop/src/ipc/methods.ts"); + let (_, array) = mirror.split_once("= ").expect("method array assignment"); + let (array, _) = array + .split_once(" as const;") + .expect("readonly method array"); + let methods: Vec = serde_json::from_str(array).expect("JSON method literals"); + assert_eq!(methods, crate::wire::method::ALL); } #[test] diff --git a/crates/core/src/wire/method.rs b/crates/core/src/wire/method.rs index d4633ea..96bffd5 100644 --- a/crates/core/src/wire/method.rs +++ b/crates/core/src/wire/method.rs @@ -55,6 +55,8 @@ pub const GIT_STATUS: &str = "git.status"; /// Read a bounded textual diff for a registered project, session, or worktree. pub const GIT_DIFF: &str = "git.diff"; +/// List managed worktrees, optionally constrained to a project. +pub const WORKTREE_LIST: &str = "worktree.list"; /// Inspect whether a managed worktree can be safely removed. pub const WORKTREE_PREPARE_REMOVE: &str = "worktree.prepare_remove"; /// Remove a managed worktree after token-bound state confirmation. @@ -90,6 +92,7 @@ pub const ALL: &[&str] = &[ SESSION_UNSUBSCRIBE, GIT_STATUS, GIT_DIFF, + WORKTREE_LIST, WORKTREE_PREPARE_REMOVE, WORKTREE_REMOVE, DIAGNOSTICS_GET, diff --git a/crates/core/src/wire/mod.rs b/crates/core/src/wire/mod.rs index cc73091..71737a2 100644 --- a/crates/core/src/wire/mod.rs +++ b/crates/core/src/wire/mod.rs @@ -26,7 +26,7 @@ pub use request::{ GitTarget, ProjectAddRequest, ProjectRemoveRequest, ProjectRenameRequest, SessionCreateRequest, SessionDeleteRequest, SessionIsolation, SessionListRequest, SessionRenameRequest, SessionResizeRequest, SessionRestartRequest, SessionStartRequest, SessionStopRequest, - SessionSubscribeRequest, SessionUnsubscribeRequest, SessionWriteRequest, + SessionSubscribeRequest, SessionUnsubscribeRequest, SessionWriteRequest, WorktreeListRequest, WorktreePrepareRemoveRequest, WorktreeRemoveRequest, validate_agent_command, }; pub use response::{ @@ -37,7 +37,7 @@ pub use response::{ ProjectListResponse, ProjectRemoveResponse, ProjectRenameResponse, SessionCreateResponse, SessionDeleteResponse, SessionListResponse, SessionRenameResponse, SessionResizeResponse, SessionRestartResponse, SessionStartResponse, SessionStopResponse, SessionSubscribeResponse, - SessionUnsubscribeResponse, SessionWriteResponse, StateSnapshotResponse, + SessionUnsubscribeResponse, SessionWriteResponse, StateSnapshotResponse, WorktreeListResponse, WorktreePrepareRemoveResponse, WorktreeRemovalBlocker, WorktreeRemoveResponse, }; pub use value::{ diff --git a/crates/core/src/wire/request.rs b/crates/core/src/wire/request.rs index 34632b1..2fbff93 100644 --- a/crates/core/src/wire/request.rs +++ b/crates/core/src/wire/request.rs @@ -464,6 +464,15 @@ pub struct GitDiffRequest { pub path: Option, } +/// Request to list managed worktrees, optionally for one project. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct WorktreeListRequest { + /// Project filter; omitted to return all managed worktrees. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_id: Option, +} + /// Request to prepare safe managed-worktree removal. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] diff --git a/crates/core/src/wire/response.rs b/crates/core/src/wire/response.rs index 20afb29..1a059a9 100644 --- a/crates/core/src/wire/response.rs +++ b/crates/core/src/wire/response.rs @@ -235,6 +235,14 @@ pub struct GitDiffResponse { pub binary: bool, } +/// Durable managed worktrees visible to the local client. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeListResponse { + /// Managed worktrees matching the requested project filter. + pub worktrees: Vec, +} + /// Safe reason a worktree cannot currently be removed. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] diff --git a/crates/daemon/src/server.rs b/crates/daemon/src/server.rs index 43ce49d..855d60a 100644 --- a/crates/daemon/src/server.rs +++ b/crates/daemon/src/server.rs @@ -17,7 +17,8 @@ use cli_master_core::{ SessionOutputEvent, SessionOutputGapEvent, SessionRenameRequest, SessionReplayCompleteEvent, SessionResizeRequest, SessionRestartRequest, SessionStartRequest, SessionStatusChangedEvent, SessionStopRequest, - SessionSubscribeRequest, SessionWriteRequest, event_name, method, + SessionSubscribeRequest, SessionWriteRequest, WorktreeListRequest, + WorktreePrepareRemoveRequest, WorktreeRemoveRequest, event_name, method, }, }; use cli_master_git::Git; @@ -59,13 +60,13 @@ pub struct HelloResponse { pub struct StateSnapshot { /// Applied `SQLite` schema migration version. pub schema_version: u32, - /// Registered projects. Empty until project persistence is wired in. + /// Registered projects. pub projects: Vec, - /// Available agent definitions. Empty until registry persistence is wired in. + /// Available agent definitions. pub agents: Vec, - /// Known sessions. Empty until the session manager is wired in. + /// Known sessions, including managed worktree associations. pub sessions: Vec, - /// Managed worktrees. Empty until Git orchestration is wired in. + /// Durable managed worktrees, including recoverable partial operations. pub worktrees: Vec, } @@ -146,6 +147,8 @@ impl Daemon { sessions: SessionRegistry::new( session_storage, DaemonInstanceId::from_uuid(instance_id), + config.data_directory().join("worktrees"), + git.clone(), ) .map_err(|error| { DaemonError::initialization( @@ -525,7 +528,10 @@ fn validate_peer(_stream: &UnixStream) -> Result<(), io::Error> { clippy::too_many_lines, reason = "the versioned IPC method table is intentionally kept in one auditable dispatcher" )] -async fn dispatch(request: RequestEnvelope, state: &ServerState) -> ResponseEnvelope { +async fn dispatch( + request: RequestEnvelope, + state: &Arc, +) -> ResponseEnvelope { if request.kind != EnvelopeKind::Request { return ResponseEnvelope::failure( request.request_id, @@ -560,7 +566,7 @@ async fn dispatch(request: RequestEnvelope, state: &ServerState) -> Respo projects, agents, sessions, - worktrees: Vec::new(), + worktrees: state.sessions.worktrees()?, }) }), method::PROJECT_ADD => decode_payload(request.payload) @@ -582,27 +588,34 @@ async fn dispatch(request: RequestEnvelope, state: &ServerState) -> Respo state.sessions.create_custom_agent(payload) }) .and_then(encode_response), - method::SESSION_CREATE => decode_payload(request.payload) - .and_then(|payload: SessionCreateRequest| state.sessions.create(payload)) - .and_then(encode_response), + method::SESSION_CREATE => match decode_payload::(request.payload) { + Ok(payload) => session_operation(state, move |sessions| sessions.create(payload)).await, + Err(error) => Err(error), + }, method::SESSION_LIST => decode_payload(request.payload) .and_then(|payload: SessionListRequest| state.sessions.list_sessions(payload)) .and_then(encode_response), method::SESSION_RENAME => decode_payload(request.payload) .and_then(|payload: SessionRenameRequest| state.sessions.rename(&payload)) .and_then(encode_response), - method::SESSION_START => decode_payload(request.payload) - .and_then(|payload: SessionStartRequest| state.sessions.start(payload)) - .and_then(encode_response), - method::SESSION_RESTART => decode_payload(request.payload) - .and_then(|payload: SessionRestartRequest| state.sessions.restart(payload)) - .and_then(encode_response), - method::SESSION_STOP => decode_payload(request.payload) - .and_then(|payload: SessionStopRequest| state.sessions.stop(payload)) - .and_then(encode_response), - method::SESSION_DELETE => decode_payload(request.payload) - .and_then(|payload: SessionDeleteRequest| state.sessions.delete(payload)) - .and_then(encode_response), + method::SESSION_START => match decode_payload::(request.payload) { + Ok(payload) => session_operation(state, move |sessions| sessions.start(payload)).await, + Err(error) => Err(error), + }, + method::SESSION_RESTART => match decode_payload::(request.payload) { + Ok(payload) => { + session_operation(state, move |sessions| sessions.restart(payload)).await + } + Err(error) => Err(error), + }, + method::SESSION_STOP => match decode_payload::(request.payload) { + Ok(payload) => session_operation(state, move |sessions| sessions.stop(payload)).await, + Err(error) => Err(error), + }, + method::SESSION_DELETE => match decode_payload::(request.payload) { + Ok(payload) => session_operation(state, move |sessions| sessions.delete(payload)).await, + Err(error) => Err(error), + }, method::SESSION_WRITE => decode_payload(request.payload) .and_then(|payload: SessionWriteRequest| state.sessions.write(&payload)) .and_then(encode_response), @@ -610,6 +623,26 @@ async fn dispatch(request: RequestEnvelope, state: &ServerState) -> Respo .and_then(|payload: SessionResizeRequest| state.sessions.resize(payload)) .and_then(encode_response), method::SESSION_UNSUBSCRIBE => encode_response(EmptyResponse::default()), + method::WORKTREE_LIST => decode_payload(request.payload) + .and_then(|payload: WorktreeListRequest| state.sessions.list_worktrees(payload)) + .and_then(encode_response), + method::WORKTREE_PREPARE_REMOVE => { + match decode_payload::(request.payload) { + Ok(payload) => { + session_operation(state, move |sessions| { + sessions.prepare_worktree_removal(payload) + }) + .await + } + Err(error) => Err(error), + } + } + method::WORKTREE_REMOVE => match decode_payload::(request.payload) { + Ok(payload) => { + session_operation(state, move |sessions| sessions.remove_worktree(&payload)).await + } + Err(error) => Err(error), + }, method::DIAGNOSTICS_GET => encode_response(&state.diagnostics), method::GIT_STATUS => match decode_payload::(request.payload) { Ok(payload) => { @@ -642,6 +675,23 @@ async fn dispatch(request: RequestEnvelope, state: &ServerState) -> Respo } } +/// Git and PTY lifecycle calls may block; keep client I/O and output delivery responsive. +async fn session_operation(state: &Arc, operation: F) -> Result +where + T: Serialize, + F: FnOnce(&SessionRegistry) -> Result + Send + 'static, +{ + let state = Arc::clone(state); + tokio::task::spawn_blocking(move || operation(&state.sessions).and_then(encode_response)) + .await + .map_err(|_| { + ApiError::new( + "session_operation_failed", + "The session operation could not complete.", + ) + })? +} + fn decode_payload(payload: Value) -> Result where T: for<'de> Deserialize<'de>, diff --git a/crates/daemon/src/sessions.rs b/crates/daemon/src/sessions.rs index 1192e5c..625938b 100644 --- a/crates/daemon/src/sessions.rs +++ b/crates/daemon/src/sessions.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; use std::env; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex, MutexGuard}; +use std::sync::{Arc, Mutex, MutexGuard, Weak}; use std::time::{SystemTime, UNIX_EPOCH}; use cli_master_core::wire::{ @@ -14,12 +14,16 @@ use cli_master_core::wire::{ use cli_master_core::{ AgentId, AgentSource, ApiError, DaemonInstanceId, Project, Session, SessionId, SessionStatus, }; +use cli_master_git::Git; use cli_master_session::{ - SessionError, SessionManager, SessionSnapshot, SessionSubscription, TerminalSize, + SessionError, SessionManager, SessionSnapshot, SessionSubscription, SessionWorktreeSaga, + TerminalSize, }; use cli_master_storage::{SessionRuntimeUpdate, Storage, StorageError, StoredAgent, StoredSession}; use uuid::Uuid; +mod worktrees; + const INITIAL_COLUMNS: u16 = 100; const INITIAL_ROWS: u16 = 30; const SHELL_AGENT_ID: u128 = 0x018f_0000_0000_7000_8000_0000_0000_0001; @@ -32,17 +36,44 @@ pub(super) struct SessionRegistry { storage: Arc>, manager: SessionManager, daemon_instance_id: DaemonInstanceId, + worktree_saga: Option>, + git: Option, + managed_root: PathBuf, + /// Serializes start/delete/removal so Git cannot remove a directory during spawn. + lifecycle: Mutex<()>, + session_operations: Mutex>>>, } impl SessionRegistry { pub(super) fn new( storage: Storage, daemon_instance_id: DaemonInstanceId, + managed_root: PathBuf, + git: Option, ) -> Result { + let storage = Arc::new(Mutex::new(storage)); + let manager = SessionManager::default(); + let worktree_saga = git + .clone() + .map(|git| { + SessionWorktreeSaga::new_with_shared_storage( + git, + Arc::clone(&storage), + manager.clone(), + daemon_instance_id.to_string(), + ) + }) + .transpose() + .map_err(worktrees::saga_error)?; let registry = Self { - storage: Arc::new(Mutex::new(storage)), - manager: SessionManager::default(), + storage, + manager, daemon_instance_id, + worktree_saga, + git, + managed_root, + lifecycle: Mutex::new(()), + session_operations: Mutex::new(BTreeMap::new()), }; registry.seed_builtin_agents()?; let now = unix_timestamp_ms()?; @@ -50,6 +81,9 @@ impl SessionRegistry { .storage()? .recover_stale_sessions_for_daemon(&daemon_instance_id.to_string(), now) .map_err(storage_error)?; + if let Some(saga) = ®istry.worktree_saga { + saga.recover().map_err(worktrees::saga_error)?; + } Ok(registry) } @@ -115,10 +149,8 @@ impl SessionRegistry { } pub(super) fn sessions(&self) -> Result, ApiError> { - self.storage()? - .list_sessions() - .map_err(storage_error) - .map(|sessions| sessions.into_iter().map(stored_session).collect()) + self.list_sessions(SessionListRequest { project_id: None }) + .map(|response| response.sessions) } pub(super) fn list_sessions( @@ -132,18 +164,18 @@ impl SessionRegistry { } .map_err(storage_error)? .into_iter() - .map(stored_session) - .collect(); + .map(|session| worktrees::session_with_worktree(&storage, session)) + .collect::, _>>()?; Ok(SessionListResponse { sessions }) } pub(super) fn create(&self, request: SessionCreateRequest) -> Result { - if request.isolation != SessionIsolation::Current { - return Err(ApiError::new( - "worktree_sessions_unavailable", - "Isolated worktree sessions are not available in this canvas yet.", - ) - .with_action("Choose the current project directory and try again.")); + let _lifecycle = self.lifecycle()?; + if let Some(saga) = &self.worktree_saga { + return self.prepare_with_saga(saga, request); + } + if request.isolation == SessionIsolation::NewWorktree { + return Err(worktrees::git_unavailable()); } let storage = self.storage()?; let project = storage @@ -192,10 +224,16 @@ impl SessionRegistry { } pub(super) fn start(&self, request: SessionStartRequest) -> Result { + let operation = self.session_operation(request.session_id)?; + let _session = operation.lock().map_err(|_| session_state_unavailable())?; + let _lifecycle = self.lifecycle()?; self.start_id(request.session_id) } pub(super) fn restart(&self, request: SessionRestartRequest) -> Result { + let operation = self.session_operation(request.session_id)?; + let _session = operation.lock().map_err(|_| session_state_unavailable())?; + let _lifecycle = self.lifecycle()?; if let Ok(snapshot) = self.manager.snapshot(request.session_id) { if is_live(snapshot.status) { self.manager @@ -210,6 +248,9 @@ impl SessionRegistry { } pub(super) fn stop(&self, request: SessionStopRequest) -> Result { + // Stopping only reduces worktree use. Do not queue it behind a slow checkout. + let operation = self.session_operation(request.session_id)?; + let _session = operation.lock().map_err(|_| session_state_unavailable())?; let snapshot = self .manager .stop(request.session_id) @@ -219,6 +260,9 @@ impl SessionRegistry { } pub(super) fn delete(&self, request: SessionDeleteRequest) -> Result { + let operation = self.session_operation(request.session_id)?; + let _session = operation.lock().map_err(|_| session_state_unavailable())?; + let _lifecycle = self.lifecycle()?; if let Ok(snapshot) = self.manager.snapshot(request.session_id) { if is_live(snapshot.status) { return Err(ApiError::new( @@ -227,10 +271,17 @@ impl SessionRegistry { ) .with_action("Stop the session, then try deleting it again.")); } + // A process may have exited while no client was subscribed to its events. + self.persist_snapshot(&snapshot)?; self.manager .remove(request.session_id) .map_err(session_error)?; } + if let Some(saga) = &self.worktree_saga { + saga.delete_session(request.session_id) + .map_err(worktrees::saga_error)?; + return Ok(EmptyResponse::default()); + } self.storage()? .remove_session_metadata(request.session_id) .map_err(storage_error)?; @@ -311,7 +362,7 @@ impl SessionRegistry { .ok_or_else(|| not_found("session", session_id))?; if let Ok(snapshot) = self.manager.snapshot(session_id) { if is_live(snapshot.status) { - return Ok(stored_session(stored)); + return worktrees::session_with_worktree(&storage, stored); } self.manager.remove(session_id).map_err(session_error)?; } @@ -319,8 +370,19 @@ impl SessionRegistry { .get_agent(stored.agent_id) .map_err(storage_error)? .ok_or_else(|| not_found("agent", stored.agent_id))?; + if !agent.enabled { + return Err(ApiError::new( + "agent_disabled", + "The selected terminal command is disabled.", + )); + } let command = agent.command_for_cwd(&stored.cwd).map_err(storage_error)?; drop(storage); + let worktree_id = self.validate_start_directory(&stored)?; + if let (Some(saga), Some(worktree_id)) = (&self.worktree_saga, worktree_id) { + saga.cancel_pending_removal(worktree_id) + .map_err(worktrees::saga_error)?; + } let size = TerminalSize::new(INITIAL_ROWS, INITIAL_COLUMNS).map_err(session_error)?; let handle = self .manager @@ -328,7 +390,11 @@ impl SessionRegistry { .map_err(session_error)?; let snapshot = self.manager.snapshot(session_id).map_err(session_error)?; debug_assert_eq!(handle.id, session_id); - self.persist_snapshot(&snapshot)?; + if let Err(error) = self.persist_snapshot(&snapshot) { + cli_master_session::SessionSpawner::rollback(&self.manager, session_id) + .map_err(worktrees::saga_error)?; + return Err(error); + } self.get(session_id) } @@ -378,6 +444,34 @@ impl SessionRegistry { .with_action("Restart Jig and try again.") }) } + + fn lifecycle(&self) -> Result, ApiError> { + self.lifecycle + .lock() + .map_err(|_| session_state_unavailable()) + } + + fn session_operation(&self, session_id: SessionId) -> Result>, ApiError> { + let mut operations = self + .session_operations + .lock() + .map_err(|_| session_state_unavailable())?; + operations.retain(|_, operation| operation.strong_count() > 0); + if let Some(operation) = operations.get(&session_id).and_then(Weak::upgrade) { + return Ok(operation); + } + let operation = Arc::new(Mutex::new(())); + operations.insert(session_id, Arc::downgrade(&operation)); + Ok(operation) + } +} + +fn session_state_unavailable() -> ApiError { + ApiError::new( + "session_state_unavailable", + "Session operations are unavailable.", + ) + .with_action("Restart the daemon and try again.") } fn agent_record(agent: StoredAgent) -> Result { @@ -421,18 +515,18 @@ fn stored_session(session: StoredSession) -> Session { } fn load_session(storage: &Storage, session_id: SessionId) -> Result { - storage + let session = storage .get_session(session_id) .map_err(storage_error)? - .map(stored_session) - .ok_or_else(|| not_found("session", session_id)) + .ok_or_else(|| not_found("session", session_id))?; + worktrees::session_with_worktree(storage, session) } fn session_directory( project: &Project, relative_directory: Option<&cli_master_core::wire::RelativeDirectory>, ) -> Result { - let root = project.repository_root.as_ref().unwrap_or(&project.path); + let root = &project.path; let directory = relative_directory.map_or_else(|| root.clone(), |relative| root.join(relative.as_str())); if !directory.is_dir() { @@ -442,14 +536,27 @@ fn session_directory( ) .with_action("Choose an existing directory inside the project.")); } - directory.canonicalize().map_err(|error| { + let canonical = directory.canonicalize().map_err(|error| { ApiError::new( "session_directory_unavailable", "The terminal working directory could not be opened.", ) .with_action("Check the directory permissions and try again.") .with_detail("reason", error.to_string()) - }) + })?; + let root = root.canonicalize().map_err(|_| { + ApiError::new( + "session_directory_unavailable", + "The project directory could not be opened.", + ) + })?; + if !canonical.starts_with(&root) { + return Err(ApiError::new( + "session_invalid_input", + "The terminal directory escapes the project.", + )); + } + Ok(canonical) } fn resolve_executable(executable: &str) -> Option { diff --git a/crates/daemon/src/sessions/worktrees.rs b/crates/daemon/src/sessions/worktrees.rs new file mode 100644 index 0000000..9c8f4d0 --- /dev/null +++ b/crates/daemon/src/sessions/worktrees.rs @@ -0,0 +1,267 @@ +use cli_master_core::wire::{ + SessionCreateRequest, WorktreeListRequest, WorktreeListResponse, WorktreePrepareRemoveRequest, + WorktreePrepareRemoveResponse, WorktreeRemovalBlocker, WorktreeRemoveRequest, +}; +use cli_master_core::{ApiError, Session, Worktree, WorktreeState}; +use cli_master_session::{CreateSession, SagaError, SessionManager, SessionWorktreeSaga}; +use cli_master_storage::{Storage, StoredSession, StoredWorktree}; + +use super::{SessionRegistry, not_found, storage_error, stored_session}; + +impl SessionRegistry { + pub(super) fn prepare_with_saga( + &self, + saga: &SessionWorktreeSaga, + request: SessionCreateRequest, + ) -> Result { + // The root belongs to the daemon; callers cannot supply paths or branches. + let creation = CreateSession { + project_id: request.project_id, + agent_id: request.agent_id, + name: request.name.into_inner(), + isolation: request.isolation, + managed_root: self.managed_root.join(request.project_id.to_string()), + short_id: None, + }; + saga.prepare_session(&creation, request.relative_directory.as_ref()) + .map(|created| created.session) + .map_err(saga_error) + } + + pub(crate) fn worktrees(&self) -> Result, ApiError> { + self.list_worktrees(WorktreeListRequest::default()) + .map(|response| response.worktrees) + } + + pub(crate) fn list_worktrees( + &self, + request: WorktreeListRequest, + ) -> Result { + let storage = self.storage()?; + let worktrees = match request.project_id { + Some(project_id) => storage.list_worktrees_for_project(project_id), + None => storage.list_worktrees(), + } + .map_err(storage_error)?; + Ok(WorktreeListResponse { + worktrees: worktrees.into_iter().map(worktree_dto).collect(), + }) + } + + pub(crate) fn prepare_worktree_removal( + &self, + request: WorktreePrepareRemoveRequest, + ) -> Result { + let _lifecycle = self.lifecycle()?; + let saga = self.worktree_saga.as_ref().ok_or_else(git_unavailable)?; + if self.has_live_worktree_user(request.worktree_id)? { + return Ok(WorktreePrepareRemoveResponse::Blocked { + worktree_id: request.worktree_id, + is_dirty: self + .storage()? + .get_worktree(request.worktree_id) + .map_err(storage_error)? + .is_some_and(|worktree| worktree.is_dirty), + blockers: vec![ + WorktreeRemovalBlocker::Running, + WorktreeRemovalBlocker::InUse, + ], + }); + } + saga.prepare_remove(request.worktree_id).map_err(saga_error) + } + + pub(crate) fn remove_worktree( + &self, + request: &WorktreeRemoveRequest, + ) -> Result { + let _lifecycle = self.lifecycle()?; + let saga = self.worktree_saga.as_ref().ok_or_else(git_unavailable)?; + if self.has_live_worktree_user(request.worktree_id)? { + return Err(ApiError::new( + "worktree_confirmation_invalid", + "A live session now uses this worktree.", + ) + .with_action("Stop its sessions and prepare removal again.")); + } + saga.remove_worktree(request.worktree_id, &request.confirmation_token) + .map_err(saga_error)?; + Ok(cli_master_core::wire::EmptyResponse::default()) + } + + fn has_live_worktree_user( + &self, + worktree_id: cli_master_core::WorktreeId, + ) -> Result { + let (worktree, sessions) = { + let storage = self.storage()?; + let worktree = storage + .get_worktree(worktree_id) + .map_err(storage_error)? + .ok_or_else(|| not_found("worktree", worktree_id))?; + let sessions = storage.list_sessions().map_err(storage_error)?; + (worktree, sessions) + }; + let root = worktree.path.canonicalize().unwrap_or(worktree.path); + let mut live = false; + for session in sessions { + // Also cover sessions registered directly at this worktree or a child folder. + let cwd = session.cwd.canonicalize().unwrap_or(session.cwd); + if worktree.session_id == Some(session.id) || cwd.starts_with(&root) { + if let Ok(snapshot) = self.manager.snapshot(session.id) { + self.persist_snapshot(&snapshot)?; + live |= snapshot.status.is_live(); + } else { + live |= session.status.is_live(); + } + } + } + Ok(live) + } +} + +pub(super) fn session_with_worktree( + storage: &Storage, + stored: StoredSession, +) -> Result { + let worktree = storage + .list_worktrees_for_project(stored.project_id) + .map_err(storage_error)? + .into_iter() + .find(|worktree| worktree.session_id == Some(stored.id)); + let mut session = stored_session(stored); + if let Some(worktree) = worktree { + session.branch = Some(worktree.branch); + session.worktree_id = Some(worktree.id); + session.worktree_path = Some(worktree.path); + } + Ok(session) +} + +impl SessionRegistry { + pub(super) fn validate_start_directory( + &self, + stored: &StoredSession, + ) -> Result, ApiError> { + let (project, worktree) = { + let storage = self.storage()?; + let project = storage + .get_project(stored.project_id) + .map_err(storage_error)? + .ok_or_else(|| not_found("project", stored.project_id))?; + let worktree = storage + .list_worktrees_for_project(stored.project_id) + .map_err(storage_error)? + .into_iter() + .find(|worktree| worktree.session_id == Some(stored.id)); + (project, worktree) + }; + let worktree_id = worktree.as_ref().map(|worktree| worktree.id); + let root = if let Some(worktree) = worktree { + if !matches!( + worktree.state, + cli_master_storage::WorktreeState::Active + | cli_master_storage::WorktreeState::RemovePending + ) { + return Err( + ApiError::new("worktree_not_active", "This worktree needs recovery.") + .with_action("Resolve the incomplete operation before starting."), + ); + } + if worktree + .path + .canonicalize() + .map_err(|_| directory_unavailable())? + != worktree.path + { + return Err(directory_unavailable()); + } + let git = self.git.as_ref().ok_or_else(git_unavailable)?; + let registered = git + .list_worktrees(&project.path) + .map_err(|error| saga_error(error.into()))?; + let inspection = git + .inspect_repository(&worktree.path) + .map_err(|error| saga_error(error.into()))?; + if inspection.repository_root.as_ref() != Some(&worktree.path) + || inspection.branch.as_deref() != Some(worktree.branch.as_str()) + || !registered.iter().any(|entry| { + entry.path == worktree.path + && entry.branch.as_deref() == Some(worktree.branch.as_str()) + && !entry.prunable + }) + { + return Err(ApiError::new( + "worktree_identity_changed", + "The managed Git worktree identity changed.", + ) + .with_action("Restore the original worktree or create a new isolated session.")); + } + worktree.path + } else { + project.path + }; + let canonical_root = root.canonicalize().map_err(|_| directory_unavailable())?; + let cwd = stored + .cwd + .canonicalize() + .map_err(|_| directory_unavailable())?; + if canonical_root != root || cwd != stored.cwd || !cwd.is_dir() || !cwd.starts_with(root) { + return Err(directory_unavailable()); + } + Ok(worktree_id) + } +} + +fn directory_unavailable() -> ApiError { + ApiError::new( + "session_directory_unavailable", + "The session directory is missing or outside its project/worktree.", + ) + .with_action("Restore the directory or create a session in an existing project folder.") +} + +fn worktree_dto(stored: StoredWorktree) -> Worktree { + Worktree { + id: stored.id, + project_id: stored.project_id, + session_id: stored.session_id, + path: stored.path, + branch: stored.branch, + is_dirty: stored.is_dirty, + state: match stored.state { + cli_master_storage::WorktreeState::Creating => WorktreeState::Creating, + cli_master_storage::WorktreeState::Active => WorktreeState::Active, + cli_master_storage::WorktreeState::RemovePending => WorktreeState::RemovePending, + cli_master_storage::WorktreeState::Orphaned => WorktreeState::Orphaned, + }, + created_at_ms: stored.created_at_ms, + updated_at_ms: stored.updated_at_ms, + } +} + +pub(super) fn git_unavailable() -> ApiError { + ApiError::new( + "git_unavailable", + "Git is required to manage isolated worktrees.", + ) + .with_action("Install Git and restart the daemon.") +} + +#[allow( + clippy::needless_pass_by_value, + reason = "owned adapter for Result::map_err" +)] +pub(super) fn saga_error(error: SagaError) -> ApiError { + let mut api = ApiError::new(error.code(), error.message()).with_action(error.action()); + if let Some(path) = error.path() { + api = api.with_detail("path", path.to_string_lossy().into_owned()); + } + if let Some(id) = error.worktree_id() { + api = api.with_detail("worktreeId", id.to_string()); + } + if let Some(id) = error.session_id() { + api = api.with_detail("sessionId", id.to_string()); + } + api +} diff --git a/crates/daemon/tests/worktree_ipc.rs b/crates/daemon/tests/worktree_ipc.rs new file mode 100644 index 0000000..98f36fe --- /dev/null +++ b/crates/daemon/tests/worktree_ipc.rs @@ -0,0 +1,831 @@ +use std::collections::BTreeMap; +use std::fs; +use std::os::unix::fs::symlink; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use cli_master_core::wire::{ + AgentRecord, ConfirmationToken, StateSnapshotResponse, WorktreeListResponse, + WorktreePrepareRemoveResponse, WorktreeRemovalBlocker, method, +}; +use cli_master_core::{ + AgentId, CommandSpec, Project, ProjectId, RequestEnvelope, ResponseEnvelope, ResponsePayload, + Session, SessionStatus, Worktree, WorktreeId, WorktreeState, +}; +use cli_master_daemon::{Daemon, DaemonConfig, DaemonError, MAX_FRAME_LENGTH}; +use cli_master_session::{SessionManager, SessionManagerConfig, TerminalSize}; +use cli_master_storage::{SessionRuntimeUpdate, Storage}; +use futures_util::{SinkExt, StreamExt}; +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tokio::net::UnixStream; +use tokio::task::JoinHandle; +use tokio_util::codec::{Framed, LengthDelimitedCodec}; +use tokio_util::sync::CancellationToken; + +type Client = Framed; + +struct RunningDaemon { + config: DaemonConfig, + cancellation: CancellationToken, + task: JoinHandle>, +} + +impl RunningDaemon { + fn start(root: &Path) -> Self { + let config = DaemonConfig::from_paths(root.join("data"), root.join("run")); + let daemon = Daemon::bind(config.clone()).expect("daemon should bind"); + let cancellation = CancellationToken::new(); + let task_cancellation = cancellation.clone(); + let task = tokio::spawn(async move { daemon.run(task_cancellation).await }); + Self { + config, + cancellation, + task, + } + } + + async fn connect(&self) -> Client { + let stream = UnixStream::connect(self.config.socket_path()) + .await + .expect("client should connect"); + LengthDelimitedCodec::builder() + .max_frame_length(MAX_FRAME_LENGTH) + .new_framed(stream) + } + + async fn stop(self) { + self.cancellation.cancel(); + tokio::time::timeout(Duration::from_secs(10), self.task) + .await + .expect("daemon should stop before timeout") + .expect("daemon task should join") + .expect("daemon should stop cleanly"); + } +} + +struct Fixture { + root: TempDir, + repository: PathBuf, + observed_cwd: PathBuf, + project_id: ProjectId, + agent_id: AgentId, +} + +impl Fixture { + async fn new(project_directory: &str) -> (Self, RunningDaemon, Client) { + let root = TempDir::new().expect("temporary directory should exist"); + let repository = root.path().join("repository"); + fs::create_dir_all(repository.join("apps/api")).unwrap(); + fs::write(repository.join("apps/api/tracked.txt"), "original\n").unwrap(); + git(&repository, &["init", "-b", "main"]); + git( + &repository, + &["config", "user.email", "tests@example.invalid"], + ); + git(&repository, &["config", "user.name", "CLI Master Tests"]); + git(&repository, &["add", "."]); + git(&repository, &["commit", "-m", "initial"]); + let script = root.path().join("observe-cwd.sh"); + fs::write(&script, "pwd -P > \"$1\"\nexec /bin/cat\n").unwrap(); + let observed_cwd = root.path().join("observed-cwd"); + let daemon = RunningDaemon::start(root.path()); + let mut client = daemon.connect().await; + let project: Project = call( + &mut client, + method::PROJECT_ADD, + json!({"path": repository.join(project_directory)}), + ) + .await; + let agent: AgentRecord = call( + &mut client, + method::AGENT_CUSTOM_CREATE, + json!({ + "displayName": "Worktree fixture", + "command": { + "executable": "/bin/sh", + "args": [script, observed_cwd], + "env": {} + } + }), + ) + .await; + ( + Self { + root, + repository, + observed_cwd, + project_id: project.id, + agent_id: agent.id, + }, + daemon, + client, + ) + } + + async fn create(&self, client: &mut Client, relative_directory: Option<&str>) -> Session { + let mut payload = json!({ + "projectId": self.project_id, + "agentId": self.agent_id, + "name": "Isolated task", + "isolation": "new_worktree" + }); + if let Some(relative_directory) = relative_directory { + payload["relativeDirectory"] = json!(relative_directory); + } + call(client, method::SESSION_CREATE, payload).await + } +} + +fn git(cwd: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .expect("git fixture should start"); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +async fn exchange(client: &mut Client, method: &str, payload: Value) -> ResponseEnvelope { + let request = RequestEnvelope::v1(method, payload); + client + .send(serde_json::to_vec(&request).unwrap().into()) + .await + .expect("request should send"); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let bytes = client + .next() + .await + .expect("response frame should arrive") + .expect("frame should be valid"); + let envelope: Value = serde_json::from_slice(&bytes).unwrap(); + if envelope["kind"] == "event" { + continue; + } + let response: ResponseEnvelope = serde_json::from_value(envelope).unwrap(); + assert_eq!(response.request_id, request.request_id); + return response; + } + }) + .await + .unwrap_or_else(|_| panic!("{method} should respond before timeout")) +} + +async fn call(client: &mut Client, method: &str, payload: Value) -> T { + match exchange(client, method, payload).await.payload { + ResponsePayload::Success { data } => { + serde_json::from_value(data).expect("response should match its wire DTO") + } + ResponsePayload::Error { error } => panic!("{method} failed: {error:?}"), + } +} + +async fn failure(client: &mut Client, method: &str, payload: Value) -> String { + match exchange(client, method, payload).await.payload { + ResponsePayload::Error { error } => error.code, + ResponsePayload::Success { data } => panic!("{method} unexpectedly succeeded: {data}"), + } +} + +async fn worktrees(client: &mut Client, project_id: Option) -> Vec { + let payload = project_id.map_or_else(|| json!({}), |id| json!({"projectId": id})); + let response: WorktreeListResponse = call(client, method::WORKTREE_LIST, payload).await; + response.worktrees +} + +async fn prepare(client: &mut Client, worktree_id: WorktreeId) -> WorktreePrepareRemoveResponse { + call( + client, + method::WORKTREE_PREPARE_REMOVE, + json!({"worktreeId": worktree_id}), + ) + .await +} + +async fn ready_token(client: &mut Client, worktree_id: WorktreeId) -> ConfirmationToken { + let response = prepare(client, worktree_id).await; + let WorktreePrepareRemoveResponse::Ready { + worktree_id: prepared_id, + confirmation_token, + expires_at_ms, + } = response + else { + panic!("clean unused worktree should be removable: {response:?}"); + }; + assert_eq!(prepared_id, worktree_id); + assert!(expires_at_ms > 1_700_000_000_000); + confirmation_token +} + +async fn remove(client: &mut Client, worktree_id: WorktreeId, token: ConfirmationToken) { + let _: Value = call( + client, + method::WORKTREE_REMOVE, + json!({"worktreeId": worktree_id, "confirmationToken": token}), + ) + .await; +} + +async fn wait_for_file(path: &Path) -> String { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Ok(contents) = fs::read_to_string(path) { + if !contents.is_empty() { + return contents; + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("child should report before timeout") +} + +#[tokio::test] +#[allow( + clippy::too_many_lines, + reason = "one socket lifecycle proves preparation, launch and independent metadata deletion" +)] +async fn create_prepares_without_spawning_then_start_uses_the_isolated_subdirectory() { + let (fixture, daemon, mut client) = Fixture::new("").await; + let created = fixture.create(&mut client, Some("apps/api")).await; + let worktree_id = created + .worktree_id + .expect("session should identify its worktree"); + let path = created.worktree_path.as_ref().expect("worktree path"); + assert_eq!(created.status, SessionStatus::Unknown); + assert_eq!(created.pid, None); + assert_eq!(created.pty_id, None); + assert_eq!( + failure( + &mut client, + method::SESSION_WRITE, + json!({"sessionId": created.id, "base64": "eA=="}) + ) + .await, + "session_not_found", + "prepared metadata must not have a writable PTY" + ); + assert_eq!(created.cwd, path.join("apps/api")); + assert!( + !fixture.observed_cwd.exists(), + "create must not run the command" + ); + assert_eq!( + fs::read_to_string(created.cwd.join("tracked.txt")).unwrap(), + "original\n" + ); + assert_ne!(path, &fixture.repository); + + let snapshot: StateSnapshotResponse = + call(&mut client, method::STATE_SNAPSHOT, json!({})).await; + assert_eq!(snapshot.sessions, std::slice::from_ref(&created)); + let listed = worktrees(&mut client, Some(fixture.project_id)).await; + assert_eq!(snapshot.worktrees, listed); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, worktree_id); + assert_eq!(listed[0].session_id, Some(created.id)); + assert_eq!(listed[0].path, *path); + assert_eq!(Some(&listed[0].branch), created.branch.as_ref()); + assert_eq!(listed[0].state, WorktreeState::Active); + assert!( + worktrees(&mut client, Some(ProjectId::new())) + .await + .is_empty() + ); + + let started: Session = call( + &mut client, + method::SESSION_START, + json!({"sessionId": created.id}), + ) + .await; + assert!(started.status.is_live()); + assert!(started.pid.is_some()); + assert!(started.pty_id.is_some()); + assert_eq!(started.worktree_id, Some(worktree_id)); + assert_eq!(started.cwd, created.cwd); + assert_eq!( + PathBuf::from(wait_for_file(&fixture.observed_cwd).await.trim()), + created.cwd + ); + + let _: Session = call( + &mut client, + method::SESSION_STOP, + json!({"sessionId": created.id}), + ) + .await; + let _: Value = call( + &mut client, + method::SESSION_DELETE, + json!({"sessionId": created.id}), + ) + .await; + assert!( + path.join("apps/api/tracked.txt").is_file(), + "deleting metadata must preserve the checkout" + ); + let snapshot: StateSnapshotResponse = + call(&mut client, method::STATE_SNAPSHOT, json!({})).await; + assert!(snapshot.sessions.is_empty()); + assert_eq!(snapshot.worktrees.len(), 1); + assert_eq!(snapshot.worktrees[0].session_id, None); + + let token = ready_token(&mut client, worktree_id).await; + remove(&mut client, worktree_id, token).await; + assert!(!path.exists()); + assert!(fixture.repository.join("apps/api/tracked.txt").is_file()); + assert!(worktrees(&mut client, None).await.is_empty()); + daemon.stop().await; +} + +#[tokio::test] +async fn start_rejects_a_worktree_replaced_by_a_symlink_to_the_original_project() { + let (fixture, daemon, mut client) = Fixture::new("").await; + let created = fixture.create(&mut client, Some("apps/api")).await; + let path = created.worktree_path.unwrap(); + let moved = fixture.root.path().join("moved-worktree"); + fs::rename(&path, &moved).unwrap(); + symlink(&fixture.repository, &path).unwrap(); + + let response = exchange( + &mut client, + method::SESSION_START, + json!({"sessionId": created.id}), + ) + .await; + // Restore Git's registered path before checking the result so the fixture + // stays recoverable even if a regression unexpectedly starts the command. + fs::remove_file(&path).unwrap(); + fs::rename(&moved, &path).unwrap(); + + let ResponsePayload::Error { error } = response.payload else { + panic!("a substituted worktree path must not launch an agent"); + }; + assert_eq!(error.code, "session_directory_unavailable"); + assert!(!fixture.observed_cwd.exists()); + for root in [&path, &fixture.repository] { + assert_eq!( + fs::read_to_string(root.join("apps/api/tracked.txt")).unwrap(), + "original\n" + ); + } + let snapshot: StateSnapshotResponse = + call(&mut client, method::STATE_SNAPSHOT, json!({})).await; + assert_eq!(snapshot.sessions[0].status, SessionStatus::Unknown); + assert_eq!(snapshot.sessions[0].pid, None); + assert_eq!(snapshot.worktrees[0].path, path); + daemon.stop().await; +} + +#[tokio::test] +async fn concurrent_stop_and_restart_keep_the_same_session_metadata_consistent_with_its_runtime() { + let (fixture, daemon, mut control) = Fixture::new("").await; + let created = fixture.create(&mut control, None).await; + let mut stopping = daemon.connect().await; + let mut restarting = daemon.connect().await; + + for _ in 0..3 { + let _: Session = call( + &mut control, + method::SESSION_START, + json!({"sessionId": created.id}), + ) + .await; + let (stopped, restarted) = tokio::join!( + call::( + &mut stopping, + method::SESSION_STOP, + json!({"sessionId": created.id}) + ), + call::( + &mut restarting, + method::SESSION_RESTART, + json!({"sessionId": created.id}) + ), + ); + assert!(!stopped.status.is_live()); + assert_eq!(stopped.pid, None); + assert!(restarted.status.is_live()); + assert!(restarted.pid.is_some()); + assert_eq!(restarted.worktree_id, created.worktree_id); + + let snapshot: StateSnapshotResponse = + call(&mut control, method::STATE_SNAPSHOT, json!({})).await; + assert_eq!(snapshot.sessions.len(), 1); + let durable = &snapshot.sessions[0]; + assert_eq!(durable.id, created.id); + let write = exchange( + &mut control, + method::SESSION_WRITE, + json!({"sessionId": created.id, "base64": "cHJvYmUK"}), + ) + .await; + match write.payload { + ResponsePayload::Success { .. } => { + assert!( + durable.status.is_live(), + "a writable runtime must be recorded live" + ); + assert_eq!(durable.pid, restarted.pid); + } + ResponsePayload::Error { error } => { + assert_eq!(error.code, "session_not_running"); + assert!(!durable.status.is_live()); + assert_eq!(durable.pid, None); + } + } + } + + let stopped: Session = call( + &mut control, + method::SESSION_STOP, + json!({"sessionId": created.id}), + ) + .await; + assert!(!stopped.status.is_live()); + assert_eq!(stopped.pid, None); + let token = ready_token(&mut control, created.worktree_id.unwrap()).await; + remove(&mut control, created.worktree_id.unwrap(), token).await; + daemon.stop().await; +} + +#[tokio::test] +async fn dirty_worktrees_block_removal_and_changes_after_confirmation_invalidate_the_token() { + let (fixture, daemon, mut client) = Fixture::new("").await; + let created = fixture.create(&mut client, None).await; + let worktree_id = created.worktree_id.unwrap(); + let path = created.worktree_path.unwrap(); + let token = ready_token(&mut client, worktree_id).await; + fs::write(path.join("user-notes.txt"), "preserve this\n").unwrap(); + + assert_eq!( + failure( + &mut client, + method::WORKTREE_REMOVE, + json!({ + "worktreeId": worktree_id, "confirmationToken": token + }) + ) + .await, + "worktree_confirmation_invalid" + ); + assert_eq!( + fs::read_to_string(path.join("user-notes.txt")).unwrap(), + "preserve this\n" + ); + let blocked = prepare(&mut client, worktree_id).await; + let WorktreePrepareRemoveResponse::Blocked { + is_dirty, blockers, .. + } = blocked + else { + panic!("untracked content must block removal: {blocked:?}"); + }; + assert!(is_dirty); + assert!(blockers.contains(&WorktreeRemovalBlocker::UntrackedFiles)); + assert_eq!( + worktrees(&mut client, None).await[0].state, + WorktreeState::Active + ); + + fs::remove_file(path.join("user-notes.txt")).unwrap(); + let token = ready_token(&mut client, worktree_id).await; + remove(&mut client, worktree_id, token).await; + assert!(!path.exists()); + daemon.stop().await; +} + +#[tokio::test] +async fn a_live_session_blocks_removal_and_old_tokens_cannot_override_changed_ownership() { + let (fixture, daemon, mut client) = Fixture::new("").await; + let created = fixture.create(&mut client, None).await; + let worktree_id = created.worktree_id.unwrap(); + let path = created.worktree_path.unwrap(); + let token = ready_token(&mut client, worktree_id).await; + let _: Session = call( + &mut client, + method::SESSION_START, + json!({"sessionId": created.id}), + ) + .await; + wait_for_file(&fixture.observed_cwd).await; + assert_eq!( + failure( + &mut client, + method::WORKTREE_REMOVE, + json!({ + "worktreeId": worktree_id, "confirmationToken": token + }) + ) + .await, + "worktree_confirmation_invalid" + ); + let blocked = prepare(&mut client, worktree_id).await; + let WorktreePrepareRemoveResponse::Blocked { + is_dirty, blockers, .. + } = blocked + else { + panic!("the live session must block removal: {blocked:?}"); + }; + assert!(!is_dirty); + assert!( + blockers.contains(&WorktreeRemovalBlocker::Running) + || blockers.contains(&WorktreeRemovalBlocker::InUse) + ); + assert_eq!( + failure( + &mut client, + method::SESSION_DELETE, + json!({"sessionId": created.id}) + ) + .await, + "session_still_running" + ); + assert!(path.is_dir()); + + let _: Session = call( + &mut client, + method::SESSION_STOP, + json!({"sessionId": created.id}), + ) + .await; + let token = ready_token(&mut client, worktree_id).await; + let _: Value = call( + &mut client, + method::SESSION_DELETE, + json!({"sessionId": created.id}), + ) + .await; + assert_eq!( + failure( + &mut client, + method::WORKTREE_REMOVE, + json!({ + "worktreeId": worktree_id, "confirmationToken": token + }) + ) + .await, + "worktree_confirmation_invalid" + ); + assert!(path.is_dir()); + let token = ready_token(&mut client, worktree_id).await; + remove(&mut client, worktree_id, token).await; + daemon.stop().await; +} + +#[tokio::test] +async fn an_unsubscribed_session_can_be_deleted_after_its_process_exits_on_its_own() { + let (fixture, daemon, mut client) = Fixture::new("").await; + fs::write( + fixture.root.path().join("observe-cwd.sh"), + "pwd -P > \"$1\"\nIFS= read -r line\n", + ) + .unwrap(); + let created = fixture.create(&mut client, None).await; + let worktree_id = created.worktree_id.unwrap(); + let path = created.worktree_path.unwrap(); + let started: Session = call( + &mut client, + method::SESSION_START, + json!({"sessionId": created.id}), + ) + .await; + assert!(started.status.is_live()); + wait_for_file(&fixture.observed_cwd).await; + + // Let the command return normally, with no subscription driving durable + // status updates and no explicit stop operation that could hide the bug. + let _: Value = call( + &mut client, + method::SESSION_WRITE, + json!({"sessionId": created.id, "base64": "ZG9uZQo="}), + ) + .await; + tokio::time::timeout(Duration::from_secs(5), async { + loop { + match exchange( + &mut client, + method::SESSION_DELETE, + json!({"sessionId": created.id}), + ) + .await + .payload + { + ResponsePayload::Success { .. } => break, + ResponsePayload::Error { error } => { + assert_eq!(error.code, "session_still_running"); + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("metadata should become deletable after the child exits"); + + assert!(path.join("apps/api/tracked.txt").is_file()); + let snapshot: StateSnapshotResponse = + call(&mut client, method::STATE_SNAPSHOT, json!({})).await; + assert!(snapshot.sessions.is_empty()); + assert_eq!(snapshot.worktrees[0].id, worktree_id); + assert_eq!(snapshot.worktrees[0].session_id, None); + daemon.stop().await; +} + +#[tokio::test] +async fn another_session_using_the_checkout_blocks_removal_without_a_worktree_association() { + let (fixture, daemon, mut client) = Fixture::new("").await; + let isolated = fixture.create(&mut client, None).await; + let worktree_id = isolated.worktree_id.unwrap(); + let path = isolated.worktree_path.unwrap(); + let token = ready_token(&mut client, worktree_id).await; + let project: Project = call( + &mut client, + method::PROJECT_ADD, + json!({"path": path.join("apps/api")}), + ) + .await; + let other: Session = call( + &mut client, + method::SESSION_CREATE, + json!({ + "projectId": project.id, + "agentId": fixture.agent_id, + "name": "Independent terminal", + "isolation": "current" + }), + ) + .await; + assert_eq!(other.worktree_id, None); + let _: Session = call( + &mut client, + method::SESSION_START, + json!({"sessionId": other.id}), + ) + .await; + wait_for_file(&fixture.observed_cwd).await; + let error = failure( + &mut client, + method::WORKTREE_REMOVE, + json!({"worktreeId": worktree_id, "confirmationToken": token}), + ) + .await; + assert!( + ["worktree_confirmation_invalid", "worktree_in_use"].contains(&error.as_str()), + "removal should reject changed usage: {error}" + ); + let blocked = prepare(&mut client, worktree_id).await; + let WorktreePrepareRemoveResponse::Blocked { blockers, .. } = blocked else { + panic!("another session using this checkout must block removal: {blocked:?}"); + }; + assert!(blockers.contains(&WorktreeRemovalBlocker::InUse)); + assert!(path.is_dir()); + + let _: Session = call( + &mut client, + method::SESSION_STOP, + json!({"sessionId": other.id}), + ) + .await; + let token = ready_token(&mut client, worktree_id).await; + remove(&mut client, worktree_id, token).await; + assert!(!path.exists()); + daemon.stop().await; +} + +#[tokio::test] +async fn prepared_worktrees_survive_restart_with_the_project_subdirectory_preserved() { + let (fixture, first, mut client) = Fixture::new("apps").await; + let created = fixture.create(&mut client, Some("api")).await; + let worktree_id = created.worktree_id.unwrap(); + let path = created.worktree_path.as_ref().unwrap(); + assert_eq!(created.cwd, path.join("apps/api")); + drop(client); + first.stop().await; + + let second = RunningDaemon::start(fixture.root.path()); + let mut client = second.connect().await; + let snapshot: StateSnapshotResponse = + call(&mut client, method::STATE_SNAPSHOT, json!({})).await; + assert_eq!(snapshot.sessions.len(), 1); + assert_eq!(snapshot.sessions[0].id, created.id); + assert_eq!(snapshot.sessions[0].cwd, created.cwd); + assert_eq!(snapshot.sessions[0].worktree_id, Some(worktree_id)); + assert_eq!(snapshot.sessions[0].status, SessionStatus::Unknown); + assert_eq!(snapshot.sessions[0].pid, None); + assert_eq!(snapshot.worktrees.len(), 1); + assert_eq!(snapshot.worktrees[0].state, WorktreeState::Active); + assert_eq!(snapshot.worktrees[0].path, *path); + assert_eq!(snapshot.worktrees, worktrees(&mut client, None).await); + assert!(!fixture.observed_cwd.exists()); + + let started: Session = call( + &mut client, + method::SESSION_START, + json!({"sessionId": created.id}), + ) + .await; + assert_eq!(started.cwd, created.cwd); + assert_eq!( + PathBuf::from(wait_for_file(&fixture.observed_cwd).await.trim()), + created.cwd + ); + let _: Session = call( + &mut client, + method::SESSION_STOP, + json!({"sessionId": created.id}), + ) + .await; + second.stop().await; +} + +#[tokio::test] +async fn restart_reconciles_a_worktree_session_without_signalling_an_unowned_stale_pid() { + let (fixture, first, mut client) = Fixture::new("").await; + let created = fixture.create(&mut client, None).await; + let database = first.config.database_path().to_path_buf(); + drop(client); + first.stop().await; + + // This separate manager owns the canary process. Its PID simulates an old + // persisted PID that now belongs to an unrelated process after a crash. + let canary_script = fixture.root.path().join("canary.sh"); + let canary_reply = fixture.root.path().join("canary-reply"); + fs::write( + &canary_script, + "IFS= read -r line\nprintf '%s' \"$line\" > \"$1\"\n", + ) + .unwrap(); + let canary_manager = SessionManager::new(SessionManagerConfig::default()).unwrap(); + let command = CommandSpec::try_from_parts( + "/bin/sh", + [ + canary_script.to_string_lossy().into_owned(), + canary_reply.to_string_lossy().into_owned(), + ], + fixture.root.path(), + BTreeMap::new(), + ) + .unwrap(); + let canary = canary_manager + .spawn(&command, TerminalSize::default()) + .unwrap(); + let canary_pid = canary_manager.snapshot(canary.id).unwrap().pid.unwrap(); + Storage::open(&database) + .unwrap() + .update_session_runtime( + created.id, + &SessionRuntimeUpdate { + status: SessionStatus::Running, + runtime_pid: Some(canary_pid), + daemon_instance_id: Some("crashed-daemon".to_owned()), + exit_code: None, + error_code: None, + last_activity_at_ms: None, + updated_at_ms: created.updated_at_ms, + }, + ) + .unwrap(); + + let second = RunningDaemon::start(fixture.root.path()); + let mut client = second.connect().await; + let snapshot: StateSnapshotResponse = + call(&mut client, method::STATE_SNAPSHOT, json!({})).await; + let recovered = &snapshot.sessions[0]; + assert_eq!(recovered.id, created.id); + assert_eq!(recovered.status, SessionStatus::Unknown); + assert_eq!(recovered.pid, None); + assert_eq!(recovered.pty_id, None); + assert_eq!(recovered.worktree_id, created.worktree_id); + assert_eq!(recovered.worktree_path, created.worktree_path); + assert_eq!(snapshot.worktrees[0].state, WorktreeState::Active); + assert_eq!( + failure( + &mut client, + method::SESSION_STOP, + json!({"sessionId": created.id}) + ) + .await, + "session_not_found" + ); + canary_manager.write(canary.id, b"still-alive\n").unwrap(); + assert_eq!(wait_for_file(&canary_reply).await, "still-alive"); + let durable = Storage::open(&database) + .unwrap() + .get_session(created.id) + .unwrap() + .unwrap(); + assert_eq!(durable.status, SessionStatus::Unknown); + assert_eq!(durable.runtime_pid, None); + assert_eq!(durable.daemon_instance_id, None); + canary_manager.shutdown().unwrap(); + second.stop().await; +} diff --git a/protocol/catalog.json b/protocol/catalog.json index fff6729..0efc949 100644 --- a/protocol/catalog.json +++ b/protocol/catalog.json @@ -27,6 +27,7 @@ "session.unsubscribe", "git.status", "git.diff", + "worktree.list", "worktree.prepare_remove", "worktree.remove", "diagnostics.get" From 2f3b7c810f62c0ee2f6022e19e173bb1d4f6e0d4 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 21:50:20 -0300 Subject: [PATCH 07/23] feat(knowledge): persist scoped prompts and context over IPC --- apps/desktop/src/ipc/client.test.ts | 31 + apps/desktop/src/ipc/client.ts | 17 + apps/desktop/src/ipc/domain.ts | 47 ++ apps/desktop/src/ipc/knowledge-schema.test.ts | 39 ++ apps/desktop/src/ipc/knowledge-schema.ts | 64 +++ apps/desktop/src/ipc/methods.ts | 36 ++ apps/desktop/src/test/mockIpc.ts | 6 + crates/core/src/knowledge/mod.rs | 530 ++++++++++++++++++ crates/core/src/lib.rs | 1 + crates/core/src/wire/method.rs | 10 + crates/core/src/wire/mod.rs | 6 + crates/core/tests/knowledge_catalog.rs | 19 + crates/core/tests/knowledge_contract.rs | 271 +++++++++ crates/daemon/src/knowledge/mod.rs | 68 +++ crates/daemon/src/lib.rs | 1 + crates/daemon/src/server.rs | 3 + crates/daemon/tests/knowledge_ipc.rs | 248 ++++++++ .../migrations/0004_knowledge_documents.sql | 29 + crates/storage/src/durability_tests.rs | 1 + crates/storage/src/knowledge/mod.rs | 402 +++++++++++++ crates/storage/src/lib.rs | 4 +- crates/storage/src/migrate.rs | 21 + crates/storage/tests/knowledge.rs | 509 +++++++++++++++++ docs/adr/0005-local-knowledge.md | 3 +- docs/codex/xirp-context-report.md | 100 +++- docs/codex/xirp-coordination-reply.md | 44 ++ docs/codex/xirp-integration-request.md | 21 + docs/xirp-local-parity.md | 6 +- protocol/catalog.json | 5 +- protocol/fixtures/knowledge-page.json | 25 + 30 files changed, 2538 insertions(+), 29 deletions(-) create mode 100644 apps/desktop/src/ipc/domain.ts create mode 100644 apps/desktop/src/ipc/knowledge-schema.test.ts create mode 100644 apps/desktop/src/ipc/knowledge-schema.ts create mode 100644 apps/desktop/src/ipc/methods.ts create mode 100644 crates/core/src/knowledge/mod.rs create mode 100644 crates/core/tests/knowledge_catalog.rs create mode 100644 crates/core/tests/knowledge_contract.rs create mode 100644 crates/daemon/src/knowledge/mod.rs create mode 100644 crates/daemon/tests/knowledge_ipc.rs create mode 100644 crates/storage/migrations/0004_knowledge_documents.sql create mode 100644 crates/storage/src/knowledge/mod.rs create mode 100644 crates/storage/tests/knowledge.rs create mode 100644 docs/codex/xirp-coordination-reply.md create mode 100644 protocol/fixtures/knowledge-page.json diff --git a/apps/desktop/src/ipc/client.test.ts b/apps/desktop/src/ipc/client.test.ts index f487293..be45fab 100644 --- a/apps/desktop/src/ipc/client.test.ts +++ b/apps/desktop/src/ipc/client.test.ts @@ -379,3 +379,34 @@ function requestFromArgs(value: unknown): RequestEnvelope { } return value.request as RequestEnvelope; } + +// Knowledge uses the same versioned request transport as all domain methods. +describe("knowledge IPC transport", () => { + it("keeps revisions and scope and surfaces conflicts without issuing session writes", async () => { + const entry = { + id: "0198f000-0000-7000-8000-000000000010", kind: "prompt", projectId: null, + title: "Review", body: "Review the selected changes", revision: 1, + createdAtMs: 100, updatedAtMs: 100, + }; + transport.invoke.mockReset(); + installWireResponder({ + "knowledge.list": { entries: [entry], nextCursor: null }, + "knowledge.save": entry, + "knowledge.delete": {}, + }); + const client = createTauriIpcClient(); + expect(await client.listKnowledge({ projectId: null, query: "review" })).toEqual({ entries: [entry], nextCursor: null }); + const input = { kind: "prompt" as const, projectId: null, title: entry.title, body: entry.body }; + expect(await client.saveKnowledge(input)).toEqual(entry); + await client.deleteKnowledge({ id: entry.id, expectedRevision: 1 }); + expect(capturedRequests().map((request) => request.method)).toEqual(["knowledge.list", "knowledge.save", "knowledge.delete"]); + expect(capturedRequests()[1].payload).toEqual(input); + expect(capturedRequests()[2].payload).toEqual({ id: entry.id, expectedRevision: 1 }); + + transport.invoke.mockImplementation(async (_command, { request }) => ({ + kind: "response", version: 1, requestId: request.requestId, status: "error", + error: { code: "knowledge_conflict", message: "This entry changed. Reload or save a copy." }, + })); + await expect(client.saveKnowledge({ ...input, id: entry.id, expectedRevision: 1 })).rejects.toMatchObject({ code: "knowledge_conflict" }); + }); +}); diff --git a/apps/desktop/src/ipc/client.ts b/apps/desktop/src/ipc/client.ts index e30335d..62b2cdb 100644 --- a/apps/desktop/src/ipc/client.ts +++ b/apps/desktop/src/ipc/client.ts @@ -1,3 +1,5 @@ +import { decodeKnowledgeEntry, decodeKnowledgePage } from "./knowledge-schema"; +import type { KnowledgeEntry, KnowledgeListRequest, KnowledgeListResponse, KnowledgeSaveRequest, KnowledgeDeleteRequest } from "./domain"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { openPath } from "@tauri-apps/plugin-opener"; @@ -60,6 +62,9 @@ export interface TerminalResizeInput { /** The sole frontend interface to daemon and native desktop capabilities. */ export interface IpcClient { readonly platform: AppPlatform; + listKnowledge(input: KnowledgeListRequest): Promise; + saveKnowledge(input: KnowledgeSaveRequest): Promise; + deleteKnowledge(input: KnowledgeDeleteRequest): Promise; initialize(): Promise; subscribe( handler: IpcEventHandler, @@ -136,6 +141,18 @@ export function toIpcError(error: unknown): IpcError { class TauriIpcClient implements IpcClient { readonly platform = detectPlatform(); + async listKnowledge(input: KnowledgeListRequest): Promise { + return decodeKnowledgePage(await this.request("knowledge.list", input)); + } + + async saveKnowledge(input: KnowledgeSaveRequest): Promise { + return decodeKnowledgeEntry(await this.request("knowledge.save", input)); + } + + async deleteKnowledge(input: KnowledgeDeleteRequest): Promise { + await this.request("knowledge.delete", input); + } + async initialize(): Promise { const hello = decodeHello(await this.request("system.hello", {})); if (hello.protocolVersion !== 1) { diff --git a/apps/desktop/src/ipc/domain.ts b/apps/desktop/src/ipc/domain.ts new file mode 100644 index 0000000..4dcfdaf --- /dev/null +++ b/apps/desktop/src/ipc/domain.ts @@ -0,0 +1,47 @@ +/** Additive daemon contracts. Existing UI DTOs remain owned by types.ts. */ +export type * from "./types"; + +/** User-authored local text; no Portal connection or transcript capture. */ +export type KnowledgeKind = "prompt" | "context"; + +/** Mirrors the validated Rust knowledge entry. */ +export interface KnowledgeEntry { + readonly id: string; + readonly kind: KnowledgeKind; + readonly projectId: string | null; + readonly title: string; + readonly body: string; + readonly revision: number; + readonly createdAtMs: number; + readonly updatedAtMs: number; +} + +/** Null/omitted project selects globals; a project includes its global entries. */ +export interface KnowledgeListRequest { + readonly projectId?: string | null; + readonly kind?: KnowledgeKind; + readonly cursor?: string; + readonly query?: string; +} + +/** Bodies are included in bounded, cursor-based result pages. */ +export interface KnowledgeListResponse { + readonly entries: readonly KnowledgeEntry[]; + readonly nextCursor: string | null; +} + +/** A create omits id/revision; an update requires both. */ +export interface KnowledgeSaveRequest { + readonly id?: string; + readonly expectedRevision?: number; + readonly kind: KnowledgeKind; + readonly projectId: string | null; + readonly title: string; + readonly body: string; +} + +/** A stale delete leaves the existing text intact. */ +export interface KnowledgeDeleteRequest { + readonly id: string; + readonly expectedRevision: number; +} diff --git a/apps/desktop/src/ipc/knowledge-schema.test.ts b/apps/desktop/src/ipc/knowledge-schema.test.ts new file mode 100644 index 0000000..3004c9f --- /dev/null +++ b/apps/desktop/src/ipc/knowledge-schema.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import catalog from "../../../../protocol/catalog.json"; +import fixture from "../../../../protocol/fixtures/knowledge-page.json"; +import { IPC_METHODS } from "./methods"; +import { decodeKnowledgeEntry, decodeKnowledgePage } from "./knowledge-schema"; + +describe("knowledge Rust/TypeScript contract", () => { + it("decodes the same fixture as the Rust contract, including explicit nulls", () => { + expect(decodeKnowledgePage(fixture)).toEqual(fixture); + expect(fixture.nextCursor).toBeNull(); + expect(fixture.entries[0].projectId).toBeNull(); + }); + + it("mirrors every public method", () => { + expect(IPC_METHODS).toEqual(catalog.methods); + }); + + it("rejects unknown variants without echoing text", () => { + expect(() => decodeKnowledgeEntry({ ...fixture.entries[0], kind: "private content" })).toThrow("Invalid knowledge kind"); + }); + + it("rejects unsafe revisions, text bounds and timestamps", () => { + for (const change of [ + { revision: 0 }, { revision: Number.MAX_SAFE_INTEGER + 1 }, + { updatedAtMs: -1 }, { body: "\0" }, { title: "é".repeat(129) }, + { body: "a".repeat(65_537) }, + ]) expect(() => decodeKnowledgeEntry({ ...fixture.entries[0], ...change })).toThrow(); + }); + + it("shares Rust Unicode whitespace validation", () => { + expect(decodeKnowledgeEntry({ ...fixture.entries[0], body: "\uFEFF" }).body).toBe("\uFEFF"); + expect(() => decodeKnowledgeEntry({ ...fixture.entries[0], body: "\u0085" })).toThrow(); + }); + + it("rejects a cursor that cannot advance and repeated rows", () => { + expect(() => decodeKnowledgePage({ entries: [], nextCursor: fixture.entries[0].id })).toThrow(); + expect(() => decodeKnowledgePage({ entries: [fixture.entries[0], fixture.entries[0]], nextCursor: null })).toThrow(); + }); +}); diff --git a/apps/desktop/src/ipc/knowledge-schema.ts b/apps/desktop/src/ipc/knowledge-schema.ts new file mode 100644 index 0000000..5b3d890 --- /dev/null +++ b/apps/desktop/src/ipc/knowledge-schema.ts @@ -0,0 +1,64 @@ +import { IpcContractError, requireArray, requireRecord, requireString } from "./schema"; +import type { KnowledgeEntry, KnowledgeListResponse } from "./domain"; + +/** Keep constants aligned with core::knowledge validation. */ +export const KNOWLEDGE_TITLE_BYTES = 256; +export const KNOWLEDGE_BODY_BYTES = 65_536; + +function uuid(value: unknown, version7 = false): string { + const text = requireString(value, "knowledge identifier"); + const pattern = version7 + ? /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + : /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!pattern.test(text)) throw new IpcContractError("Invalid knowledge identifier"); + return text; +} + +function integer(value: unknown, minimum: number): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) { + throw new IpcContractError("Invalid knowledge revision or timestamp"); + } + return value; +} + +function content(value: unknown, maximum: number): string { + const text = requireString(value, "knowledge text"); + if (!/[^\p{White_Space}]/u.test(text) || text.includes("\0") || new TextEncoder().encode(text).length > maximum) { + throw new IpcContractError("Invalid knowledge text length or encoding"); + } + return text; +} + +/** Validate content without including its text in contract failures. */ +export function decodeKnowledgeEntry(value: unknown): KnowledgeEntry { + const row = requireRecord(value, "knowledge entry"); + if (row.kind !== "prompt" && row.kind !== "context") { + throw new IpcContractError("Invalid knowledge kind"); + } + const createdAtMs = integer(row.createdAtMs, 0); + const updatedAtMs = integer(row.updatedAtMs, createdAtMs); + return { + id: uuid(row.id, true), + kind: row.kind, + projectId: row.projectId === null ? null : uuid(row.projectId), + title: content(row.title, KNOWLEDGE_TITLE_BYTES), + body: content(row.body, KNOWLEDGE_BODY_BYTES), + revision: integer(row.revision, 1), + createdAtMs, + updatedAtMs, + }; +} + +/** Enforce page shape, stable ordering and a usable continuation cursor. */ +export function decodeKnowledgePage(value: unknown): KnowledgeListResponse { + const page = requireRecord(value, "knowledge page"); + const entries = requireArray(page.entries, "knowledge entries").map(decodeKnowledgeEntry); + if (entries.length > 50 || entries.some((entry, index) => index > 0 && entry.id <= entries[index - 1].id)) { + throw new IpcContractError("Invalid knowledge page ordering or size"); + } + const nextCursor = page.nextCursor === null ? null : uuid(page.nextCursor, true); + if (nextCursor !== null && nextCursor !== entries[entries.length - 1]?.id) { + throw new IpcContractError("Invalid knowledge page cursor"); + } + return { entries, nextCursor }; +} diff --git a/apps/desktop/src/ipc/methods.ts b/apps/desktop/src/ipc/methods.ts new file mode 100644 index 0000000..cb3a528 --- /dev/null +++ b/apps/desktop/src/ipc/methods.ts @@ -0,0 +1,36 @@ +/** Mirror of cli_master_core::wire::method; checked against the JSON catalog. */ +export const IPC_METHODS = [ + "system.hello", + "state.snapshot", + "project.add", + "project.list", + "project.rename", + "project.remove", + "agent.list", + "agent.detect", + "agent.set_enabled", + "agent.custom.create", + "agent.custom.update", + "agent.custom.remove", + "session.create", + "session.list", + "session.rename", + "session.start", + "session.restart", + "session.stop", + "session.delete", + "session.write", + "session.resize", + "session.subscribe", + "session.unsubscribe", + "git.status", + "git.diff", + "worktree.prepare_remove", + "worktree.remove", + "diagnostics.get", + "knowledge.list", + "knowledge.save", + "knowledge.delete" +] as const; + +export type IpcMethod = (typeof IPC_METHODS)[number]; diff --git a/apps/desktop/src/test/mockIpc.ts b/apps/desktop/src/test/mockIpc.ts index 0b13c8d..c5bdb6a 100644 --- a/apps/desktop/src/test/mockIpc.ts +++ b/apps/desktop/src/test/mockIpc.ts @@ -24,6 +24,9 @@ export interface MockIpcClientOptions { /** An injected IPC fake whose unconfigured application calls fail loudly. */ export interface MockIpcClient extends IpcClient { + readonly listKnowledge: Mock; + readonly saveKnowledge: Mock; + readonly deleteKnowledge: Mock; readonly initialize: Mock; readonly subscribe: Mock; readonly subscribeTerminal: Mock; @@ -146,6 +149,9 @@ export function createMockIpcClient( getDiagnostics: vi.fn( handlers.getDiagnostics ?? (() => rejectUnhandled("getDiagnostics")), ), + listKnowledge: vi.fn(handlers.listKnowledge ?? (() => rejectUnhandled("listKnowledge"))), + saveKnowledge: vi.fn(handlers.saveKnowledge ?? (() => rejectUnhandled("saveKnowledge"))), + deleteKnowledge: vi.fn(handlers.deleteKnowledge ?? (() => rejectUnhandled("deleteKnowledge"))), openPath: vi.fn( handlers.openPath ?? (() => rejectUnhandled("openPath")), ), diff --git a/crates/core/src/knowledge/mod.rs b/crates/core/src/knowledge/mod.rs new file mode 100644 index 0000000..e093754 --- /dev/null +++ b/crates/core/src/knowledge/mod.rs @@ -0,0 +1,530 @@ +//! Pure contracts for saved local prompts and reusable context. +//! +//! Text crosses the local IPC boundary intentionally, but is excluded from +//! diagnostic formatting. This module performs no filesystem or process I/O. + +use std::{error::Error, fmt, str::FromStr}; + +use serde::{Deserialize, Deserializer, Serialize, de}; +use uuid::{Uuid, Variant}; + +use crate::ProjectId; + +/// Maximum saved title length in UTF-8 bytes. +pub const MAX_KNOWLEDGE_TITLE_BYTES: usize = 256; +/// Maximum saved prompt or context length in UTF-8 bytes. +pub const MAX_KNOWLEDGE_BODY_BYTES: usize = 64 * 1_024; +/// Highest revision that JavaScript can represent without rounding. +pub const MAX_KNOWLEDGE_REVISION: u64 = 9_007_199_254_740_991; + +/// Safe validation failure containing only static labels and explanations. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct KnowledgeValidationError { + field: &'static str, + message: &'static str, +} + +impl KnowledgeValidationError { + const fn new(field: &'static str, message: &'static str) -> Self { + Self { field, message } + } + + /// Returns the invalid field's stable label. + #[must_use] + pub const fn field(&self) -> &'static str { + self.field + } + + /// Returns an explanation that never includes submitted content. + #[must_use] + pub const fn message(&self) -> &'static str { + self.message + } +} + +impl fmt::Display for KnowledgeValidationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{} {}", self.field, self.message) + } +} + +impl Error for KnowledgeValidationError {} + +/// UUID version 7 identifier for one saved prompt or context entry. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct KnowledgeId(Uuid); + +impl KnowledgeId { + /// Generates a time-ordered UUID version 7 identifier. + #[must_use] + pub fn new() -> Self { + Self(Uuid::now_v7()) + } + + /// Validates an existing UUID without changing it. + /// + /// # Errors + /// + /// Returns an error unless the UUID uses version 7 and the RFC variant. + pub fn try_from_uuid(value: Uuid) -> Result { + if value.get_version_num() != 7 || value.get_variant() != Variant::RFC4122 { + return Err(KnowledgeValidationError::new( + "id", + "must be a UUID version 7", + )); + } + Ok(Self(value)) + } + + /// Returns the underlying UUID. + #[must_use] + pub const fn as_uuid(&self) -> &Uuid { + &self.0 + } + + /// Consumes the identifier and returns the underlying UUID. + #[must_use] + pub const fn into_uuid(self) -> Uuid { + self.0 + } +} + +impl Default for KnowledgeId { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Display for KnowledgeId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl FromStr for KnowledgeId { + type Err = KnowledgeValidationError; + + fn from_str(value: &str) -> Result { + let uuid = Uuid::parse_str(value) + .map_err(|_| KnowledgeValidationError::new("id", "must be a UUID version 7"))?; + Self::try_from_uuid(uuid) + } +} + +impl<'de> Deserialize<'de> for KnowledgeId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer) + .map_err(|_| de::Error::custom("id must be a UUID version 7 string"))?; + value.parse().map_err(de::Error::custom) + } +} + +/// The local purpose of a saved knowledge entry. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum KnowledgeKind { + /// Reusable instructions selected explicitly by a user. + Prompt, + /// Reusable local background information selected explicitly by a user. + Context, +} + +macro_rules! knowledge_text { + ($name:ident, $description:literal, $field:literal, $maximum:ident) => { + #[doc = $description] + #[derive(Clone, Eq, PartialEq, Serialize)] + #[serde(transparent)] + pub struct $name(String); + + impl $name { + /// Validates text while preserving its exact whitespace and content. + /// + /// # Errors + /// + /// Returns an error for blank text, a NUL byte, or an exceeded byte limit. + pub fn try_new(value: impl Into) -> Result { + let value = value.into(); + validate_text($field, &value, $maximum)?; + Ok(Self(value)) + } + + /// Returns validated text for explicit use or local persistence. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Consumes the value and returns its text. + #[must_use] + pub fn into_inner(self) -> String { + self.0 + } + } + + impl fmt::Debug for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct(stringify!($name)) + .field("bytes", &self.0.len()) + .finish_non_exhaustive() + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer) + .map_err(|_| de::Error::custom(concat!($field, " must be text")))?; + Self::try_new(value).map_err(de::Error::custom) + } + } + }; +} + +knowledge_text!( + KnowledgeTitle, + "Non-blank saved title bounded to 256 UTF-8 bytes; diagnostic output is redacted.", + "title", + MAX_KNOWLEDGE_TITLE_BYTES +); +knowledge_text!( + KnowledgeBody, + "Non-blank prompt or context bounded to 64 KiB; diagnostic output is redacted.", + "body", + MAX_KNOWLEDGE_BODY_BYTES +); + +/// One persisted local prompt or context entry. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeEntry { + /// Stable identifier generated by the local owner. + pub id: KnowledgeId, + /// Prompt or context classification. + pub kind: KnowledgeKind, + /// Owning project, or no project for a globally reusable entry. + pub project_id: Option, + /// User-facing title, excluded from diagnostic formatting. + pub title: KnowledgeTitle, + /// Explicitly saved user content, excluded from diagnostic formatting. + pub body: KnowledgeBody, + /// Positive revision used for optimistic concurrency. + pub revision: u64, + /// Creation time as Unix epoch milliseconds. + pub created_at_ms: i64, + /// Most recent edit time as Unix epoch milliseconds. + pub updated_at_ms: i64, +} + +impl KnowledgeEntry { + /// Checks invariants for a programmatically assembled entry. + /// + /// # Errors + /// + /// Returns an error for invalid revisions or timestamps outside the ordered, + /// non-negative JavaScript-safe epoch millisecond range. + pub const fn validate(&self) -> Result<(), KnowledgeValidationError> { + match validate_revision("revision", self.revision) { + Ok(()) => (), + Err(error) => return Err(error), + } + if self.created_at_ms < 0 + || self.updated_at_ms < self.created_at_ms + || self.updated_at_ms > 9_007_199_254_740_991 + { + return Err(KnowledgeValidationError::new( + "timestamps", + "must satisfy 0 <= createdAtMs <= updatedAtMs <= 9007199254740991", + )); + } + Ok(()) + } +} + +impl<'de> Deserialize<'de> for KnowledgeEntry { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, rename_all = "camelCase")] + struct Fields { + id: KnowledgeId, + kind: KnowledgeKind, + project_id: Option, + title: KnowledgeTitle, + body: KnowledgeBody, + revision: u64, + created_at_ms: i64, + updated_at_ms: i64, + } + + let fields = Fields::deserialize(deserializer) + .map_err(|_| de::Error::custom("invalid saved knowledge entry"))?; + let entry = Self { + id: fields.id, + kind: fields.kind, + project_id: fields.project_id, + title: fields.title, + body: fields.body, + revision: fields.revision, + created_at_ms: fields.created_at_ms, + updated_at_ms: fields.updated_at_ms, + }; + entry.validate().map_err(de::Error::custom)?; + Ok(entry) + } +} + +/// Selects global entries, optionally including entries owned by one project. +#[derive(Clone, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeListRequest { + /// When absent, list global entries only; when present, include this project. + #[serde(skip_serializing_if = "Option::is_none")] + pub project_id: Option, + /// Optional prompt/context filter applied to both scopes. + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, + /// Exclusive cursor in ascending identifier order. + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, + /// Literal case-insensitive title/body search, bounded to 256 UTF-8 bytes. + /// + /// Empty text means no search filter. The daemon trims surrounding whitespace. + #[serde(skip_serializing_if = "Option::is_none")] + pub query: Option, +} + +impl KnowledgeListRequest { + /// Checks search limits for programmatic callers. + /// + /// # Errors + /// + /// Returns an error for an oversized or NUL-containing search query. + pub fn validate(&self) -> Result<(), KnowledgeValidationError> { + if let Some(query) = &self.query { + if query.len() > MAX_KNOWLEDGE_TITLE_BYTES { + return Err(KnowledgeValidationError::new( + "query", + "exceeds its UTF-8 byte limit", + )); + } + if query.contains('\0') { + return Err(KnowledgeValidationError::new( + "query", + "must not contain a NUL byte", + )); + } + } + Ok(()) + } +} + +impl fmt::Debug for KnowledgeListRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("KnowledgeListRequest") + .field("project_id", &self.project_id) + .field("kind", &self.kind) + .field("cursor", &self.cursor) + .field("query_bytes", &self.query.as_ref().map(String::len)) + .finish() + } +} + +impl<'de> Deserialize<'de> for KnowledgeListRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, rename_all = "camelCase")] + struct Fields { + project_id: Option, + kind: Option, + cursor: Option, + query: Option, + } + + let fields = Fields::deserialize(deserializer) + .map_err(|_| de::Error::custom("invalid knowledge list request"))?; + let mut request = Self { + project_id: fields.project_id, + kind: fields.kind, + cursor: fields.cursor, + query: fields.query, + }; + request.validate().map_err(de::Error::custom)?; + request.query = request.query.map(|query| query.trim().to_owned()); + Ok(request) + } +} + +/// Bounded page of local knowledge in ascending identifier order. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct KnowledgeListResponse { + /// Entries selected and packed within the daemon's row and byte limits. + pub entries: Vec, + /// Exclusive cursor for the next page, or null when the result is exhausted. + pub next_cursor: Option, +} + +/// Creates a local entry or replaces its text with an optimistic revision check. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeSaveRequest { + /// Absent for creation; identifies the existing entry for an update. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Absent for creation; must equal the persisted revision for an update. + #[serde(skip_serializing_if = "Option::is_none")] + pub expected_revision: Option, + /// Owning project, or global scope when absent. + #[serde(skip_serializing_if = "Option::is_none")] + pub project_id: Option, + /// Explicit prompt/context classification. + pub kind: KnowledgeKind, + /// Validated saved title. + pub title: KnowledgeTitle, + /// Validated saved content. + pub body: KnowledgeBody, +} + +impl KnowledgeSaveRequest { + /// Checks mutation shape and revision bounds for programmatic callers. + /// + /// Persistence must check the expected revision atomically with the write. + /// + /// # Errors + /// + /// Returns an error for a partial id/revision pair or an invalid revision. + pub const fn validate(&self) -> Result<(), KnowledgeValidationError> { + match (self.id, self.expected_revision) { + (None, None) => Ok(()), + (Some(_), Some(revision)) => validate_revision("expectedRevision", revision), + _ => Err(KnowledgeValidationError::new( + "id and expectedRevision", + "must either both be absent for creation or both be present for an update", + )), + } + } +} + +impl<'de> Deserialize<'de> for KnowledgeSaveRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, rename_all = "camelCase")] + struct Fields { + id: Option, + expected_revision: Option, + project_id: Option, + kind: KnowledgeKind, + title: KnowledgeTitle, + body: KnowledgeBody, + } + + let fields = Fields::deserialize(deserializer) + .map_err(|_| de::Error::custom("invalid knowledge save request"))?; + let request = Self { + id: fields.id, + expected_revision: fields.expected_revision, + project_id: fields.project_id, + kind: fields.kind, + title: fields.title, + body: fields.body, + }; + request.validate().map_err(de::Error::custom)?; + Ok(request) + } +} + +/// Removes one local entry only if its revision still matches the user's copy. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeDeleteRequest { + /// Stable identifier of the entry to remove. + pub id: KnowledgeId, + /// Revision observed when removal was requested. + pub expected_revision: u64, +} + +impl KnowledgeDeleteRequest { + /// Checks revision bounds for programmatic callers. + /// + /// # Errors + /// + /// Returns an error for a zero or JavaScript-unsafe revision. + pub const fn validate(&self) -> Result<(), KnowledgeValidationError> { + validate_revision("expectedRevision", self.expected_revision) + } +} + +impl<'de> Deserialize<'de> for KnowledgeDeleteRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, rename_all = "camelCase")] + struct Fields { + id: KnowledgeId, + expected_revision: u64, + } + + let fields = Fields::deserialize(deserializer) + .map_err(|_| de::Error::custom("invalid knowledge delete request"))?; + let request = Self { + id: fields.id, + expected_revision: fields.expected_revision, + }; + request.validate().map_err(de::Error::custom)?; + Ok(request) + } +} + +fn validate_text( + field: &'static str, + value: &str, + maximum_bytes: usize, +) -> Result<(), KnowledgeValidationError> { + if value.len() > maximum_bytes { + return Err(KnowledgeValidationError::new( + field, + "exceeds its UTF-8 byte limit", + )); + } + if value.trim().is_empty() { + return Err(KnowledgeValidationError::new(field, "must not be blank")); + } + if value.contains('\0') { + return Err(KnowledgeValidationError::new( + field, + "must not contain a NUL byte", + )); + } + Ok(()) +} + +const fn validate_revision( + field: &'static str, + revision: u64, +) -> Result<(), KnowledgeValidationError> { + if revision == 0 || revision > MAX_KNOWLEDGE_REVISION { + return Err(KnowledgeValidationError::new( + field, + "must be an integer between 1 and 9007199254740991", + )); + } + Ok(()) +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 2fcd66d..2cb3f64 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -9,6 +9,7 @@ mod catalog; mod command; mod error; mod ids; +pub mod knowledge; mod model; mod protocol; mod redact; diff --git a/crates/core/src/wire/method.rs b/crates/core/src/wire/method.rs index d4633ea..dcc4a0b 100644 --- a/crates/core/src/wire/method.rs +++ b/crates/core/src/wire/method.rs @@ -63,6 +63,13 @@ pub const WORKTREE_REMOVE: &str = "worktree.remove"; /// Read a sanitized local diagnostic snapshot. pub const DIAGNOSTICS_GET: &str = "diagnostics.get"; +/// List scoped saved prompts/context in bounded pages. +pub const KNOWLEDGE_LIST: &str = "knowledge.list"; +/// Create or revision-check an explicit saved prompt/context. +pub const KNOWLEDGE_SAVE: &str = "knowledge.save"; +/// Delete a saved prompt/context after checking its revision. +pub const KNOWLEDGE_DELETE: &str = "knowledge.delete"; + /// Every method implemented by the Beta v1 contract. pub const ALL: &[&str] = &[ SYSTEM_HELLO, @@ -93,6 +100,9 @@ pub const ALL: &[&str] = &[ WORKTREE_PREPARE_REMOVE, WORKTREE_REMOVE, DIAGNOSTICS_GET, + KNOWLEDGE_LIST, + KNOWLEDGE_SAVE, + KNOWLEDGE_DELETE, ]; /// Returns whether a dotted method belongs to the Beta v1 contract. diff --git a/crates/core/src/wire/mod.rs b/crates/core/src/wire/mod.rs index cc73091..b69af5a 100644 --- a/crates/core/src/wire/mod.rs +++ b/crates/core/src/wire/mod.rs @@ -13,6 +13,12 @@ mod request; mod response; mod value; +pub use crate::knowledge::{ + KnowledgeBody, KnowledgeDeleteRequest, KnowledgeEntry, KnowledgeId, KnowledgeKind, + KnowledgeListRequest, KnowledgeListResponse, KnowledgeSaveRequest, KnowledgeTitle, + KnowledgeValidationError, MAX_KNOWLEDGE_BODY_BYTES, MAX_KNOWLEDGE_REVISION, + MAX_KNOWLEDGE_TITLE_BYTES, +}; pub use event::{ AgentChangedEvent, AgentRemovedEvent, DaemonShuttingDownEvent, GitStatusChangedEvent, ProjectChangedEvent, ProjectRemovedEvent, SessionChangedEvent, SessionDeletedEvent, diff --git a/crates/core/tests/knowledge_catalog.rs b/crates/core/tests/knowledge_catalog.rs new file mode 100644 index 0000000..b28cd66 --- /dev/null +++ b/crates/core/tests/knowledge_catalog.rs @@ -0,0 +1,19 @@ +use cli_master_core::wire::{event_name, method}; +use serde_json::{Value, json}; + +#[test] +fn knowledge_operations_share_the_authoritative_catalog() { + let catalog: Value = + serde_json::from_str(include_str!("../../../protocol/catalog.json")).unwrap(); + assert_eq!(catalog["methods"], json!(method::ALL)); + assert_eq!(catalog["events"], json!(event_name::ALL)); + for name in [ + method::KNOWLEDGE_LIST, + method::KNOWLEDGE_SAVE, + method::KNOWLEDGE_DELETE, + ] { + assert!(method::is_supported(name)); + } + // The metadata event is deliberately not advertised until its transport exists. + assert!(!event_name::is_supported("knowledge.updated")); +} diff --git a/crates/core/tests/knowledge_contract.rs b/crates/core/tests/knowledge_contract.rs new file mode 100644 index 0000000..5920876 --- /dev/null +++ b/crates/core/tests/knowledge_contract.rs @@ -0,0 +1,271 @@ +use cli_master_core::{ + ProjectId, + knowledge::{ + KnowledgeBody, KnowledgeDeleteRequest, KnowledgeEntry, KnowledgeId, KnowledgeKind, + KnowledgeListRequest, KnowledgeListResponse, KnowledgeSaveRequest, KnowledgeTitle, + MAX_KNOWLEDGE_BODY_BYTES, MAX_KNOWLEDGE_REVISION, MAX_KNOWLEDGE_TITLE_BYTES, + }, +}; +use serde_json::{Value, json}; +use uuid::Uuid; + +fn create_payload() -> Value { + json!({"kind": "prompt", "title": "Review", "body": " Review this change.\n"}) +} + +fn entry() -> KnowledgeEntry { + KnowledgeEntry { + id: KnowledgeId::new(), + kind: KnowledgeKind::Context, + project_id: Some(ProjectId::new()), + title: KnowledgeTitle::try_new("Architecture").unwrap(), + body: KnowledgeBody::try_new("Keep the process owner in the daemon.\n").unwrap(), + revision: 2, + created_at_ms: 1_778_000_000_000, + updated_at_ms: 1_778_000_000_001, + } +} + +#[test] +fn shared_page_fixture_matches_the_typed_contract_exactly() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../protocol/fixtures/knowledge-page.json" + )) + .unwrap(); + let page: KnowledgeListResponse = serde_json::from_value(fixture.clone()).unwrap(); + assert!(page.entries[0].id < page.entries[1].id); + assert_eq!(page.entries[0].project_id, None); + assert!(page.entries[1].project_id.is_some()); + assert_eq!(page.next_cursor, None); + assert_eq!(serde_json::to_value(page).unwrap(), fixture); +} + +#[test] +fn entry_timestamps_reject_negative_reversed_and_unsafe_values() { + for (created, updated) in [(-1, 0), (2, 1), (0, 9_007_199_254_740_992)] { + let mut invalid = entry(); + invalid.created_at_ms = created; + invalid.updated_at_ms = updated; + assert!(invalid.validate().is_err()); + assert!(serde_json::from_value::(json!(invalid)).is_err()); + } +} + +#[test] +fn identifiers_validate_version_and_variant_without_echoing_input() { + let id = KnowledgeId::new(); + assert_eq!(id.as_uuid().get_version_num(), 7); + assert_eq!(id.to_string().parse::().unwrap(), id); + assert_eq!(KnowledgeId::try_from_uuid(id.into_uuid()).unwrap(), id); + assert_eq!( + serde_json::from_value::(json!(id)).unwrap(), + id + ); + assert_eq!(KnowledgeId::default().as_uuid().get_version_num(), 7); + + for invalid in [ + "secret prompt text", + "00000000-0000-0000-0000-000000000000", + "550e8400-e29b-41d4-a716-446655440000", + "01900000-0000-7000-0000-000000000000", + ] { + let error = invalid.parse::().unwrap_err(); + assert_eq!(error.field(), "id"); + assert!(!error.message().contains(invalid)); + assert!(!error.to_string().contains(invalid)); + assert!(serde_json::from_value::(json!(invalid)).is_err()); + } + assert!(KnowledgeId::try_from_uuid(Uuid::nil()).is_err()); + assert!(serde_json::from_value::(json!(5)).is_err()); +} + +#[test] +fn text_validation_counts_utf8_bytes_preserves_whitespace_and_redacts_debug() { + let title = "á".repeat(MAX_KNOWLEDGE_TITLE_BYTES / 2); + assert_eq!( + KnowledgeTitle::try_new(title.clone()).unwrap().into_inner(), + title + ); + assert!(KnowledgeTitle::try_new(format!("{title}a")).is_err()); + let body = "á".repeat(MAX_KNOWLEDGE_BODY_BYTES / 2); + assert_eq!( + KnowledgeBody::try_new(body.clone()).unwrap().into_inner(), + body + ); + assert!(KnowledgeBody::try_new(format!("{body}a")).is_err()); + + for invalid in ["", " \t\n", "private\0body"] { + assert!(KnowledgeTitle::try_new(invalid).is_err()); + assert!(KnowledgeBody::try_new(invalid).is_err()); + } + let text = " private-content\n"; + let title = KnowledgeTitle::try_new(text).unwrap(); + let body = KnowledgeBody::try_new(text).unwrap(); + assert_eq!(title.as_str(), text); + assert_eq!(body.as_str(), text); + assert!(!format!("{title:?} {body:?}").contains("private-content")); +} + +#[test] +fn create_and_update_require_matching_id_revision_presence() { + let created: KnowledgeSaveRequest = serde_json::from_value(create_payload()).unwrap(); + assert_eq!(created.id, None); + assert_eq!(created.expected_revision, None); + assert_eq!(created.body.as_str(), " Review this change.\n"); + assert_eq!(serde_json::to_value(&created).unwrap(), create_payload()); + + let mut updated = create_payload(); + updated["id"] = json!(KnowledgeId::new()); + assert!(serde_json::from_value::(updated.clone()).is_err()); + updated["expectedRevision"] = json!(1); + let request: KnowledgeSaveRequest = serde_json::from_value(updated.clone()).unwrap(); + assert_eq!(request.expected_revision, Some(1)); + assert_eq!(serde_json::to_value(&request).unwrap(), updated); + updated.as_object_mut().unwrap().remove("id"); + assert!(serde_json::from_value::(updated).is_err()); + + let mut invalid_programmatic = created; + invalid_programmatic.expected_revision = Some(1); + assert!(invalid_programmatic.validate().is_err()); +} + +#[test] +fn every_mutation_rejects_zero_fractional_and_unsafe_revisions() { + for revision in [ + json!(0), + json!(-1), + json!(1.5), + json!(MAX_KNOWLEDGE_REVISION + 1), + json!("1"), + ] { + let mut update = create_payload(); + update["id"] = json!(KnowledgeId::new()); + update["expectedRevision"] = revision.clone(); + assert!(serde_json::from_value::(update).is_err()); + assert!( + serde_json::from_value::(json!({ + "id": KnowledgeId::new(), "expectedRevision": revision, + })) + .is_err() + ); + let mut invalid_entry = serde_json::to_value(entry()).unwrap(); + invalid_entry["revision"] = revision; + assert!(serde_json::from_value::(invalid_entry).is_err()); + } + + for revision in [1, MAX_KNOWLEDGE_REVISION] { + let deletion = KnowledgeDeleteRequest { + id: KnowledgeId::new(), + expected_revision: revision, + }; + let round_trip: KnowledgeDeleteRequest = serde_json::from_value(json!(deletion)).unwrap(); + assert_eq!(round_trip, deletion); + let mut valid_entry = entry(); + valid_entry.revision = revision; + assert!(serde_json::from_value::(json!(valid_entry)).is_ok()); + } +} + +#[test] +fn wire_contract_uses_camel_case_epoch_milliseconds_and_optional_scope() { + let entry = entry(); + let encoded = serde_json::to_value(&entry).unwrap(); + assert_eq!(encoded["createdAtMs"], 1_778_000_000_000_i64); + assert_eq!(encoded["updatedAtMs"], 1_778_000_000_001_i64); + assert_eq!(encoded["projectId"], json!(entry.project_id)); + assert_eq!(encoded["kind"], "context"); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + entry + ); + + let mut global = entry; + global.project_id = None; + assert_eq!( + serde_json::to_value(global).unwrap()["projectId"], + Value::Null + ); + assert_eq!( + serde_json::to_value(KnowledgeListRequest::default()).unwrap(), + json!({}) + ); +} + +#[test] +fn list_contract_validates_pagination_search_and_response() { + let id = KnowledgeId::new(); + let project_id = ProjectId::new(); + let request: KnowledgeListRequest = serde_json::from_value(json!({ + "projectId": project_id, "kind": "prompt", "cursor": id, "query": " secret_%query ", + })) + .unwrap(); + assert_eq!(request.project_id, Some(project_id)); + assert_eq!(request.kind, Some(KnowledgeKind::Prompt)); + assert_eq!(request.cursor, Some(id)); + assert_eq!(request.query.as_deref(), Some("secret_%query")); + assert!(!format!("{request:?}").contains("secret_%query")); + assert!(serde_json::from_value::(json!({"query": " "})).is_ok()); + assert!( + serde_json::from_value::(json!({"query": "a".repeat(256)})).is_ok() + ); + for invalid in ["a".repeat(257), "secret\0query".into()] { + assert!(serde_json::from_value::(json!({"query": invalid})).is_err()); + } + + let response = KnowledgeListResponse { + entries: vec![entry()], + next_cursor: Some(id), + }; + let encoded = serde_json::to_value(&response).unwrap(); + assert_eq!(encoded["nextCursor"], json!(id)); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + response + ); + assert_eq!( + serde_json::to_value(KnowledgeListResponse { + entries: vec![], + next_cursor: None + }) + .unwrap(), + json!({"entries": [], "nextCursor": null}), + ); +} + +#[test] +fn schemas_reject_unknown_duplicate_and_unsafe_fields_without_text_leaks() { + let mut save = create_payload(); + save["private_unknown_content"] = json!(true); + let error = serde_json::from_value::(save).unwrap_err(); + assert!(!error.to_string().contains("private_unknown_content")); + assert!( + serde_json::from_str::( + r#"{"kind":"prompt","title":"One","title":"Two","body":"Text"}"#, + ) + .is_err() + ); + assert!(serde_json::from_value::(json!({"limit": 99})).is_err()); + assert!( + serde_json::from_value::(json!({ + "id": KnowledgeId::new(), "expectedRevision": 1, "force": true, + })) + .is_err() + ); + let mut saved = serde_json::to_value(entry()).unwrap(); + saved["extra"] = json!(true); + assert!(serde_json::from_value::(saved).is_err()); + + for field in ["title", "body"] { + let mut invalid = create_payload(); + invalid[field] = json!("private-content\0"); + let error = serde_json::from_value::(invalid).unwrap_err(); + assert!(!error.to_string().contains("private-content")); + } + let request: KnowledgeSaveRequest = serde_json::from_value(json!({ + "kind": "prompt", "title": "private-title", "body": "private-body", + })) + .unwrap(); + let debug = format!("{request:?}"); + assert!(!debug.contains("private-title")); + assert!(!debug.contains("private-body")); +} diff --git a/crates/daemon/src/knowledge/mod.rs b/crates/daemon/src/knowledge/mod.rs new file mode 100644 index 0000000..cce9d75 --- /dev/null +++ b/crates/daemon/src/knowledge/mod.rs @@ -0,0 +1,68 @@ +//! Local user-authored knowledge; no session or process side effects. + +use cli_master_core::ApiError; +use cli_master_core::wire::{ + EmptyResponse, KnowledgeDeleteRequest, KnowledgeListRequest, KnowledgeSaveRequest, +}; +use cli_master_storage::Storage; +use serde::de::DeserializeOwned; +use serde_json::Value; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Handle only the registered knowledge methods through the existing database. +pub(crate) fn dispatch(method: &str, payload: Value, storage: &Storage) -> Result { + use cli_master_core::wire::method; + match method { + method::KNOWLEDGE_LIST => { + let request: KnowledgeListRequest = decode(payload)?; + encode( + storage + .list_knowledge(&request) + .map_err(|error| error.to_api_error())?, + ) + } + method::KNOWLEDGE_SAVE => { + let request: KnowledgeSaveRequest = decode(payload)?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|elapsed| i64::try_from(elapsed.as_millis()).ok()) + .ok_or_else(|| { + ApiError::new("clock_unavailable", "The local clock is unavailable.") + })?; + encode( + storage + .save_knowledge(&request, now) + .map_err(|error| error.to_api_error())?, + ) + } + method::KNOWLEDGE_DELETE => { + let request: KnowledgeDeleteRequest = decode(payload)?; + storage + .delete_knowledge(&request) + .map_err(|error| error.to_api_error())?; + encode(EmptyResponse::default()) + } + _ => Err(ApiError::new( + "unsupported_method", + "Unknown knowledge operation.", + )), + } +} + +fn decode(payload: Value) -> Result { + // Serde errors can quote a user value (e.g. an invalid enum variant). Never + // attach them to errors for a text-bearing knowledge request. + serde_json::from_value(payload).map_err(|_| { + ApiError::new( + "invalid_payload", + "The saved prompt or context request is invalid.", + ) + .with_action("Check the title, text, scope and revision, then retry.") + }) +} + +fn encode(value: impl serde::Serialize) -> Result { + serde_json::to_value(value) + .map_err(|_| ApiError::new("internal_error", "The local response could not be encoded.")) +} diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index 7575f9f..685d628 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -14,6 +14,7 @@ mod diagnostics; mod error; mod events; mod git_inspection; +mod knowledge; mod lock; mod paths; mod preflight; diff --git a/crates/daemon/src/server.rs b/crates/daemon/src/server.rs index 43ce49d..4825586 100644 --- a/crates/daemon/src/server.rs +++ b/crates/daemon/src/server.rs @@ -551,6 +551,9 @@ async fn dispatch(request: RequestEnvelope, state: &ServerState) -> Respo } let result = match request.method.as_str() { + method::KNOWLEDGE_LIST | method::KNOWLEDGE_SAVE | method::KNOWLEDGE_DELETE => { + crate::knowledge::dispatch(&request.method, request.payload, &state.git_storage) + } method::SYSTEM_HELLO => encode_response(&state.hello), method::STATE_SNAPSHOT => state.projects.snapshot().and_then(|projects| { let agents = state.sessions.agents()?; diff --git a/crates/daemon/tests/knowledge_ipc.rs b/crates/daemon/tests/knowledge_ipc.rs new file mode 100644 index 0000000..1417ade --- /dev/null +++ b/crates/daemon/tests/knowledge_ipc.rs @@ -0,0 +1,248 @@ +//! Real socket/SQLite acceptance for local prompts and context. +use std::{path::Path, process::Command, time::Duration}; + +use cli_master_core::{RequestEnvelope, ResponseEnvelope, ResponsePayload}; +use cli_master_daemon::{Daemon, DaemonConfig, MAX_FRAME_LENGTH}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tokio::net::UnixStream; +use tokio_util::{ + codec::{Framed, LengthDelimitedCodec}, + sync::CancellationToken, +}; + +type Client = Framed; + +struct Running { + config: DaemonConfig, + cancellation: CancellationToken, + task: tokio::task::JoinHandle>, +} + +impl Running { + fn start(root: &Path) -> Self { + let config = DaemonConfig::from_paths(root.join("data"), root.join("run")); + let daemon = Daemon::bind(config.clone()).unwrap(); + let cancellation = CancellationToken::new(); + let token = cancellation.clone(); + let task = tokio::spawn(async move { daemon.run(token).await }); + Self { + config, + cancellation, + task, + } + } + + async fn connect(&self) -> Client { + LengthDelimitedCodec::builder() + .max_frame_length(MAX_FRAME_LENGTH) + .new_framed( + UnixStream::connect(self.config.socket_path()) + .await + .unwrap(), + ) + } + + async fn stop(self) { + self.cancellation.cancel(); + tokio::time::timeout(Duration::from_secs(5), self.task) + .await + .unwrap() + .unwrap() + .unwrap(); + } +} + +async fn exchange(client: &mut Client, method: &str, payload: Value) -> ResponseEnvelope { + let request = RequestEnvelope::v1(method, payload); + client + .send(serde_json::to_vec(&request).unwrap().into()) + .await + .unwrap(); + let bytes = tokio::time::timeout(Duration::from_secs(5), client.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(bytes.len() <= MAX_FRAME_LENGTH); + let response: ResponseEnvelope = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(response.request_id, request.request_id); + response +} + +async fn success(client: &mut Client, method: &str, payload: Value) -> Value { + match exchange(client, method, payload).await.payload { + ResponsePayload::Success { data } => data, + ResponsePayload::Error { error } => panic!("{method}: {}", error.code), + } +} + +fn error_code(response: ResponseEnvelope) -> String { + match response.payload { + ResponsePayload::Error { error } => error.code, + ResponsePayload::Success { .. } => panic!("expected request failure"), + } +} + +async fn project(client: &mut Client, root: &Path, name: &str) -> Value { + let path = root.join(name); + std::fs::create_dir(&path).unwrap(); + assert!( + Command::new("git") + .args(["init", "--initial-branch=main"]) + .current_dir(&path) + .output() + .unwrap() + .status + .success() + ); + success(client, "project.add", json!({"path":path, "name":name})).await["id"].clone() +} + +#[tokio::test] +async fn knowledge_scopes_conflicts_restart_and_no_session_effects() { + let root = TempDir::new().unwrap(); + let daemon = Running::start(root.path()); + let mut client = daemon.connect().await; + let mut second = daemon.connect().await; + let first_project = project(&mut client, root.path(), "first").await; + let other_project = project(&mut client, root.path(), "other").await; + let before = success(&mut client, "state.snapshot", json!({})).await; + let global = success(&mut client, "knowledge.save", json!({ + "kind":"prompt", "projectId":null, "title":"Review % literal", "body":"Review this change without sending anything." + })).await; + assert!(global["projectId"].is_null()); + let local = success(&mut client, "knowledge.save", json!({ + "kind":"context", "projectId":first_project, "title":"Decision", "body":"Keep the original process owner." + })).await; + let page = success( + &mut second, + "knowledge.list", + json!({"projectId":first_project}), + ) + .await; + assert_eq!(page["entries"].as_array().unwrap().len(), 2); + assert!(page["nextCursor"].is_null()); + let other = success( + &mut second, + "knowledge.list", + json!({"projectId":other_project}), + ) + .await; + assert_eq!(other["entries"], json!([global])); + let literal = success(&mut second, "knowledge.list", json!({"query":"%"})).await; + assert_eq!(literal["entries"], json!([global])); + let updated = success( + &mut client, + "knowledge.save", + json!({ + "id":global["id"], "expectedRevision":1, "kind":"prompt", "projectId":null, + "title":"Reviewed", "body":"Updated prompt" + }), + ) + .await; + assert_eq!(updated["revision"], 2); + assert_eq!(updated["createdAtMs"], global["createdAtMs"]); + for method in ["knowledge.save", "knowledge.delete"] { + let payload = if method == "knowledge.save" { + json!({ + "id":global["id"], "expectedRevision":1, "kind":"prompt", "projectId":null, + "title":"stale", "body":"must not replace updated text" + }) + } else { + json!({"id":global["id"], "expectedRevision":1}) + }; + assert_eq!( + error_code(exchange(&mut second, method, payload).await), + "knowledge_conflict" + ); + } + let after = success(&mut client, "state.snapshot", json!({})).await; + assert_eq!(after["sessions"], before["sessions"]); + assert_eq!(after["worktrees"], before["worktrees"]); + drop(client); + drop(second); + daemon.stop().await; + + let daemon = Running::start(root.path()); + let mut client = daemon.connect().await; + let page = success( + &mut client, + "knowledge.list", + json!({"projectId":first_project}), + ) + .await; + assert!(page["entries"].as_array().unwrap().contains(&updated)); + assert!(page["entries"].as_array().unwrap().contains(&local)); + success( + &mut client, + "knowledge.delete", + json!({"id":updated["id"],"expectedRevision":2}), + ) + .await; + success( + &mut client, + "project.remove", + json!({"projectId":first_project}), + ) + .await; + assert!(root.path().join("first/.git").is_dir()); + let empty = success(&mut client, "knowledge.list", json!({})).await; + assert_eq!(empty, json!({"entries":[],"nextCursor":null})); + daemon.stop().await; +} + +#[tokio::test] +async fn knowledge_worst_case_escaped_pages_and_safe_invalid_payloads() { + let root = TempDir::new().unwrap(); + let daemon = Running::start(root.path()); + let mut client = daemon.connect().await; + let body = "\u{1}".repeat(65_536); + let mut ids = Vec::new(); + for index in 0..3 { + let entry = success( + &mut client, + "knowledge.save", + json!({ + "kind":"prompt", "title":format!("Large {index}"), "body":body, "projectId":null + }), + ) + .await; + ids.push(entry["id"].clone()); + } + let mut seen = Vec::new(); + let mut cursor = Value::Null; + for _ in 0..4 { + let page = success(&mut client, "knowledge.list", json!({"cursor":cursor})).await; + for entry in page["entries"].as_array().unwrap() { + assert_eq!(entry["body"], body); + seen.push(entry["id"].clone()); + } + cursor = page["nextCursor"].clone(); + if cursor.is_null() { + break; + } + } + assert!(cursor.is_null()); + assert_eq!(seen, ids); + for payload in [ + json!({"kind":"private-submitted-content", "title":"text", "body":"private-submitted-content"}), + json!({"kind":"prompt", "title":"text", "body":"private-submitted-content", "expectedRevision":0}), + json!({"kind":"prompt", "title":"text", "body":"x".repeat(65_537)}), + json!({"kind":"prompt", "title":"text", "body":"\0"}), + ] { + let response = exchange(&mut client, "knowledge.save", payload).await; + assert!( + !serde_json::to_string(&response) + .unwrap() + .contains("private-submitted-content") + ); + assert_eq!(error_code(response), "invalid_payload"); + } + assert_eq!( + success(&mut client, "state.snapshot", json!({})).await["sessions"], + json!([]) + ); + daemon.stop().await; +} diff --git a/crates/storage/migrations/0004_knowledge_documents.sql b/crates/storage/migrations/0004_knowledge_documents.sql new file mode 100644 index 0000000..e663cb5 --- /dev/null +++ b/crates/storage/migrations/0004_knowledge_documents.sql @@ -0,0 +1,29 @@ +-- Explicitly saved local content. Project removal deletes scoped metadata only. +CREATE TABLE knowledge_documents ( + id TEXT PRIMARY KEY NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('prompt', 'context')), + project_id TEXT REFERENCES projects(id) ON DELETE CASCADE, + title TEXT NOT NULL CHECK ( + length(CAST(title AS BLOB)) BETWEEN 1 AND 256 + AND instr(title, char(0)) = 0 + ), + body TEXT NOT NULL CHECK ( + length(CAST(body AS BLOB)) BETWEEN 1 AND 65536 + AND instr(body, char(0)) = 0 + ), + revision INTEGER NOT NULL CHECK ( + typeof(revision) = 'integer' + AND revision BETWEEN 1 AND 9007199254740991 + ), + created_at_ms INTEGER NOT NULL CHECK ( + typeof(created_at_ms) = 'integer' + AND created_at_ms BETWEEN 0 AND 9007199254740991 + ), + updated_at_ms INTEGER NOT NULL CHECK ( + typeof(updated_at_ms) = 'integer' + AND updated_at_ms BETWEEN created_at_ms AND 9007199254740991 + ) +); + +CREATE INDEX knowledge_by_scope_id ON knowledge_documents(project_id, id); +CREATE INDEX knowledge_by_scope_kind_id ON knowledge_documents(project_id, kind, id); diff --git a/crates/storage/src/durability_tests.rs b/crates/storage/src/durability_tests.rs index 004168a..f29fc29 100644 --- a/crates/storage/src/durability_tests.rs +++ b/crates/storage/src/durability_tests.rs @@ -35,6 +35,7 @@ fn configured_file_database_uses_full_wal_and_verified_schema() { (1, "initial"), (2, "worktree_dirty_state"), (3, "recovery_metadata"), + (4, "knowledge_documents"), ] ); diff --git a/crates/storage/src/knowledge/mod.rs b/crates/storage/src/knowledge/mod.rs new file mode 100644 index 0000000..917514f --- /dev/null +++ b/crates/storage/src/knowledge/mod.rs @@ -0,0 +1,402 @@ +//! Durable, explicitly saved local prompts and context with optimistic writes. + +use std::{error::Error, fmt}; + +use cli_master_core::{ + ApiError, ProjectId, + knowledge::{ + KnowledgeBody, KnowledgeDeleteRequest, KnowledgeEntry, KnowledgeId, KnowledgeKind, + KnowledgeListRequest, KnowledgeListResponse, KnowledgeSaveRequest, KnowledgeTitle, + KnowledgeValidationError, MAX_KNOWLEDGE_REVISION, + }, +}; +use rusqlite::{Connection, OptionalExtension, Row, TransactionBehavior, params}; + +use crate::{Storage, StorageError}; + +/// Maximum rows returned in one knowledge page. +pub const MAX_KNOWLEDGE_PAGE_ENTRIES: usize = 50; +/// Maximum serialized page size, leaving space for the socket response envelope. +pub const MAX_KNOWLEDGE_PAGE_BYTES: usize = 512 * 1_024; + +const ENTRY_COLUMNS: &str = + "id, kind, project_id, title, body, revision, created_at_ms, updated_at_ms"; +// Reserve more than the fixed JSON wrapper plus a UUID next-page cursor. +const PAGE_OVERHEAD_BYTES: usize = 128; + +/// Safe knowledge repository failure. Diagnostic formatting never includes text. +pub enum KnowledgeStorageError { + /// Caller supplied a malformed request. + InvalidInput(KnowledgeValidationError), + /// The supplied clock value cannot be represented safely on the wire. + InvalidTimestamp, + /// The requested saved entry no longer exists. + NotFound, + /// The selected project does not exist. + ProjectNotFound, + /// Another writer changed the entry after the caller loaded it. + Conflict, + /// The entry exhausted the revision range and cannot be incremented. + RevisionExhausted, + /// Persisted content violates the typed contract. + CorruptData, + /// The underlying metadata store could not complete the operation. + Storage(StorageError), +} + +impl KnowledgeStorageError { + /// Projects a failure into a stable error without SQL or saved content. + #[must_use] + pub fn to_api_error(&self) -> ApiError { + let (code, action) = match self { + Self::InvalidInput(_) | Self::InvalidTimestamp => { + ("invalid_request", "Correct the request and retry") + } + Self::NotFound => ("knowledge_not_found", "Reload the saved library"), + Self::ProjectNotFound => ("project_not_found", "Select an existing project"), + Self::Conflict => ( + "knowledge_conflict", + "Reload the entry before saving or deleting", + ), + Self::RevisionExhausted => ( + "knowledge_revision_exhausted", + "Save the content as a new entry", + ), + Self::CorruptData => ( + "knowledge_corrupt_data", + "Restore a known-good database backup", + ), + Self::Storage(error) => return error.to_api_error(), + }; + ApiError::new(code, self.to_string()).with_action(action) + } +} + +impl fmt::Display for KnowledgeStorageError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidInput(error) => error.fmt(formatter), + Self::InvalidTimestamp => { + formatter.write_str("Knowledge timestamp is outside the supported range") + } + Self::NotFound => formatter.write_str("The saved entry no longer exists"), + Self::ProjectNotFound => formatter.write_str("The selected project no longer exists"), + Self::Conflict => { + formatter.write_str("The saved entry has changed since it was loaded") + } + Self::RevisionExhausted => { + formatter.write_str("The saved entry cannot advance its revision") + } + Self::CorruptData => { + formatter.write_str("Stored knowledge does not match the supported contract") + } + Self::Storage(error) => fmt::Display::fmt(error, formatter), + } + } +} + +impl fmt::Debug for KnowledgeStorageError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, formatter) + } +} + +// Deliberately avoid exposing underlying SQLite errors through a source chain. +impl Error for KnowledgeStorageError {} + +impl From for KnowledgeStorageError { + fn from(value: StorageError) -> Self { + Self::Storage(value) + } +} + +impl From for KnowledgeStorageError { + fn from(value: rusqlite::Error) -> Self { + Self::Storage(StorageError::Database(value)) + } +} + +impl From for KnowledgeStorageError { + fn from(value: KnowledgeValidationError) -> Self { + Self::InvalidInput(value) + } +} + +impl Storage { + /// Lists globals or globals plus one existing project's entries in ID order. + /// + /// The page is bounded by row count and actual JSON size. Literal search + /// uses `SQLite`'s built-in case folding, which is case-insensitive for ASCII. + /// Project validation and the page read share one database snapshot. + /// + /// # Errors + /// + /// Returns an error for invalid input, a missing project, corrupt rows, or database failure. + pub fn list_knowledge( + &self, + request: &KnowledgeListRequest, + ) -> Result { + request.validate()?; + self.with_connection_mut("list knowledge", |connection| { + Ok(list_knowledge(connection, request)) + })? + } + + /// Creates an entry or atomically replaces the caller's expected revision. + /// + /// An update may explicitly change kind or scope. Saved text is never + /// executed or sent to an agent by this operation. Clock rollback does not + /// decrease the entry's update timestamp. + /// + /// # Errors + /// + /// Returns an error for invalid input, a missing row/project, a stale or + /// exhausted revision, or database failure. Failed writes roll back fully. + pub fn save_knowledge( + &self, + request: &KnowledgeSaveRequest, + now: i64, + ) -> Result { + request.validate()?; + if !(0..=9_007_199_254_740_991).contains(&now) { + return Err(KnowledgeStorageError::InvalidTimestamp); + } + self.with_connection_mut("save knowledge", |connection| { + Ok(save_knowledge(connection, request, now)) + })? + } + + /// Atomically deletes only the entry revision observed by the caller. + /// + /// # Errors + /// + /// Returns an error for invalid input, a missing entry, a stale revision, or database failure. + pub fn delete_knowledge( + &self, + request: &KnowledgeDeleteRequest, + ) -> Result<(), KnowledgeStorageError> { + request.validate()?; + self.with_connection_mut("delete knowledge", |connection| { + Ok(delete_knowledge(connection, request)) + })? + } +} + +fn list_knowledge( + connection: &mut Connection, + request: &KnowledgeListRequest, +) -> Result { + let transaction = connection.transaction()?; + require_project(&transaction, request.project_id)?; + let sql = format!( + "SELECT {ENTRY_COLUMNS} FROM knowledge_documents + WHERE (project_id IS NULL OR project_id = ?1) + AND (?2 IS NULL OR kind = ?2) + AND (?3 IS NULL OR id > ?3) + AND (?4 = '' OR instr(lower(title), lower(?4)) > 0 + OR instr(lower(body), lower(?4)) > 0) + ORDER BY id ASC LIMIT 51" + ); + let mut statement = transaction.prepare(&sql)?; + let mut rows = statement.query(params![ + request.project_id.map(|id| id.to_string()), + request.kind.map(kind_name), + request.cursor.map(|id| id.to_string()), + request.query.as_deref().unwrap_or("").trim(), + ])?; + let mut page = KnowledgeListResponse { + entries: Vec::new(), + next_cursor: None, + }; + let mut page_bytes = PAGE_OVERHEAD_BYTES; + while let Some(row) = rows.next()? { + if page.entries.len() == MAX_KNOWLEDGE_PAGE_ENTRIES { + page.next_cursor = page.entries.last().map(|entry| entry.id); + break; + } + let entry = decode_entry(row)?; + let serialized = + serde_json::to_vec(&entry).map_err(|_| KnowledgeStorageError::CorruptData)?; + if page_bytes + serialized.len() + 1 > MAX_KNOWLEDGE_PAGE_BYTES { + let Some(last) = page.entries.last() else { + return Err(KnowledgeStorageError::CorruptData); + }; + page.next_cursor = Some(last.id); + break; + } + page_bytes += serialized.len() + 1; + page.entries.push(entry); + } + drop(rows); + drop(statement); + transaction.commit()?; + Ok(page) +} + +fn save_knowledge( + connection: &mut Connection, + request: &KnowledgeSaveRequest, + now: i64, +) -> Result { + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + require_project(&transaction, request.project_id)?; + let (id, revision, created_at_ms, updated_at_ms) = match (request.id, request.expected_revision) + { + (Some(id), Some(expected_revision)) => { + let current = load_entry(&transaction, id)?.ok_or(KnowledgeStorageError::NotFound)?; + if current.revision != expected_revision { + return Err(KnowledgeStorageError::Conflict); + } + if current.revision == MAX_KNOWLEDGE_REVISION { + return Err(KnowledgeStorageError::RevisionExhausted); + } + ( + id, + current.revision + 1, + current.created_at_ms, + now.max(current.updated_at_ms), + ) + } + (None, None) => (KnowledgeId::new(), 1, now, now), + _ => return Err(KnowledgeStorageError::CorruptData), + }; + let entry = KnowledgeEntry { + id, + kind: request.kind, + project_id: request.project_id, + title: request.title.clone(), + body: request.body.clone(), + revision, + created_at_ms, + updated_at_ms, + }; + entry.validate()?; + let revision_sql = + i64::try_from(entry.revision).map_err(|_| KnowledgeStorageError::RevisionExhausted)?; + let changed = if let Some(expected_revision) = request.expected_revision { + let expected_sql = i64::try_from(expected_revision) + .map_err(|_| KnowledgeStorageError::RevisionExhausted)?; + transaction.execute( + "UPDATE knowledge_documents SET kind = ?2, project_id = ?3, title = ?4, + body = ?5, revision = ?6, updated_at_ms = ?7 WHERE id = ?1 AND revision = ?8", + params![ + id.to_string(), + kind_name(entry.kind), + entry.project_id.map(|id| id.to_string()), + entry.title.as_str(), + entry.body.as_str(), + revision_sql, + updated_at_ms, + expected_sql + ], + )? + } else { + transaction.execute( + "INSERT INTO knowledge_documents + (id, kind, project_id, title, body, revision, created_at_ms, updated_at_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + id.to_string(), + kind_name(entry.kind), + entry.project_id.map(|id| id.to_string()), + entry.title.as_str(), + entry.body.as_str(), + revision_sql, + created_at_ms, + updated_at_ms + ], + )? + }; + if changed != 1 { + return Err(KnowledgeStorageError::Conflict); + } + transaction.commit()?; + Ok(entry) +} + +fn delete_knowledge( + connection: &mut Connection, + request: &KnowledgeDeleteRequest, +) -> Result<(), KnowledgeStorageError> { + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current = load_entry(&transaction, request.id)?.ok_or(KnowledgeStorageError::NotFound)?; + if current.revision != request.expected_revision { + return Err(KnowledgeStorageError::Conflict); + } + let revision = i64::try_from(request.expected_revision) + .map_err(|_| KnowledgeStorageError::RevisionExhausted)?; + let changed = transaction.execute( + "DELETE FROM knowledge_documents WHERE id = ?1 AND revision = ?2", + params![request.id.to_string(), revision], + )?; + if changed != 1 { + return Err(KnowledgeStorageError::Conflict); + } + transaction.commit()?; + Ok(()) +} + +fn require_project( + connection: &Connection, + project: Option, +) -> Result<(), KnowledgeStorageError> { + if let Some(id) = project { + let exists: bool = connection.query_row( + "SELECT EXISTS(SELECT 1 FROM projects WHERE id = ?1)", + [id.to_string()], + |row| row.get(0), + )?; + if !exists { + return Err(KnowledgeStorageError::ProjectNotFound); + } + } + Ok(()) +} + +fn load_entry( + connection: &Connection, + id: KnowledgeId, +) -> Result, KnowledgeStorageError> { + let sql = format!("SELECT {ENTRY_COLUMNS} FROM knowledge_documents WHERE id = ?1"); + connection + .query_row(&sql, [id.to_string()], |row| Ok(decode_entry(row))) + .optional()? + .transpose() +} + +fn decode_entry(row: &Row<'_>) -> Result { + let id: String = row.get(0)?; + let kind: String = row.get(1)?; + let project: Option = row.get(2)?; + let revision: i64 = row.get(5)?; + let entry = KnowledgeEntry { + id: id.parse().map_err(|_| KnowledgeStorageError::CorruptData)?, + kind: match kind.as_str() { + "prompt" => KnowledgeKind::Prompt, + "context" => KnowledgeKind::Context, + _ => return Err(KnowledgeStorageError::CorruptData), + }, + project_id: project + .map(|id| id.parse()) + .transpose() + .map_err(|_| KnowledgeStorageError::CorruptData)?, + title: KnowledgeTitle::try_new(row.get::<_, String>(3)?) + .map_err(|_| KnowledgeStorageError::CorruptData)?, + body: KnowledgeBody::try_new(row.get::<_, String>(4)?) + .map_err(|_| KnowledgeStorageError::CorruptData)?, + revision: u64::try_from(revision).map_err(|_| KnowledgeStorageError::CorruptData)?, + created_at_ms: row.get(6)?, + updated_at_ms: row.get(7)?, + }; + entry + .validate() + .map_err(|_| KnowledgeStorageError::CorruptData)?; + Ok(entry) +} + +const fn kind_name(kind: KnowledgeKind) -> &'static str { + match kind { + KnowledgeKind::Prompt => "prompt", + KnowledgeKind::Context => "context", + } +} diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 97c88f4..63572a3 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -8,6 +8,7 @@ mod agents; mod connection; mod error; +pub mod knowledge; mod migrate; mod models; mod paths; @@ -31,11 +32,12 @@ use crate::connection::{StorageLocation, maybe_backup_before_migrate}; pub use connection::Storage; pub use error::StorageError; +pub use knowledge::KnowledgeStorageError; pub use models::{SessionRuntimeUpdate, StoredAgent, StoredSession, StoredWorktree, WorktreeState}; pub use recovery::{ReconciliationEvent, ReconciliationReason, RecoveryContext}; /// The newest schema version understood by this crate. -pub const LATEST_SCHEMA_VERSION: u32 = 3; +pub const LATEST_SCHEMA_VERSION: u32 = 4; impl Storage { /// Opens and configures a file-backed `SQLite` database. diff --git a/crates/storage/src/migrate.rs b/crates/storage/src/migrate.rs index 1f6cbc3..ce4e2c1 100644 --- a/crates/storage/src/migrate.rs +++ b/crates/storage/src/migrate.rs @@ -33,6 +33,12 @@ const MIGRATIONS: &[Migration] = &[ sql: include_str!("../migrations/0003_recovery_metadata.sql"), destructive: false, }, + Migration { + version: 4, + name: "knowledge_documents", + sql: include_str!("../migrations/0004_knowledge_documents.sql"), + destructive: false, + }, ]; const REQUIRED_TABLES: &[(&str, &[&str])] = &[ @@ -91,6 +97,19 @@ const REQUIRED_TABLES: &[(&str, &[&str])] = &[ ], ), ("settings", &["key", "value_json", "updated_at"]), + ( + "knowledge_documents", + &[ + "id", + "kind", + "project_id", + "title", + "body", + "revision", + "created_at_ms", + "updated_at_ms", + ], + ), ("schema_migrations", &["version", "name", "applied_at"]), ]; @@ -103,6 +122,8 @@ const REQUIRED_INDEXES: &[&str] = &[ "sessions_by_agent", "sessions_by_daemon_status", "custom_agents_by_updated", + "knowledge_by_scope_id", + "knowledge_by_scope_kind_id", ]; const REQUIRED_TRIGGERS: &[&str] = &[ diff --git a/crates/storage/tests/knowledge.rs b/crates/storage/tests/knowledge.rs new file mode 100644 index 0000000..3a960e6 --- /dev/null +++ b/crates/storage/tests/knowledge.rs @@ -0,0 +1,509 @@ +mod common; + +use std::{ + collections::BTreeSet, + sync::{Arc, Barrier}, + thread, +}; + +use cli_master_core::{ + ProjectId, + knowledge::{ + KnowledgeBody, KnowledgeDeleteRequest, KnowledgeEntry, KnowledgeKind, KnowledgeListRequest, + KnowledgeSaveRequest, KnowledgeTitle, MAX_KNOWLEDGE_BODY_BYTES, MAX_KNOWLEDGE_REVISION, + }, +}; +use cli_master_storage::{ + KnowledgeStorageError, LATEST_SCHEMA_VERSION, Storage, + knowledge::{MAX_KNOWLEDGE_PAGE_BYTES, MAX_KNOWLEDGE_PAGE_ENTRIES}, +}; +use rusqlite::{Connection, params}; +use tempfile::TempDir; + +const NOW: i64 = 1_788_600_000_000; + +fn create( + project_id: Option, + kind: KnowledgeKind, + title: &str, + body: &str, +) -> KnowledgeSaveRequest { + KnowledgeSaveRequest { + id: None, + expected_revision: None, + project_id, + kind, + title: KnowledgeTitle::try_new(title).unwrap(), + body: KnowledgeBody::try_new(body).unwrap(), + } +} + +fn update(entry: &KnowledgeEntry, body: &str) -> KnowledgeSaveRequest { + KnowledgeSaveRequest { + id: Some(entry.id), + expected_revision: Some(entry.revision), + project_id: entry.project_id, + kind: entry.kind, + title: entry.title.clone(), + body: KnowledgeBody::try_new(body).unwrap(), + } +} + +fn list(project_id: Option) -> KnowledgeListRequest { + KnowledgeListRequest { + project_id, + ..KnowledgeListRequest::default() + } +} + +#[test] +fn entries_survive_reopen_with_scopes_kind_search_and_monotonic_timestamps() { + let directory = TempDir::new().unwrap(); + let path = directory.path().join("knowledge.db"); + let storage = Storage::open_migrated(&path).unwrap(); + let project = common::project("First", directory.path().join("first")); + let other = common::project("Other", directory.path().join("other")); + storage.insert_project(&project).unwrap(); + storage.insert_project(&other).unwrap(); + let global = storage + .save_knowledge( + &create(None, KnowledgeKind::Prompt, "Review", "Check failures"), + NOW, + ) + .unwrap(); + let scoped = storage + .save_knowledge( + &create( + Some(project.id), + KnowledgeKind::Context, + "Architecture", + "Daemon owns PTYs", + ), + NOW, + ) + .unwrap(); + storage + .save_knowledge( + &create(Some(other.id), KnowledgeKind::Prompt, "Other", "Unrelated"), + NOW, + ) + .unwrap(); + assert_eq!(global.revision, 1); + let changed = storage + .save_knowledge(&update(&scoped, "Daemon owns processes"), NOW - 1) + .unwrap(); + assert_eq!(changed.created_at_ms, NOW); + assert_eq!(changed.updated_at_ms, NOW); + assert_eq!(changed.revision, 2); + storage.close().unwrap(); + drop(storage); + + let reopened = Storage::open_migrated(&path).unwrap(); + assert_eq!( + reopened.list_knowledge(&list(None)).unwrap().entries, + vec![global] + ); + let scoped_page = reopened.list_knowledge(&list(Some(project.id))).unwrap(); + assert_eq!(scoped_page.entries.len(), 2); + assert!(scoped_page.entries.contains(&changed)); + let mut filtered = list(Some(project.id)); + filtered.kind = Some(KnowledgeKind::Context); + assert_eq!( + reopened.list_knowledge(&filtered).unwrap().entries, + vec![changed.clone()] + ); + filtered.query = Some(" PROCESSES ".to_owned()); + assert_eq!( + reopened.list_knowledge(&filtered).unwrap().entries, + vec![changed] + ); +} + +#[test] +fn literal_search_treats_percent_underscore_and_sql_syntax_as_text() { + let directory = TempDir::new().unwrap(); + let storage = Storage::open_migrated(directory.path().join("literal.db")).unwrap(); + let exact = storage + .save_knowledge( + &create( + None, + KnowledgeKind::Prompt, + "Literal %_", + "SELECT ' OR 1=1 --", + ), + NOW, + ) + .unwrap(); + storage + .save_knowledge( + &create(None, KnowledgeKind::Prompt, "Other", "Different"), + NOW, + ) + .unwrap(); + for text in ["%_", "literal", "' OR 1=1 --"] { + let request = KnowledgeListRequest { + query: Some(text.to_owned()), + ..list(None) + }; + assert_eq!( + storage.list_knowledge(&request).unwrap().entries, + vec![exact.clone()] + ); + } + assert_eq!( + storage + .list_knowledge(&KnowledgeListRequest { + query: Some(String::new()), + ..list(None) + }) + .unwrap() + .entries + .len(), + 2 + ); +} + +#[test] +fn missing_projects_fail_and_explicit_scope_move_obeys_cascade() { + let directory = TempDir::new().unwrap(); + let path = directory.path().join("scope.db"); + let storage = Storage::open_migrated(&path).unwrap(); + let project = common::project("Scope", directory.path().join("project")); + std::fs::create_dir(&project.path).unwrap(); + std::fs::write(project.path.join("keep.txt"), "repository content").unwrap(); + storage.insert_project(&project).unwrap(); + let global = storage + .save_knowledge( + &create(None, KnowledgeKind::Prompt, "Global", "Preserved"), + NOW, + ) + .unwrap(); + let scoped = storage + .save_knowledge( + &create( + Some(project.id), + KnowledgeKind::Prompt, + "Scoped", + "Project data", + ), + NOW, + ) + .unwrap(); + let missing_project = ProjectId::new(); + assert!(matches!( + storage.list_knowledge(&list(Some(missing_project))), + Err(KnowledgeStorageError::ProjectNotFound) + )); + assert!(matches!( + storage.save_knowledge( + &create( + Some(missing_project), + KnowledgeKind::Context, + "Missing", + "Should not save" + ), + NOW + ), + Err(KnowledgeStorageError::ProjectNotFound) + )); + let mut move_request = update(&global, "Attempted edit"); + move_request.project_id = Some(missing_project); + assert!(matches!( + storage.save_knowledge(&move_request, NOW + 1), + Err(KnowledgeStorageError::ProjectNotFound) + )); + assert_eq!( + storage.list_knowledge(&list(None)).unwrap().entries, + vec![global] + ); + let mut export = update(&scoped, "Exported to global"); + export.project_id = None; + let exported = storage.save_knowledge(&export, NOW + 1).unwrap(); + storage + .save_knowledge( + &create( + Some(project.id), + KnowledgeKind::Context, + "Disposable", + "Removed with metadata", + ), + NOW, + ) + .unwrap(); + storage.remove_project_metadata(project.id).unwrap(); + let globals = storage.list_knowledge(&list(None)).unwrap().entries; + assert_eq!(globals.len(), 2); + assert!(globals.contains(&exported)); + assert_eq!( + std::fs::read_to_string(project.path.join("keep.txt")).unwrap(), + "repository content" + ); + let raw = Connection::open(path).unwrap(); + let remaining: i64 = raw + .query_row( + "SELECT COUNT(*) FROM knowledge_documents WHERE project_id IS NOT NULL", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(remaining, 0); +} + +#[test] +fn independent_connections_cannot_overwrite_the_same_revision() { + let directory = TempDir::new().unwrap(); + let path = directory.path().join("concurrent.db"); + let original = Storage::open_migrated(&path).unwrap(); + let entry = original + .save_knowledge(&create(None, KnowledgeKind::Prompt, "Race", "Initial"), NOW) + .unwrap(); + let first = Storage::open_migrated(&path).unwrap(); + let second = Storage::open_migrated(&path).unwrap(); + let barrier = Arc::new(Barrier::new(3)); + let mut handles = Vec::new(); + for (storage, text) in [(first, "First writer"), (second, "Second writer")] { + let barrier = Arc::clone(&barrier); + let request = update(&entry, text); + handles.push(thread::spawn(move || { + barrier.wait(); + storage.save_knowledge(&request, NOW + 1) + })); + } + barrier.wait(); + let outcomes: Vec<_> = handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect(); + assert_eq!(outcomes.iter().filter(|outcome| outcome.is_ok()).count(), 1); + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, Err(KnowledgeStorageError::Conflict))) + .count(), + 1 + ); + let winner = original + .list_knowledge(&list(None)) + .unwrap() + .entries + .remove(0); + assert_eq!(winner.revision, 2); + let stale_delete = KnowledgeDeleteRequest { + id: entry.id, + expected_revision: entry.revision, + }; + let conflict = original.delete_knowledge(&stale_delete).unwrap_err(); + assert_eq!(conflict.to_api_error().code, "knowledge_conflict"); + original + .delete_knowledge(&KnowledgeDeleteRequest { + id: winner.id, + expected_revision: winner.revision, + }) + .unwrap(); + let absent = original.delete_knowledge(&stale_delete).unwrap_err(); + assert_eq!(absent.to_api_error().code, "knowledge_not_found"); + assert!(matches!( + original.save_knowledge(&update(&winner, "Gone"), NOW), + Err(KnowledgeStorageError::NotFound) + )); +} + +#[test] +fn pagination_is_bounded_ordered_and_exclusive_without_duplicate_or_missing_rows() { + let directory = TempDir::new().unwrap(); + let storage = Storage::open_migrated(directory.path().join("pages.db")).unwrap(); + let mut expected = BTreeSet::new(); + for index in 0..57 { + let entry = storage + .save_knowledge( + &create( + None, + KnowledgeKind::Prompt, + &format!("Entry {index}"), + "A small body", + ), + NOW, + ) + .unwrap(); + expected.insert(entry.id); + } + let first = storage.list_knowledge(&list(None)).unwrap(); + assert_eq!(first.entries.len(), MAX_KNOWLEDGE_PAGE_ENTRIES); + assert_eq!( + first.next_cursor, + first.entries.last().map(|entry| entry.id) + ); + let second = storage + .list_knowledge(&KnowledgeListRequest { + cursor: first.next_cursor, + ..list(None) + }) + .unwrap(); + assert_eq!(second.entries.len(), 7); + assert_eq!(second.next_cursor, None); + let seen: Vec<_> = first + .entries + .into_iter() + .chain(second.entries) + .map(|entry| entry.id) + .collect(); + assert!(seen.windows(2).all(|pair| pair[0] < pair[1])); + assert_eq!(seen.into_iter().collect::>(), expected); +} + +#[test] +fn worst_case_json_escaping_fits_one_entry_and_pages_without_skipping() { + let directory = TempDir::new().unwrap(); + let storage = Storage::open_migrated(directory.path().join("escaped.db")).unwrap(); + let body = "\u{0001}".repeat(MAX_KNOWLEDGE_BODY_BYTES); + let mut expected = BTreeSet::new(); + for index in 0..3 { + expected.insert( + storage + .save_knowledge( + &create( + None, + KnowledgeKind::Context, + &format!("Large {index}"), + &body, + ), + NOW, + ) + .unwrap() + .id, + ); + } + let mut request = list(None); + let mut seen = BTreeSet::new(); + loop { + let page = storage.list_knowledge(&request).unwrap(); + assert_eq!(page.entries.len(), 1); + assert!(serde_json::to_vec(&page).unwrap().len() <= MAX_KNOWLEDGE_PAGE_BYTES); + assert_eq!(page.entries[0].body.as_str(), body); + assert!(seen.insert(page.entries[0].id)); + request.cursor = page.next_cursor; + if request.cursor.is_none() { + break; + } + } + assert_eq!(seen, expected); +} + +#[test] +fn exhausted_revisions_reject_updates_but_allow_checked_deletion() { + let directory = TempDir::new().unwrap(); + let path = directory.path().join("exhausted.db"); + let storage = Storage::open_migrated(&path).unwrap(); + let entry = storage + .save_knowledge( + &create(None, KnowledgeKind::Prompt, "Max revision", "Retained"), + NOW, + ) + .unwrap(); + let raw = Connection::open(path).unwrap(); + raw.execute( + "UPDATE knowledge_documents SET revision = ?1 WHERE id = ?2", + params![ + i64::try_from(MAX_KNOWLEDGE_REVISION).unwrap(), + entry.id.to_string() + ], + ) + .unwrap(); + let mut exhausted = entry; + exhausted.revision = MAX_KNOWLEDGE_REVISION; + assert!(matches!( + storage.save_knowledge(&update(&exhausted, "Never stored"), NOW + 1), + Err(KnowledgeStorageError::RevisionExhausted) + )); + assert_eq!( + storage.list_knowledge(&list(None)).unwrap().entries, + vec![exhausted.clone()] + ); + storage + .delete_knowledge(&KnowledgeDeleteRequest { + id: exhausted.id, + expected_revision: exhausted.revision, + }) + .unwrap(); +} + +#[test] +fn programmatic_input_constraints_and_corrupt_rows_never_echo_content() { + let directory = TempDir::new().unwrap(); + let path = directory.path().join("validation.db"); + let storage = Storage::open_migrated(&path).unwrap(); + let mut invalid = create(None, KnowledgeKind::Prompt, "Private title", "Private body"); + invalid.expected_revision = Some(1); + assert!(storage.save_knowledge(&invalid, NOW).is_err()); + invalid.expected_revision = None; + for timestamp in [-1, 9_007_199_254_740_992] { + assert!(matches!( + storage.save_knowledge(&invalid, timestamp), + Err(KnowledgeStorageError::InvalidTimestamp) + )); + } + let invalid_query = KnowledgeListRequest { + query: Some("private-query\0".into()), + ..list(None) + }; + let error = storage.list_knowledge(&invalid_query).unwrap_err(); + assert!(!format!("{error:?}").contains("private-query")); + let entry = storage.save_knowledge(&invalid, NOW).unwrap(); + let raw = Connection::open(path).unwrap(); + raw.execute_batch("PRAGMA ignore_check_constraints = ON") + .unwrap(); + raw.execute( + "UPDATE knowledge_documents SET body = ?1 WHERE id = ?2", + params!["PRIVATE_CORRUPT_CONTENT\0", entry.id.to_string()], + ) + .unwrap(); + let error = storage.list_knowledge(&list(None)).unwrap_err(); + assert!(matches!(error, KnowledgeStorageError::CorruptData)); + assert!( + !format!("{error:?} {}", error.to_api_error().message).contains("PRIVATE_CORRUPT_CONTENT") + ); +} + +#[test] +fn version_three_upgrade_preserves_metadata_and_checks_new_schema_objects() { + let directory = TempDir::new().unwrap(); + let path = directory.path().join("v3.db"); + let project = common::project("Existing", directory.path().join("existing")); + { + let raw = Connection::open(&path).unwrap(); + raw.execute_batch(include_str!("../migrations/0001_initial.sql")) + .unwrap(); + raw.execute_batch(include_str!("../migrations/0002_worktree_dirty_state.sql")) + .unwrap(); + raw.execute_batch(include_str!("../migrations/0003_recovery_metadata.sql")) + .unwrap(); + raw.execute_batch("CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL DEFAULT '2026-09-05'); + INSERT INTO schema_migrations (version, name) VALUES (1, 'initial'), (2, 'worktree_dirty_state'), (3, 'recovery_metadata');").unwrap(); + raw.execute( + "INSERT INTO projects (id,name,path,created_at,last_opened_at) VALUES (?1,?2,?3,?4,?4)", + params![ + project.id.to_string(), + project.name, + project.path.to_str().unwrap(), + common::CREATED_AT_MS + ], + ) + .unwrap(); + } + let storage = Storage::open_migrated(&path).unwrap(); + assert_eq!(storage.schema_version().unwrap(), LATEST_SCHEMA_VERSION); + assert_eq!(storage.get_project(project.id).unwrap(), Some(project)); + storage.migrate().unwrap(); + storage + .save_knowledge( + &create(None, KnowledgeKind::Prompt, "After upgrade", "New data"), + NOW, + ) + .unwrap(); + let raw = Connection::open(&path).unwrap(); + let indexes: i64 = raw.query_row("SELECT COUNT(*) FROM sqlite_schema WHERE type='index' AND name IN ('knowledge_by_scope_id','knowledge_by_scope_kind_id')", [], |row| row.get(0)).unwrap(); + assert_eq!(indexes, 2); + raw.execute_batch("DROP INDEX knowledge_by_scope_id") + .unwrap(); + assert!(storage.migrate().is_err()); +} diff --git a/docs/adr/0005-local-knowledge.md b/docs/adr/0005-local-knowledge.md index abdea73..d6f2349 100644 --- a/docs/adr/0005-local-knowledge.md +++ b/docs/adr/0005-local-knowledge.md @@ -1,6 +1,7 @@ # ADR 0005: Local prompts, reusable context, and organization -Status: proposed; shared contract and migration registration await S2 agreement. +Status: accepted for the first prompt/context increment after S2 agreement. +Organization and filesystem details remain integration dependencies. ## Context diff --git a/docs/codex/xirp-context-report.md b/docs/codex/xirp-context-report.md index 2fe3b56..4651d7c 100644 --- a/docs/codex/xirp-context-report.md +++ b/docs/codex/xirp-context-report.md @@ -4,34 +4,88 @@ Branch: `feat/xirp-local-workflows`. Worktree: `/Users/eguimacs/cli-master-xirp`. Base: `0ac8dd7`, fetched from `origin/refactor/canvas-only-shell` on 2026-09-05. -## Scope and evidence +The full user request remains open. Source/acceptance inventory: +[xirp-local-parity.md](../xirp-local-parity.md). Architecture: +[ADR 0005](../adr/0005-local-knowledge.md). -The source/acceptance inventory is [xirp-local-parity.md](../xirp-local-parity.md). -The architecture proposal is [ADR 0005](../adr/0005-local-knowledge.md). -The full user request remains open; documentation and isolated components are -intermediate deliverables. +## Coordination and ownership -## Coordination +S2 approved the first knowledge contract and additive shared registrations in +[xirp-coordination-reply.md](xirp-coordination-reply.md), reserving migration +`0004_knowledge_documents.sql`. S2 retains organization/workflow, runtime, +worktrees, file service and conversation capabilities. S1 retains canvas and +composer integration; the request is in [xirp-integration-request.md](xirp-integration-request.md). -A concrete proposal was placed in `docs/codex/xirp-integration-request.md` in -S1, S2 and S3 worktrees. S2 acknowledgment is pending for shared contracts, -module registrations, migration allocation and dispatch. S1 acknowledgment is -pending for canvas mounting and composer insertion. No shared code has been -changed by S3 at this point. +The active daemon dispatch is `server.rs`. Knowledge handlers reuse its +existing storage connection and generic request path, with no session/process +operation. The additive `methods.ts`/`domain.ts` mirrors preserve existing +`client.ts`/`types.ts`/`schema.ts`. Merge S2's `worktree.list` mirror entries by +name, rather than replacing whole files. -Baseline drift reported to owners: active daemon dispatch is `server.rs`; -`client.rs` is uncompiled. Frontend currently has `types.ts`/`schema.ts` rather -than the mirrors named in AGENTS. Runtime registry/adapters need consolidation -by S2; S3 will not introduce another registry or Tauri client. +## Implemented first vertical increment -## Work in progress +- `knowledge.list`: nullable project scope, optional kind, literal title/body + query, exclusive UUIDv7 cursor; `{entries,nextCursor}`. Global query returns + globals; project query returns its entries plus globals. Unknown projects + fail. Pages cap at 50 rows and 512 KiB of serialized JSON. +- `knowledge.save`: UUIDv7, prompt/context kind, title (256 UTF-8 bytes), body + (64 KiB), project scope, revision and epoch-ms timestamps. Create omits + id/expectedRevision; update requires both. Revision conflicts are atomic. +- `knowledge.delete`: id plus expectedRevision. Stale requests preserve data. +- `knowledge_documents` in existing SQLite, additive v3→v4 migration, schema + verification and scoped indexes. Project metadata removal cascades its + knowledge rows; it never deletes project files. Archive remains separate. +- `knowledge_conflict`, `knowledge_not_found`, `project_not_found`, + `knowledge_revision_exhausted`, `knowledge_corrupt_data` are stable errors. + Text/queries are excluded from Debug and request error details. +- IpcClient methods `listKnowledge`, `saveKnowledge`, `deleteKnowledge` use + validated TypeScript mirrors and a shared Rust/TypeScript JSON fixture. -- Pure validated knowledge types and contract tests (new module only). -- Isolated saved prompt/context editor/picker with typed callbacks and tests. -- Persisted handlers and integration follow the agreed shared boundaries. +`knowledge.updated` is deliberately not advertised: compiled baseline supports +session-specific streams, and general metadata event transport remains an S2 +integration dependency. Clients refresh after mutations and can explicitly +refresh; revision checks protect concurrent edits. Search uses SQLite ASCII +case folding; Unicode text is preserved but non-ASCII case folding is not +claimed. Pagination is per-request consistent, not a multi-request snapshot; +refresh observes inserts/edits made during browsing. -## Commits and checks +## Verification executed on macOS -Initial documentation commit: source review completed; `git diff --check`. -Implementation tests and runtime/platform evidence will be recorded here as -executed. No Linux/macOS runtime parity is claimed yet. +Use `CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0` +for the Rust commands below; debug information was reduced because the host +had limited free disk space. + +- `cargo test -p cli-master-core -p cli-master-storage --locked`: existing + suites and 9 new core contract tests passed. +- `cargo test -p cli-master-storage --test knowledge --locked`: 9 tests passed; + actual file-backed databases, competing connections, CAS races, reopen, + v3 migration, FK cleanup, scope moves, literal search, revision exhaustion, + corrupt content, 57-row pagination and worst-case JSON escaping. +- `cargo test -p cli-master-core --test knowledge_catalog --locked`: passed; + JSON catalog matches Rust methods/events exactly. +- `cargo test -p cli-master-daemon --locked`: 37 tests passed, including 2 new + real Unix-socket acceptance tests. Proves scoped CRUD, cross-client conflicts, + restart persistence, frame limits, safe errors and unchanged session state. +- `cargo clippy -p cli-master-core -p cli-master-storage --all-targets --locked + -- -D warnings`, plus the equivalent daemon command: passed. +- `cargo fmt --all -- --check`: passed. +- IPC TypeScript tests and typecheck passed. Full frontend run with Node 25 + needs `NODE_OPTIONS=--no-experimental-webstorage` to use jsdom storage; + CI uses Node 24. Isolated UI behavior tests are being finalized separately. + +Direct typed serde duplicate-field validation is not a promise that duplicate +keys are rejected after the daemon's intermediate JSON Value decode. +Linux CI, actual Tauri/canvas consumption and full parity remain unverified. + +## Commits / next integration + +- `569449f`: source inventory and integration proposal; pushed. +- Backend first vertical increment follows this report in the current commit. +- Isolated `KnowledgePanel` follows as its own UI increment. Props: + `{client,currentProject,onInsert,insertDisabledReason}`; insertion receives + `{sourceId,kind,title,body}` and must append to an editable composer draft. + Keep panel mounted if unsaved editor drafts must survive closing its surface. +- Next: bounded rules/skills discovery; consume S2 pin/archive/workflow and + initial-objective contracts; mount with S1; verify additional agent features + only against trustworthy native sources. Portal/MCP remain explicit separate + dependencies; no connection is simulated. diff --git a/docs/codex/xirp-coordination-reply.md b/docs/codex/xirp-coordination-reply.md new file mode 100644 index 0000000..9d27ceb --- /dev/null +++ b/docs/codex/xirp-coordination-reply.md @@ -0,0 +1,44 @@ +# S2 runtime coordination — 2026-09-05 + +S2 is active in `/Users/eguimacs/cli-master-runtime`, branch +`feat/maestri-runtime`, baseline `0ac8dd7`. This reply is an agreed boundary, +not a claim that the proposed endpoints are implemented. + +- S3 owns `knowledge/` modules in core/storage/daemon and isolated UI modules. +- Reserve migration **0004_knowledge_documents.sql** for S3. S2 worktree + integration needs no new migration. S2 future migrations begin at 0005 after + integrating 0004; no missing migration may be published in a runnable branch. +- Approved first vertical contract: `knowledge.list/save/delete`, tagged + `kind: prompt|context`, UUIDv7, nullable project scope, title/body, + created/updated epoch-ms, positive integer revision. Updates and deletes + require expectedRevision; stale revisions return `knowledge_conflict`. + Create has no existing id/revision. Define bounded lengths in the Rust + validation types and keep list responses bounded (pagination if needed). +- `knowledge.updated` must be delivered through the existing daemon event + transport before its event path is reported complete. Do not advertise an + unimplemented event. Draft insertion stays explicit and is owned by S1/S3. +- S3 may make the small additive registration patch on its own branch: + core/storage/daemon lib.rs, migration registration, wire exports and + `knowledge.*` dispatch, protocol catalog and TypeScript mirrors. Publish + implementation and tests in the same coherent commit and report its hash; + S2 will review and integrate it. Preserve all runtime handlers and additions. +- S2 currently adds `worktree.list` with `{ projectId?: UUID }` and response + `{ worktrees: Worktree[] }`. Existing `session.create` payload stays intact; + new_worktree creates metadata/Git only, followed by explicit session.start. +- S2 retains process lifecycle, workspace/floor/canvas persistence, and + project/session organization/workflow contracts. S3 owns drafting and context + references; do not overload SessionStatus with workflow or attention. +- Planned S2 file API targets registered project/session/worktree ids, uses + bounded reads and optimistic conflict checks with atomic writes. Its final + path policy and DTOs require an ADR. Until published, S3 can implement + discovery of explicitly known rules/skills with a narrow bounded safe reader; + do not create a second generic editor/file service. + +Current baseline no longer contains the AGENTS-referenced methods.ts/domain.ts +mirrors: S2 will restore those as additive IPC contract artifacts while keeping +the existing client/types/schema files and all React code intact. Merge contract +entries by name, never replace either side's whole catalog. + +Integration evidence and published commits will be kept in +`docs/codex/maestri-runtime-report.md` on the S2 branch. S2 will inspect S3's +`docs/codex/xirp-context-report.md` and this request thread for follow-ups. diff --git a/docs/codex/xirp-integration-request.md b/docs/codex/xirp-integration-request.md index 0522dbf..63a2621 100644 --- a/docs/codex/xirp-integration-request.md +++ b/docs/codex/xirp-integration-request.md @@ -23,3 +23,24 @@ Will deliver isolated components with typed callbacks for saved prompts/context ## Responses Pending owner acknowledgment. + +### S3 acknowledgment and implementation details + +S2 acknowledgment received in xirp-coordination-reply.md. First implementation uses +knowledge.list/save/delete; list accepts optional projectId/kind/query/cursor, +returns {entries,nextCursor}; cursor is exclusive UUIDv7 ID ascending, literal +title/body search. Pages cap at 50 rows and 512 KiB serialized entries. Title +256 UTF-8 bytes, body 64 KiB. Prompt/context errors do not attach serde causes. +Shared additive TypeScript mirrors restore methods.ts/domain.ts; merge S2 +worktree.list entries by name. IpcClient gains listKnowledge/saveKnowledge/ +deleteKnowledge; existing generic Tauri request path is reused. + +The compiled daemon currently has only session-specific event streams; generic +metadata broadcasts are not wired. S3 will not advertise knowledge.updated +until S2's general event transport is available. Initial UI refreshes after +mutations and provides explicit refresh; conflict protection still covers +multiple clients. Please advise the general event transport integration point. + +S1: initial components will expose KnowledgePanel({client,currentProject, +onInsert,insertDisabledReason}); onInsert receives plain draft text and sourceId. +Please mount as canvas contextual panel/palette. No hidden terminal send. diff --git a/docs/xirp-local-parity.md b/docs/xirp-local-parity.md index 4e56b21..87f9bff 100644 --- a/docs/xirp-local-parity.md +++ b/docs/xirp-local-parity.md @@ -8,18 +8,18 @@ added to this implementation's scope. | ID | Requirement and source | Owner / integration | Evidence / remaining work | | --- | --- | --- | --- | -| X01 | Local project pin, rename, remove registration, non-Git folders, discover child repositories. [Projects](https://backstage.spotify.com/docs/xirp/projects) | S2 project base, S3 organization UI, S1 canvas | Baseline registers Git projects; pin/archive metadata, non-Git support and child discovery pending. Project archive is a user-requested extension. | +| X01 | Local project pin, rename, remove registration, non-Git folders, discover child repositories. [Projects](https://backstage.spotify.com/docs/xirp/projects) | S2 project base, S3 organization UI, S1 canvas | Baseline real daemon tests register both Git projects and plain folders; pin/archive metadata and child discovery remain pending. Project archive is a user-requested extension. | | X02 | Initial objective delivered to agent, attachments, checkout/worktree and agent-specific options. [Sessions](https://backstage.spotify.com/docs/xirp/sessions) | S2 execution/contracts, S3 draft/context selection, S1 composer | Goal delivery requires verified runtime path, not metadata only. Attachments/options pending runtime contract. | | X03 | Resume/fork, agent switch preserving supported history, linked shell. [Sessions](https://backstage.spotify.com/docs/xirp/sessions), [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S2 adapters/runtime, S1 canvas | Current adapter capabilities do not expose verified conversation IDs, fork or usage. Await trusted native contracts and runtime integration. Local context reuse is a separate feature. | | X04 | Working/idle/needs-input attention separate from process lifecycle. [Sessions](https://backstage.spotify.com/docs/xirp/sessions) | S2 event source, S3 indicators, S1 canvas | PTY silence is not proof of approval/input need. Explicit hooks or native signals required. | | X05 | Local preferences, shortcuts, supported native settings, diagnostics. [Settings](https://backstage.spotify.com/docs/xirp/settings) | S2 settings/runtime, S3 rules/skills, S1 UI | Existing diagnostics/custom-agent creation are baseline only; do not expose unsupported options. Credentials excluded from editors. | | X06 | Global/project rules and skills, including symlinked skill directories. [Projects](https://backstage.spotify.com/docs/xirp/projects), [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S3 discovery, S2 file boundary | Need verified location inventory and safe bounded reads with origins/scopes; docs do not enumerate complete formats or precedence. Symlink policy and editing depend on file service. | -| X07a | Saved global/project prompts, searchable picker and insertion. [Changelog v0.19.1](https://backstage.spotify.com/docs/xirp/changelog) | S3 knowledge, S2 shared registration, S1 canvas insertion | Isolated module implementation underway; persistence, socket, UI integration and cross-platform evidence pending. | +| X07a | Saved global/project prompts, searchable picker and insertion. [Changelog v0.19.1](https://backstage.spotify.com/docs/xirp/changelog) | S3 knowledge, S2 shared registration, S1 canvas insertion | Persisted CRUD and paginated search verified through real SQLite and daemon socket; isolated UI underway; canvas and Linux evidence pending. See S3 report. | | X07b | Session archive and workflow: backlog/in_progress/in_review/blocked/done. [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S2 organization metadata, S3 controls, S1 filtering | Workflow must not mutate lifecycle. Session pin is a user-requested extension. | | X07c | Tokens/spend per session/day. [Changelog v0.18.0](https://backstage.spotify.com/docs/xirp/changelog) | S2 verified native data, S3 presentation | Source documents feature existence, not a collector/format/pricing algorithm. Currently unavailable, not zero. No proxy or PTY-based estimates. | | X07d | PR review, Doctor, cleanup. [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S2 runtime/Git, S1 UI | Existing Git status/diagnostics/removal-token safety are baseline; review and expanded cleanup require their real contracts. | | X08 | Agent-specific options, including Cursor. [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S2 adapter registry, S1 UI | No flags invented from agent display name; support requires installed-version tests and authoritative CLI sources. | -| X09-local | Explicit reusable local context between sessions (user request). | S3 knowledge, S1 composer, S2 delivery | Local text records and explicit draft insertion planned. No transcript capture, autonomous summarization, or Portal connection implied. | +| X09-local | Explicit reusable local context between sessions (user request). | S3 knowledge, S1 composer, S2 delivery | Local text records persist and survive daemon restart; explicit draft insertion component is underway. No transcript capture, autonomous summarization, or Portal connection implied. | | X09-Portal | Shared workspaces, members, catalog links, resources, decisions/wiki. [Workspaces](https://backstage.spotify.com/docs/xirp/workspaces) | Separate authenticated connector | Portal-dependent; no account/API verification supplied. Not implemented or simulated. | | X10 | Workspace context via MCP, manual transcript sharing, configured external integrations. [Workspaces](https://backstage.spotify.com/docs/xirp/workspaces) | Separate authenticated connector + S2 transport | Requires real account, scopes/API and explicit outbound preview. No silent publishing, account linking or telemetry. | diff --git a/protocol/catalog.json b/protocol/catalog.json index fff6729..814cee3 100644 --- a/protocol/catalog.json +++ b/protocol/catalog.json @@ -29,7 +29,10 @@ "git.diff", "worktree.prepare_remove", "worktree.remove", - "diagnostics.get" + "diagnostics.get", + "knowledge.list", + "knowledge.save", + "knowledge.delete" ], "events": [ "project.updated", diff --git a/protocol/fixtures/knowledge-page.json b/protocol/fixtures/knowledge-page.json new file mode 100644 index 0000000..19443bb --- /dev/null +++ b/protocol/fixtures/knowledge-page.json @@ -0,0 +1,25 @@ +{ + "entries": [ + { + "id": "01990000-0000-7000-8000-000000000001", + "kind": "prompt", + "projectId": null, + "title": "Review the change", + "body": "Review correctness and failure paths.\n", + "revision": 1, + "createdAtMs": 1788600000000, + "updatedAtMs": 1788600000000 + }, + { + "id": "01990000-0000-7000-8000-000000000002", + "kind": "context", + "projectId": "01990000-0000-7000-8000-000000000010", + "title": "Project architecture", + "body": "The daemon owns session processes.\n", + "revision": 2, + "createdAtMs": 1788600000000, + "updatedAtMs": 1788600000001 + } + ], + "nextCursor": null +} From 72b4d4dd086a08d71d269005b1457ac8bef2a089 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 21:52:14 -0300 Subject: [PATCH 08/23] docs(runtime): record parity evidence and integration contracts --- docs/codex/maestri-runtime-report.md | 94 ++++++++++++++ docs/maestri-runtime-parity.md | 177 +++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 docs/codex/maestri-runtime-report.md create mode 100644 docs/maestri-runtime-parity.md diff --git a/docs/codex/maestri-runtime-report.md b/docs/codex/maestri-runtime-report.md new file mode 100644 index 0000000..5eec583 --- /dev/null +++ b/docs/codex/maestri-runtime-report.md @@ -0,0 +1,94 @@ +# Relatório de integração S2 — runtime Maestri + +Data: **2026-09-05**. Branch: **feat/maestri-runtime**. +Worktree: `/Users/eguimacs/cli-master-runtime`. +Baseline de criação: `0ac8dd7d49eefee16e5efbf389994f551bc584f5`, obtido de +`origin/refactor/canvas-only-shell`. Integração final pertence à S1 nessa branch. + +O objetivo completo permanece na [matriz de runtime](../maestri-runtime-parity.md). +Esta entrega resolve o primeiro caminho backend. Floors, landing, editor, +canvas durável, presets, continuidade nativa, comunicação, rotinas e ambientes +remotos permanecem em desenvolvimento. Integração desktop não foi presumida. + +## Commits disponíveis + +| Commit | Alteração | Evidência local | +| --- | --- | --- | +| `7693468` | Saga separa preparação de início; associação SQLite transacional; subdiretórios; compensação e cancelamento de tokens. | Suite session/storage executada; após ajustes finais, 29 testes de create/prepare/remove passaram. Clippy session/storage sem warnings. | +| `3c25a85` | Daemon liga new_worktree, snapshot/listagem, preparo/remoção e recovery à saga compartilhando SessionManager e Storage. | 118 testes core/daemon passaram, incluindo 9 fluxos novos pelo socket real; Clippy dos quatro pacotes sem warnings. | + +Ambos usam a identidade Git configurada `guicybercode`, sem trailers +`Co-authored-by`. Publicados em `origin/feat/maestri-runtime`. + +## Contratos prontos para integração + +| Método | Request → response | Comportamento observado | +| --- | --- | --- | +| `session.create` | `{ projectId, name, agentId, isolation: "current" | "new_worktree", relativeDirectory? } → Session` | Prepara metadata/Git sem PTY ou processo. Retorna unknown sem pid; subdiretório cadastrado e relativeDirectory preservados. | +| `session.start` | `{ sessionId } → Session` | Início explícito por SessionManager no cwd persistido. Revalida raiz/identidade Git e invalida remoção preparada. | +| `session.list` / `state.snapshot` | Contratos existentes | Sessões incluem branch/worktreeId/worktreePath; snapshot inclui worktrees duráveis. | +| `worktree.list` | **Novo:** `{ projectId?: UUID } → { worktrees: Worktree[] }` | Todas as worktrees ou filtro por projeto; estados parciais permanecem visíveis. | +| `worktree.prepare_remove` | `{ worktreeId } → ready + confirmationToken/expiresAtMs ou blocked + isDirty/blockers` | Inspeção real de Git e uso por sessões, inclusive cwd sem associação direta. | +| `worktree.remove` | `{ worktreeId, confirmationToken } → {}` | Reinspeção e confirmação vinculada ao estado; dirty/ignored/uso impedem exclusão. | +| `session.delete` | `{ sessionId } → {}` | Remove apenas metadados parados; preserva diretório e branch. Saída sem assinante também pode ser excluída. | + +Rust wire, `protocol/catalog.json`, `ipc/methods.ts` e `ipc/domain.ts` +foram sincronizados. Os dois últimos são artefatos aditivos: o baseline havia +removido esses caminhos citados em AGENTS. Nenhum componente React, estado de +canvas, estilo ou bridge Tauri foi alterado. + +Erros relevantes: `worktree_confirmation_invalid`, `worktree_in_use`, +`worktree_dirty`, `worktree_not_active`, `worktree_identity_changed`, +`session_directory_unavailable`, `session_still_running`, `git_unavailable`. +As respostas têm mensagens/ações; não transportar env ou argumentos em logs. + +**Refresh explícito:** este incremento não implementa emissão global de +`worktree.updated`/`worktree.removed`. S1 deve reler snapshot/list após mutação +ou reconexão. A existência dos nomes no catálogo não prova entrega de eventos. + +## Verificação executada e limites + +PR de integração: [#44](https://github.com/guicybercode/Jig/pull/44), em draft. +CI e Packaging Linux/macOS iniciados; resultados ainda pendentes. + +Host local: macOS. Passaram: + +- `CARGO_INCREMENTAL=0 cargo test -p cli-master-core -p cli-master-daemon --locked` + — 118 testes, dos quais 9 novos em `worktree_ipc.rs`. +- Testes session/storage e 29 testes finais de sagas, conforme primeiro commit. +- `CARGO_INCREMENTAL=0 cargo clippy -p cli-master-core -p cli-master-storage -p cli-master-session -p cli-master-daemon --all-targets --locked -- -D warnings`. +- `cargo fmt --all -- --check`, `git diff --check` e `bash scripts/check-versions.sh`. +- Rustdoc dos quatro pacotes com `RUSTDOCFLAGS=-Dwarnings`. +- Typecheck frontend e 8 testes dos contratos/client IPC existentes. + +Os casos socket exercitam start/stop/restart concorrentes, troca de raiz por +symlink entre create/start, token após edição externa, sessão sem vínculo +usando o checkout, saída espontânea e restart com PID canário de outro manager. +A matriz CI Linux/macOS e o pacote desktop ainda exigem resultado externo; +não foram chamados de aprovados. O editor/canvas S1 ainda precisa consumir e +verificar estes contratos. Os IDs M14/M34/M35 continuam parciais no escopo total. + +## Acordo com XIRP/S3 + +Resposta publicada em +`/Users/eguimacs/cli-master-xirp/docs/codex/xirp-coordination-reply.md`: + +- S3 possui novos módulos knowledge e componentes isolados. +- **0004_knowledge_documents.sql reservada para S3**; worktrees não criam migração. +- Acordados `knowledge.list/save/delete`, kind prompt/context, UUIDv7, + escopo opcional de projeto, revisão inteira; update/delete exigem revisão, + conflito retorna `knowledge_conflict`. Ainda não são contratos publicados. +- S3 pode publicar registro aditivo de lib.rs/migração/wire/mirrors/dispatch na + própria branch junto de implementação/testes; S2 revisa e integra o commit. +- `knowledge.updated` só pode ser anunciado como funcional com emissão real. +- S2 possui file service, workspace/floor/canvas e organização/workflow. + S3 possui composição de rascunhos/contexto; entrega usa SessionManager/adapters. + +## Próxima etapa + +Implementar serviço local `file.list/read/write` com alvos registrados, +leitura limitada, caminhos Unix preservados, escrita atômica e revisão de +conteúdo, documentando limites de concorrência externa. S3 reutiliza a leitura +segura. Em seguida, workspace/floor e canvas durável com revisão e importação +explícita do localStorage pela S1. A matriz mantém os incrementos posteriores; +esta ordem não reduz o objetivo aos serviços de arquivos. diff --git a/docs/maestri-runtime-parity.md b/docs/maestri-runtime-parity.md new file mode 100644 index 0000000..472ea02 --- /dev/null +++ b/docs/maestri-runtime-parity.md @@ -0,0 +1,177 @@ +# Evidências e sequência de integração do runtime Maestri + +Auditoria inicial de S2 em **2026-09-05**, no baseline +`0ac8dd7d49eefee16e5efbf389994f551bc584f5`, branch +`feat/maestri-runtime`, worktree `/Users/eguimacs/cli-master-runtime`. +Mudanças em andamento somente promovem um estado após registrar o commit e +a execução que o comprova no [relatório de integração](codex/maestri-runtime-report.md). + +Este documento complementa a matriz central `docs/maestri-xirp-parity.md`, +mantida por S1. Os IDs M01–M48 e X01–X10 continuam sendo os dela; as descrições +completas, fontes por recurso e critérios visuais não são duplicados aqui. +Na auditoria, a matriz e `docs/codex/parallel-goals.md` foram lidos na worktree +principal, `/Users/eguimacs/cli-master`, onde ainda não estavam no baseline +de S2. Publicar este recorte não substitui integrar esses documentos centrais. + +## Fontes e interpretação + +O [changelog oficial](https://www.themaestri.app/en/changelog), consultado em +2026-09-05, chega a 0.45.3, de 2026-09-04. Ele amplia o escopo até controle +remoto de rotinas/floors, sessões persistentes e retomada de conversas. +As páginas de [arquivos](https://www.themaestri.app/en/docs/file-tree), +[workspaces](https://www.themaestri.app/en/docs/workspaces), +[floors](https://www.themaestri.app/en/docs/floors) e +[notas](https://www.themaestri.app/en/docs/notes) também foram consultadas. +As opções de implementação e os testes abaixo são propostas para Jig. +Não se infere equivalência Linux a partir de links de download no site. + +### Como ler a evidência + +- **A:** implementação funcional não encontrada no baseline examinado. +- **P:** infraestrutura parcial ou integração incompleta. +- **V:** fluxo comprovado por teste executado e evidência identificada. +- **L:** limite de plataforma ainda exige resultado explícito. +- **S1:** interface, Tauri e integração visual; **S2:** runtime/contratos; + **S3:** knowledge/workflows locais. Uma dependência de S1/S3 permanece aberta. + +Nenhuma linha recebe V nesta auditoria estática. Teste existente sem execução +é evidência de cobertura pretendida. Compilar um tipo, anunciar um método, +passar teste direto da saga ou renderizar uma UI com IPC mockado não comprova +o caminho desktop → socket → domínio → efeito real. + +| Evidência | Arquivos no baseline | Limite observado | +| --- | --- | --- | +| R1 | `crates/daemon/src/sessions.rs`, `server.rs` | PTY e `current` estão ligados ao daemon; `new_worktree` retorna `worktree_sessions_unavailable`; snapshot usa `worktrees: Vec::new()`. | +| R2 | `crates/session/src/{saga,create,remove,recover,token}.rs`, `tests/{create_saga,remove_saga,recovery}.rs` | Saga cria e inicia em uma operação interna; persistência e remoção segura existem, mas os métodos públicos de worktree não têm dispatch. | +| R3 | `crates/core/src/wire/{method,request,response,event_name}.rs`, `protocol/catalog.json` | Catálogo existente não contém files/workspace/floor/canvas/knowledge/routine/environment. Eventos anunciados precisam de auditoria de emissão. | +| R4 | `crates/storage/migrations/0001_initial.sql` até `0003_recovery_metadata.sql`, `src/{migrate,settings,recovery}.rs` | SQLite guarda projetos/agentes/sessões/worktrees/settings; não há documento canvas ou modelos posteriores. | +| R5 | `crates/daemon/src/projects.rs`, `crates/daemon/tests/daemon_ipc.rs` | Cadastro aceita pasta não Git e subdiretório; teste `project_registration_accepts_a_plain_folder` existe. Organização e importação de filhos não existem. | +| R6 | `crates/daemon/src/git_inspection.rs`, `crates/daemon/tests/git_inspection.rs`, `crates/git/src` | Inspeção status/diff por alvo registrado; não implementa o conjunto de ações de escrita Git. | +| R7 | `crates/agents/src/{builtins,registry}.rs`, `crates/daemon/src/sessions.rs` | Registry e seed próprio do daemon divergem; Gemini no adapter não implica preset executável no produto. | +| R8 | `crates/session/src/{manager,replay,runtime}.rs`, `crates/storage/src/recovery.rs` | Handles e replay são memória do daemon; reinício reconcilia metadados, sem retomar conversa nativa/tmux. | +| R9 | `apps/desktop/src/app/features/canvas/{canvas-state,useCanvasState}.ts` | Documento local contém notas/terminais/conexões; não existe serviço durável no daemon. Frontend é somente leitura nesta sessão. | +| R10 | `crates/daemon/tests/{daemon_ipc,git_inspection,event_stream}.rs`, `crates/e2e/tests/acceptance.rs` | Existem casos reais de IPC e testes da saga; o caso de duas sessões isoladas usa saga diretamente. Não prova isolamento pela API pública. | +| R11 | `docs/adr/0001-session-ownership.md` até `0004-git-worktree-safety.md`, `AGENTS.md` | Ownership, epoch-ms, revisão de contrato e token por estado são invariantes. Floors compartilhados/hook/arquivo precisam de decisões adicionais. | +| R12 | `.github/workflows/ci.yml`, `docs/{PACKAGING,backup-and-recovery,KNOWN_ISSUES}.md` | Matriz Linux/macOS e procedimentos existem; auditoria não executou CI/pacote nem restauração visual. | + +## Evidência posterior ao baseline + +Em 2026-09-05, `7693468` e `3c25a85` na branch `feat/maestri-runtime` +ligam preparação/start, snapshot/listagem e remoção segura ao socket real. +Os 118 testes core/daemon passaram no macOS, incluindo 9 novos de worktree; +sagas/storage, Clippy e contratos frontend também foram verificados conforme +[relatório S2](codex/maestri-runtime-report.md). Isso comprova o incremento de +runtime M14/M34/M35; **os IDs completos continuam P**, pois floors/landing e +integração desktop não estão concluídos. Linux/CI/pacote seguem sem resultado. +Nenhum recurso posterior ganha V com essa execução. + +## Recorte de runtime por ID central + +A coluna de fonte remete à capacidade de mesmo ID na matriz central. Linhas +agrupadas mantêm todos os IDs para auditoria sem redefinir seu escopo. + +| IDs / fonte central | Estado no baseline / evidência | Próximo efeito de runtime comprovável | Dependência de integração | +| --- | --- | --- | --- | +| M01, M02, M03, M10, M46 — canvas | P: R9 | Persistir composição versionada, identidade e relações sem guardar PTY. | S1 implementa interação/visual; S2 oferece armazenamento sem determinar gestos. | +| M04 — workspaces | P: R4/R5 | Diretório/identidade/ordenação por workspace após restart; herança de cwd explícita. | S1 rail/edição; decisão de identidade workspace versus project antes de migrar. | +| M05 — instruções/importação | A: R3 | Importar pacote revisado sem sobrescrever arquivos externos; revisão de conteúdo em escrita. | S3 regras, S2 serviço de arquivo/importação, S1 preview. | +| M06 — paleta/busca | P: R9 | Consultas de arquivos e documentos limitadas ao escopo autorizado. | S1 busca/atalhos; S3 índices de contexto. | +| M07 — notas | P: R9, persistência A: R4 | Markdown real, leitura após restart, escrita atômica e conflito externo preservado. | S1 edita/preview; distinguir nota gerenciada de referência a arquivo externo. | +| M08 — acesso a notas | A: R3 | Resolução autorizada de grafo com leitura limitada, atualização e revogação. | Conexão visual S1 não concede autorização sozinha. | +| M09 — fichários | A: R3/R4 | Mover página entre agrupamentos sem duplicar conteúdo/identidade. | S1 páginas; depende de notas e grafo duráveis. | +| M11, M16 — preferências/bloqueio | P: R4/R9 | Preferências por escopo e revisão; bloqueio impede escrita também pela API de agente. | S1 aparência/atalhos; settings genérico não prova painel funcional. | +| M12 — partituras | A: R3/R4 | Exportação própria versionada e importação revisável, referências remapeadas, sem dados de execução. | S1 seleção/preview; não anunciar leitura de `.maestri` sem fixture real. | +| M13 — mídia | A: R3 | Arquivos referenciados com ownership e leitura limitada; remover nó preserva fonte externa. | S1/Tauri preview/clipboard Linux e macOS. | +| M14 — terminais/presets | P: R1/R7 | Usar definições reais do registry em create/start e editar argv sem divergência de seed. | S1 seletor; testar cada preset anunciado. | +| M15 — roles | A: R3/R7 | Provisionamento por adaptador com revisão e origem, sem substituir arquivos inseguros/existentes. | S3 descoberta e conteúdo; S1 seleção. | +| M17 — atenção | P lifecycle: R8 | Registrar sinais verificáveis de espera/atenção separados de status do processo. | S1 notificações/navegação; silêncio PTY não é evidência de aprovação. | +| M18, M19 — entrada/composer | P PTY: R1/R8; demais A | Draft durável e entrega única de texto/anexos no ambiente correto. | S1 IME/seleção/teclas; S3 prompts inserem draft, não fazem envio implícito. | +| M20 — conexões | P visual: R9; domínio A | Grafo durável com revisões e permissões independentes de estilo. | S1 geometria e navegação entre floors. | +| M21 — comunicação | A: R3/R4 | Pedido/resposta entre duas sessões com correlação, prazo, cancelamento e autorização atual. | Requer cliente CLI local e alvo verificável; não proxyar tráfego dos vendors. | +| M22 — Maestro | A: R3 | Recrutamento/dispensa autorizado reutiliza SessionManager e modelos de workspace/floor. | S1 gerência; acesso comum não implica privilégio gerencial. | +| M23, M24 — rotinas | A: R3/R4 | Agenda/histórico duráveis; skip, execução única e restart não duplicam disparos. | Hooks pelo dono da execução; S1 UI/histórico; S3 workflow não é scheduler. | +| M25 — persistência de processo | P: R8 | Distinguir cliente reconectado de daemon novo e attachment tmux verificado. | S1 mostrar capacidade real; persistência após crash exige novo ADR. | +| M26 — conversa | P metadados: R8; resume A | Adapter persiste e valida identidade nativa; retoma a conversa escolhida. | Nunca reconstruir conversa a partir do PID ou de replay PTY. | +| M27, M28 — ambientes | A: R3/R7 | Resolver execução, cwd, arquivo e provisionamento no mesmo host; reconexão não duplica agente. | S1 ambiente/override; SSH/Docker/custom usam argv e transporte definido em ADR. | +| M29 — arquivo | A: R3 | Listar/criar/mover/remover sob raiz registrada, tratar nomes Unix e links sem escapar do escopo. | S1 árvore; S3 reutiliza segurança de I/O. | +| M30 — editor | A: R3 | Read/write em disco com limite de tamanho e revisão esperada; mudança externa gera conflito. | S1 buffers/edição; proteger conteúdo não salvo. | +| M31 — busca/tabs | A: R3/R4 | Busca cancelável com paginação estável; persistência de tabs/defaults. | S1 abre arquivo/linha; definir indexação e limites após file service. | +| M32 — Git local | P: R6 | Stage/unstage/commit e descarte revisado no repositório derivado do alvo. | S1 diff real; descarte precisa prova de estado, não um booleano force. | +| M33 — Git remoto/histórico | A: R6 | Git argv real com exclusão mútua, cancelamento e erros acionáveis. | S1 opções/history; credenciais continuam com Git do usuário. | +| M34 — floors | P infraestrutura: R2 | Primeiro session.create/start isolado pelo socket; depois floor com várias sessões e cwd compartilhado correto. | S1 canvas por floor; worktree por sessão não equivale a floor. | +| M35 — landing/remoção | P remoção: R2; landing A | Ligar preparo/token/remove e depois preview de landing vinculado a refs e estado atuais. | S1 confirmação; manter branch/diretório diante de conflito. | +| M36 — hooks | A: R3/R4 | Comandos ordenados com contexto, prazo, cancelamento e histórico via SessionManager. | Novo ADR de jobs; setup/teardown não abrem executor paralelo. | +| M37, M38, M39 — portais | A: R3 | Domínio de autorização e alvos de automação sobre bridge real, com revogação. | S1/Tauri webview e suporte Linux/macOS; adapter deve anunciar capacidades observadas. | +| M40 — dispositivos | A/L: R3/R12 | Sessão de dispositivo e operações verificadas no SDK local. | Android nos dois hosts; iOS local exige macOS/Xcode. Não chamar indisponibilidade de paridade. | +| M41, M42 — Wire | A: R3 | Transporte remoto autenticado reutiliza domínio; pareamento/revogação e feed recuperável. | Novo ADR e S1 cliente; socket local não prova protocolo Wire compatível. | +| M43 — Ombro | A/L: R3 | Decisão explícita sobre inferência local sem transformar o coordenador em proxy de agente. | Alternativa Linux e viabilidade medidas; badge de status não equivale a resumo. | +| M44, M45 — recuperação/plataforma | P: R4/R12 | Restaurar composição, notas e anexos reais com manifest/revisão; recuperar corrupção sem apagar dados. | S1 preview/restore, bridge de suspensão por plataforma. | +| M47 — agentes adicionais | P: R7 | Capacidade por versão/adaptador, envio/provisionamento/resume testados individualmente. | S1 exibe somente presets detectados e comportamentos suportados. | +| M48 — atualização | P build: R12 | Pacote atualizado preserva DB, ownership do daemon e identidade de conversa. | S1/Tauri/release e smoke Linux/macOS. | +| X01 — projetos/organização | P: R5 | Preservar pastas não Git e descoberta explícita; pin/importação persistentes. | S3 organização/descoberta, S1 navegação. | +| X02 — sessões gerais | P PTY: R1 | Objetivo/anexos/opções com destino verificável; sessão geral sem projeto fictício. | ADR de identidade/scope e S1 composer; S3 draft/contexto. | +| X03 — continuidade/fork | P: R8 | Fork/resume nativo quando suportado; shell vinculado com lifecycle independente. | S2 adapters/floor; S1 escolha explícita. | +| X04 — atividade/apresentação | P: R8/R9 | Atenção separada de processo/workflow, sem encerrar ao ocultar nó. | S1 grade/minimapa/busca. | +| X05 — preferências/diagnóstico | P: R4/R7/R12 | Preferências revisadas e diagnóstico sanitizado do fluxo real. | S3 regras; S1 UI; arquivos de credenciais não entram no editor. | +| X06 — regras/skills | A: R3 | Descoberta conhecida e leitura segura com origem/escopo; edição usa file service único. | S3 knowledge, S2 contratos compartilhados. | +| X07 — workflow/arquivo/prompts | A/P: R1/R6 | Metadados de workflow separados do processo; prompts/contexto persistem com revisão. | S3 responsável; S2 cleanup e métricas nativas, nunca custo inferido de PTY. | +| X08 — opções dos agentes | P: R7 | Registry consolidado e controles somente quando adapter comprova suporte. | S1 presets e opções. | +| X09 — contexto | A: R3/R4 | Knowledge global/projeto com origem, revisão, referências e busca/exportação. | S3 serviço/componentes; S2 migração/IPC; S1 montagem no canvas. | +| X10 — MCP/compartilhamento | A: R3 | Expor apenas contexto local autorizado; envio explícito fora da máquina em contrato próprio. | S3 ferramentas; S2 autorização/transporte; conector Portal continua opcional. | + +## Sequência de incrementos verticais + +1. **M14/M34/M35, base de X03:** separar preparação de criação e início na + saga; ligar `new_worktree`, snapshot/listagem e remoção no socket; executar + processo curto somente após `session.start`, incluindo subdiretórios, + concorrência, compensação, restart e token invalidado por edição externa. + S1 consome as respostas e comprova o fluxo no desktop. +2. **M29/M30 e base de M07/X06:** serviço de arquivos local compartilhado, + inicialmente list/read/write em alvo registrado e revisão de conteúdo. + Editor S1 lê, modifica e salva arquivo real; um segundo escritor produz + conflito visível, sem sobrescrita. S3 reutiliza o leitor seguro para regras. +3. **M04/M07/M20/M44 e base de X09:** workspace/documento canvas revisados, + nota Markdown em arquivo real e referências estáveis. S1 importa o documento + local existente uma única vez, confirma persistência e só depois muda a + fonte principal. Restart/reconnect recuperam nota, composição e vínculos. + S3 entrega knowledge em migração reservada, com refresh/eventos no mesmo IPC. +4. **M14/M15/M17/M19/M21/M25/M26/M47/X02/X03/X08:** registry único, + capacidades por adapter, roles, draft/anexo e resume. Em seguida autorização + de grafo e cliente CLI: duas sessões reais trocam pedido/resposta; revogação, + timeout e restart têm resultado explícito. Não confundir resume de conversa + com reanexar processo tmux; ambos precisam de aceitação própria. +5. **M32/M33/M34/M35/M36:** Git de escrita, floor compartilhado, landing e + hooks. Provar várias sessões no mesmo floor e terminais da ground floor + preservados; hook é um job sob SessionManager, com política documentada + para erro/timeout/teardown. Confirmar destruição com estado atualizado. +6. **M09/M11/M12/M23/M24/M44/M45:** fichários, preferências, exportação própria, + rotinas duráveis e snapshots restauráveis. Testar duas gravações concorrentes, + interrupção durante importação/backup e scheduler reiniciado antes/depois + de registrar disparo. Histórico registra skipped/failed, não conclusão falsa. +7. **M27/M28/M37–M42:** ambientes reais antes da automação remota: SSH/Docker/ + custom, arquivo/anexo no ambiente alvo e persistência tmux. Depois bridge + de portais/dispositivos e transporte remoto autorizado, cada um em ADR e + incremento próprio com capacidades por plataforma. +8. **M43/M45/M48 e aceitação transversal:** decisão sobre assistente local, + suspensão e atualização. Provar pacotes em Linux/macOS; reunir evidência + S1/S3 para os IDs ainda parciais. Prioridade posterior não remove requisito. + +Cada passo contém vários commits pequenos. Contrato só entra no catálogo +junto de handler utilizável e teste pelo socket; a sequência não autoriza +publicar stubs para reservar nomes. + +## Decisões necessárias antes de ampliar fronteiras + +| Tema do ADR futuro | Decisão que precisa ficar concreta | Alternativas e consequência | +| --- | --- | --- | +| Arquivos e revisão de documentos | Raiz/identidade autorizadas, encoding de nomes Unix, limits, revisões de conteúdo e escrita atômica. | Path relativo UTF-8 é menor, mas exclui nomes válidos; ID opaco/bytes preserva identidade. Não reutilizar `GitRelativePath` como path genérico. Definir symlinks e proteção contra troca de diretório durante a operação. | +| Workspace, floor e canvas | Project continua raiz registrada; workspace/floor são identidades próprias, com revisão de documento e vínculo de várias sessões ao isolamento. | Documento único facilita evolução S1, mas pode conflitar inteiro; tabelas por nó reduzem conflito e aumentam migrações. Escolha deve preservar migração do canvas existente e não duplicar truth em localStorage. | +| Notas e anexos | Ownership de arquivo gerenciado/externo, journal para SQLite+filesystem e recuperação. | SQLite de metadados + Markdown real preserva interoperabilidade, mas exige compensação/reconciliação; não esconder nota só em JSON de canvas. | +| Jobs, hooks e rotinas | CommandSpec/SessionManager como execução única, modelo durável de run, idempotência, cancellation e teardown. | Reusar sessão como job simplifica ownership; job explícito separa histórico/TTY. Ambos exigem um único executor e distinção prompt/programa. | +| Continuidade e ambientes | Resume por adapter versus attachment tmux, identidade de processo por ambiente e segredo/transporte. | Nova conversa não equivale a resume; PID antigo nunca prova ownership. Persistência opcional deve ter fallback explícito sem afirmar retomada. | +| Grafo/CLI, portais e Wire | Principais, autorização/revogação por recurso, correlação e transporte remoto separado do IPC local. | Grafo visual pode referenciar relações funcionais, mas estilos não concedem acesso. Não anunciar compatibilidade Wire sem contrato externo testado. | +| Inferência local e plataformas | Preservar coordenador local-first, alternativa Linux e política de capacidade por hardware. | Integrar inferência altera fronteira de produto; requer decisão registrada antes do código. | + +Os números de migração e ADR são alocados por S2 no momento de integrar, +considerando commits S3. A matriz central continua responsável por paridade +global; este recorte não conclui o goal de backend. From a757f0598aabd7debd10e09a4a29c4da16a92114 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 21:53:44 -0300 Subject: [PATCH 09/23] feat(knowledge): add reusable prompt and context library panel --- .../knowledge/KnowledgeLibrary.test.tsx | 263 ++++++++++++++++ .../features/knowledge/KnowledgeLibrary.tsx | 215 ++++++++++++++ .../app/features/knowledge/KnowledgePanel.tsx | 23 ++ .../src/app/features/knowledge/index.ts | 14 + .../features/knowledge/knowledge-library.css | 188 ++++++++++++ .../app/features/knowledge/knowledge-types.ts | 54 ++++ .../features/knowledge/useKnowledgeLibrary.ts | 281 ++++++++++++++++++ .../artifacts/xirp/knowledge-desktop.png | Bin 0 -> 76626 bytes .../xirp/knowledge-narrow-editor.png | Bin 0 -> 45707 bytes docs/codex/xirp-context-report.md | 11 +- docs/xirp-local-parity.md | 4 +- 11 files changed, 1048 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/app/features/knowledge/KnowledgeLibrary.test.tsx create mode 100644 apps/desktop/src/app/features/knowledge/KnowledgeLibrary.tsx create mode 100644 apps/desktop/src/app/features/knowledge/KnowledgePanel.tsx create mode 100644 apps/desktop/src/app/features/knowledge/index.ts create mode 100644 apps/desktop/src/app/features/knowledge/knowledge-library.css create mode 100644 apps/desktop/src/app/features/knowledge/knowledge-types.ts create mode 100644 apps/desktop/src/app/features/knowledge/useKnowledgeLibrary.ts create mode 100644 docs/codex/artifacts/xirp/knowledge-desktop.png create mode 100644 docs/codex/artifacts/xirp/knowledge-narrow-editor.png diff --git a/apps/desktop/src/app/features/knowledge/KnowledgeLibrary.test.tsx b/apps/desktop/src/app/features/knowledge/KnowledgeLibrary.test.tsx new file mode 100644 index 0000000..b28eef9 --- /dev/null +++ b/apps/desktop/src/app/features/knowledge/KnowledgeLibrary.test.tsx @@ -0,0 +1,263 @@ +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { IpcError } from "../../../ipc/client"; +import { createMockIpcClient } from "../../../test/mockIpc"; +import { KnowledgePanel } from "./KnowledgePanel"; +import type { KnowledgePage, KnowledgeRecord } from "./knowledge-types"; + +const project = { id: "project-a", name: "Project A" }; + +/** Content fixtures intentionally keep exact whitespace in the reusable body. */ +function entry(overrides: Partial = {}): KnowledgeRecord { + return { + id: "entry-a", kind: "prompt", projectId: null, title: "Review changes", + body: "Review the diff.\nKeep the public API stable.\n", revision: 3, + createdAtMs: 100, updatedAtMs: 200, ...overrides, + }; +} + +/** Controllable responses exercise real user-visible async races. */ +function deferred() { + let settle: (value: T) => void = () => { throw new Error("Promise not initialized"); }; + const promise = new Promise((resolve) => { settle = resolve; }); + return { promise, resolve: settle }; +} + +describe("KnowledgePanel", () => { + it("saves project context and only inserts exact text after an explicit click", async () => { + const user = userEvent.setup(); + const onInsert = vi.fn(); + const saved = entry({ kind: "context", projectId: project.id, title: "Architecture", body: "Keep this context.\n\n" }); + const client = createMockIpcClient({ handlers: { listKnowledge: async () => ({ entries: [], nextCursor: null }) } }); + client.saveKnowledge.mockResolvedValue(saved); + render(); + await waitFor(() => expect(client.listKnowledge).toHaveBeenCalledWith({ projectId: project.id })); + + await user.selectOptions(screen.getByLabelText("Type"), "context"); + await user.type(screen.getByLabelText("Title"), " Architecture "); + fireEvent.change(screen.getByLabelText("Content"), { target: { value: saved.body } }); + await user.click(screen.getByRole("button", { name: "Save locally" })); + expect(client.saveKnowledge).toHaveBeenCalledWith({ kind: "context", projectId: project.id, title: "Architecture", body: saved.body }); + expect(await screen.findByText("Saved locally.")).toBeVisible(); + expect(onInsert).not.toHaveBeenCalled(); + expect(client.writeTerminal).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Insert into draft" })); + expect(onInsert).toHaveBeenCalledWith({ sourceId: saved.id, kind: "context", title: saved.title, body: saved.body }); + expect(screen.getByText("Inserted into the session draft.")).toBeVisible(); + expect(client.writeTerminal).not.toHaveBeenCalled(); + }); + + it("preserves item and new-project drafts when selecting items, refreshing, or changing project", async () => { + const user = userEvent.setup(); + const first = entry(); + const second = entry({ id: "entry-b", title: "Project context", kind: "context", projectId: project.id }); + const client = createMockIpcClient({ handlers: { listKnowledge: async ({ projectId }) => ({ entries: projectId ? [first, second] : [first], nextCursor: null }) } }); + const onInsert = vi.fn(); + const view = render(); + await user.click(await screen.findByRole("button", { name: /Review changes/ })); + fireEvent.change(screen.getByLabelText("Content"), { target: { value: "Unsaved review" } }); + await user.click(screen.getByRole("button", { name: /Project context/ })); + await user.click(screen.getByRole("button", { name: /Review changes/ })); + expect(screen.getByLabelText("Content")).toHaveValue("Unsaved review"); + await user.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(client.listKnowledge).toHaveBeenCalledTimes(2)); + expect(screen.getByLabelText("Content")).toHaveValue("Unsaved review"); + + await user.click(screen.getByRole("button", { name: "New item" })); + await user.type(screen.getByLabelText("Title"), "Project draft"); + await user.selectOptions(screen.getByLabelText("Scope"), ""); + view.rerender(); + await waitFor(() => expect(client.listKnowledge).toHaveBeenLastCalledWith({ projectId: null })); + await user.type(screen.getByLabelText("Title"), "Global draft"); + view.rerender(); + expect(screen.getByLabelText("Title")).toHaveValue("Project draft"); + expect(screen.getByLabelText("Scope")).toHaveValue(""); + await user.click(await screen.findByRole("button", { name: /Review changes/ })); + expect(screen.getByLabelText("Content")).toHaveValue("Unsaved review"); + expect(client.saveKnowledge).not.toHaveBeenCalled(); + }); + + it("retains conflict edits and saves a copy without overwriting the newer revision", async () => { + const user = userEvent.setup(); + const original = entry(); + const client = createMockIpcClient({ handlers: { listKnowledge: async () => ({ entries: [original], nextCursor: null }) } }); + client.saveKnowledge.mockRejectedValueOnce(new IpcError({ code: "revision_conflict", message: "Revision changed" })); + client.saveKnowledge.mockResolvedValueOnce(entry({ id: "entry-copy", body: "My retained draft", revision: 1 })); + render(); + await user.click(await screen.findByRole("button", { name: /Review changes/ })); + fireEvent.change(screen.getByLabelText("Content"), { target: { value: "My retained draft" } }); + await user.click(screen.getByRole("button", { name: "Save locally" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Your edits are preserved"); + expect(screen.getByLabelText("Content")).toHaveValue("My retained draft"); + expect(client.saveKnowledge).toHaveBeenNthCalledWith(1, { id: original.id, expectedRevision: 3, kind: "prompt", projectId: null, title: original.title, body: "My retained draft" }); + await user.click(screen.getByRole("button", { name: "Save as copy" })); + expect(client.saveKnowledge).toHaveBeenNthCalledWith(2, { kind: "prompt", projectId: null, title: original.title, body: "My retained draft" }); + expect(await screen.findByText("Copy saved locally.")).toBeVisible(); + }); + + it("requires confirmation before revision-bound deletion and keeps cancelled edits", async () => { + const user = userEvent.setup(); + const original = entry(); + const client = createMockIpcClient({ handlers: { listKnowledge: async () => ({ entries: [original], nextCursor: null }), deleteKnowledge: async () => undefined } }); + render(); + await user.click(await screen.findByRole("button", { name: /Review changes/ })); + fireEvent.change(screen.getByLabelText("Content"), { target: { value: "Keep my edits" } }); + await user.click(screen.getByRole("button", { name: "Delete item" })); + expect(client.deleteKnowledge).not.toHaveBeenCalled(); + const dialog = screen.getByRole("dialog", { name: "Delete saved content" }); + expect(within(dialog).getByRole("button", { name: "Keep item" })).toHaveFocus(); + await user.click(within(dialog).getByRole("button", { name: "Keep item" })); + expect(screen.getByLabelText("Content")).toHaveValue("Keep my edits"); + await user.click(screen.getByRole("button", { name: "Delete item" })); + await user.click(screen.getByRole("button", { name: "Confirm delete" })); + expect(client.deleteKnowledge).toHaveBeenCalledWith({ id: original.id, expectedRevision: original.revision }); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(screen.getByLabelText("Title")).toHaveValue(""); + }); + + it("loads additional pages and searches all saved title/body content", async () => { + const user = userEvent.setup(); + const first = entry(); + const second = entry({ id: "entry-b", title: "Deployment notes", kind: "context" }); + const client = createMockIpcClient({ handlers: { listKnowledge: async ({ cursor, query }) => query ? { entries: [second], nextCursor: null } : cursor ? { entries: [second], nextCursor: null } : { entries: [first], nextCursor: first.id } } }); + render(); + await user.click(await screen.findByRole("button", { name: "Load more" })); + expect(client.listKnowledge).toHaveBeenLastCalledWith({ projectId: project.id, cursor: first.id }); + expect(await screen.findByRole("button", { name: /Deployment notes/ })).toBeVisible(); + expect(screen.getByRole("button", { name: /Review changes/ })).toBeVisible(); + await user.type(screen.getByRole("searchbox"), "deployment"); + await waitFor(() => expect(client.listKnowledge).toHaveBeenLastCalledWith({ projectId: project.id, query: "deployment" })); + expect(await screen.findByRole("button", { name: /Deployment notes/ })).toBeVisible(); + expect(screen.queryByRole("button", { name: /Review changes/ })).not.toBeInTheDocument(); + }); + + it("keeps the confirmed delete target when the host switches projects", async () => { + const user = userEvent.setup(); + const original = entry({ projectId: project.id }); + const client = createMockIpcClient({ handlers: { + listKnowledge: async ({ projectId }) => ({ entries: projectId ? [original] : [], nextCursor: null }), + deleteKnowledge: async () => undefined, + } }); + const onInsert = vi.fn(); + const view = render(); + await user.click(await screen.findByRole("button", { name: "Review changes" })); + await user.click(screen.getByRole("button", { name: "Delete item" })); + view.rerender(); + await user.click(screen.getByRole("button", { name: "Confirm delete" })); + expect(client.deleteKnowledge).toHaveBeenCalledWith({ id: original.id, expectedRevision: original.revision }); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + }); + + it("does not replace a successful save with an older refresh response", async () => { + const user = userEvent.setup(); + const oldResponse = deferred(); + const original = entry(); + const updated = entry({ title: "Updated title", revision: 4 }); + const client = createMockIpcClient(); + client.listKnowledge.mockResolvedValueOnce({ entries: [original], nextCursor: null }); + client.listKnowledge.mockReturnValueOnce(oldResponse.promise); + client.listKnowledge.mockResolvedValue({ entries: [updated], nextCursor: null }); + client.saveKnowledge.mockResolvedValue(updated); + render(); + await user.click(await screen.findByRole("button", { name: "Review changes" })); + fireEvent.change(screen.getByLabelText("Title"), { target: { value: "Updated title" } }); + await user.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(client.listKnowledge).toHaveBeenCalledTimes(2)); + await user.click(screen.getByRole("button", { name: "Save locally" })); + expect(await screen.findByRole("button", { name: "Updated title" })).toBeVisible(); + await act(async () => oldResponse.resolve({ entries: [original], nextCursor: null })); + expect(screen.queryByRole("button", { name: "Review changes" })).not.toBeInTheDocument(); + expect(screen.getByLabelText("Title")).toHaveValue("Updated title"); + }); + + it("shows deletion failures inside the confirmation and retains the edited content", async () => { + const user = userEvent.setup(); + const original = entry(); + const client = createMockIpcClient({ handlers: { listKnowledge: async () => ({ entries: [original], nextCursor: null }) } }); + client.deleteKnowledge.mockRejectedValue(new IpcError({ code: "revision_conflict", message: "This item has a newer revision." })); + render(); + await user.click(await screen.findByRole("button", { name: "Review changes" })); + fireEvent.change(screen.getByLabelText("Content"), { target: { value: "My draft survives" } }); + await user.click(screen.getByRole("button", { name: "Delete item" })); + await user.click(screen.getByRole("button", { name: "Confirm delete" })); + const dialog = screen.getByRole("dialog", { name: "Delete saved content" }); + expect(await within(dialog).findByRole("alert")).toHaveTextContent("This item has a newer revision."); + await user.click(within(dialog).getByRole("button", { name: "Keep item" })); + expect(screen.getByLabelText("Content")).toHaveValue("My draft survives"); + }); + + it("ignores late search responses without replacing a newer result or editor draft", async () => { + const user = userEvent.setup(); + const oldResponse = deferred(); + const current = entry({ id: "entry-current", title: "Current result" }); + const client = createMockIpcClient({ handlers: { listKnowledge: async ({ query }) => query === "old" ? oldResponse.promise : { entries: query ? [current] : [], nextCursor: null } } }); + render(); + await user.type(screen.getByLabelText("Title"), "Unfinished note"); + await user.type(screen.getByRole("searchbox"), "old"); + await waitFor(() => expect(client.listKnowledge).toHaveBeenCalledWith({ projectId: null, query: "old" })); + await user.clear(screen.getByRole("searchbox")); + await user.type(screen.getByRole("searchbox"), "new"); + expect(await screen.findByRole("button", { name: /Current result/ })).toBeVisible(); + await act(async () => oldResponse.resolve({ entries: [entry({ title: "Outdated result" })], nextCursor: null })); + expect(screen.queryByRole("button", { name: /Outdated result/ })).not.toBeInTheDocument(); + expect(screen.getByLabelText("Title")).toHaveValue("Unfinished note"); + }); + + it("validates UTF-8 limits, focuses invalid fields, and matches Rust whitespace rules", async () => { + const user = userEvent.setup(); + const client = createMockIpcClient({ handlers: { listKnowledge: async () => ({ entries: [], nextCursor: null }) } }); + client.saveKnowledge.mockResolvedValue(entry({ title: "\uFEFF", body: "\uFEFF" })); + render(); + await user.click(screen.getByRole("button", { name: "Save locally" })); + expect(screen.getByLabelText("Title")).toHaveFocus(); + expect(screen.getByText("Enter a title.")).toBeVisible(); + fireEvent.change(screen.getByLabelText("Title"), { target: { value: "é".repeat(129) } }); + fireEvent.change(screen.getByLabelText("Content"), { target: { value: "Valid body" } }); + await user.click(screen.getByRole("button", { name: "Save locally" })); + expect(screen.getByText("Use a title of at most 256 UTF-8 bytes.")).toBeVisible(); + fireEvent.change(screen.getByLabelText("Title"), { target: { value: "Valid title" } }); + fireEvent.change(screen.getByLabelText("Content"), { target: { value: "é".repeat(32_769) } }); + await user.click(screen.getByRole("button", { name: "Save locally" })); + expect(screen.getByLabelText("Content")).toHaveFocus(); + expect(screen.getByText(/Content must be at most 64 KiB/)).toBeVisible(); + expect(client.saveKnowledge).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText("Title"), { target: { value: "\uFEFF" } }); + fireEvent.change(screen.getByLabelText("Content"), { target: { value: "\uFEFF" } }); + await user.click(screen.getByRole("button", { name: "Save locally" })); + expect(client.saveKnowledge).toHaveBeenCalledWith({ kind: "prompt", projectId: null, title: "\uFEFF", body: "\uFEFF" }); + }); + + it("blocks duplicate saves and explains unavailable insertion", async () => { + const user = userEvent.setup(); + const pending = deferred(); + const client = createMockIpcClient({ handlers: { listKnowledge: async () => ({ entries: [], nextCursor: null }) } }); + client.saveKnowledge.mockReturnValue(pending.promise); + render(); + await user.type(screen.getByLabelText("Title"), "Review changes"); + await user.type(screen.getByLabelText("Content"), "Content"); + expect(screen.getByRole("button", { name: "Insert into draft" })).toBeDisabled(); + expect(screen.getByText("Select a session to insert this content.")).toBeVisible(); + await user.dblClick(screen.getByRole("button", { name: "Save locally" })); + expect(client.saveKnowledge).toHaveBeenCalledOnce(); + expect(screen.getByRole("button", { name: "Saving changes…" })).toBeDisabled(); + expect(screen.getByLabelText("Content")).toBeDisabled(); + await act(async () => pending.resolve(entry())); + expect(await screen.findByText("Saved locally.")).toBeVisible(); + }); + + it("offers a retry after list failure while retaining a new draft", async () => { + const user = userEvent.setup(); + const client = createMockIpcClient({ handlers: { listKnowledge: async () => ({ entries: [], nextCursor: null }) } }); + client.listKnowledge.mockRejectedValueOnce(new IpcError({ code: "storage_failed", message: "Could not load saved content." })); + client.listKnowledge.mockResolvedValue({ entries: [entry()], nextCursor: null }); + render(); + expect(await screen.findByRole("alert")).toHaveTextContent("Could not load saved content."); + await user.type(screen.getByLabelText("Title"), "Keep this draft"); + await user.click(screen.getByRole("button", { name: "Retry loading" })); + expect(await screen.findByRole("button", { name: /Review changes/ })).toBeVisible(); + expect(screen.getByLabelText("Title")).toHaveValue("Keep this draft"); + }); +}); diff --git a/apps/desktop/src/app/features/knowledge/KnowledgeLibrary.tsx b/apps/desktop/src/app/features/knowledge/KnowledgeLibrary.tsx new file mode 100644 index 0000000..3e30ae7 --- /dev/null +++ b/apps/desktop/src/app/features/knowledge/KnowledgeLibrary.tsx @@ -0,0 +1,215 @@ +import { useId, useRef, useState, type FormEvent } from "react"; + +import { Dialog } from "../../components/Dialog"; +import type { KnowledgeLibraryProps, KnowledgeRecord } from "./knowledge-types"; +import { hasDraftChanges, trimKnowledgeText, useKnowledgeLibrary } from "./useKnowledgeLibrary"; +import "./knowledge-library.css"; + +/** Local content library. The host owns persistence and insertion into a session draft. */ +export function KnowledgeLibrary(props: KnowledgeLibraryProps) { + const library = useKnowledgeLibrary(props); + const { draft, busy } = library; + const id = useId(); + const titleRef = useRef(null); + const bodyRef = useRef(null); + const cancelDeleteRef = useRef(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteFailed, setDeleteFailed] = useState(false); + const dirty = hasDraftChanges(draft); + const projectId = props.currentProject?.id ?? null; + + function submit(event: FormEvent) { + event.preventDefault(); + saveDraft(); + } + + function saveDraft(asCopy = false) { + const invalidField = library.validate(); + if (invalidField === "title") titleRef.current?.focus(); + else if (invalidField === "body") bodyRef.current?.focus(); + else void library.save(asCopy); + } + + async function confirmDelete() { + if (!deleteTarget) return; + setDeleteFailed(false); + if (await library.remove(deleteTarget)) { + setDeleteTarget(null); + titleRef.current?.focus(); + } else { + setDeleteFailed(true); + } + } + + return ( +
+
+
+

Prompts & context

+

Reusable content saved on this device.

+
+ +
+ +
+
+ +
+

{props.currentProject ? `${props.currentProject.name} + global` : "Global library"}

+ +
+ {library.listError ? ( +
+

{library.listError}

+ +
+ ) : null} +

+ {library.loading ? "Loading saved content…" : `${library.records.length} ${library.records.length === 1 ? "item" : "items"} loaded${library.nextCursor ? "; more available" : ""}.`} +

+
    + {library.records.map((record) => { + const itemDraft = library.drafts[record.id]; + return ( +
  • + +
  • + ); + })} +
+ {!library.loading && !library.listError && !library.records.length ? ( +

+ {trimKnowledgeText(library.query) ? "No saved content matches this search." : "No saved content yet. Create a prompt or context note to reuse across sessions."} +

+ ) : null} + {library.nextCursor ? ( + + ) : null} +
+ +
+
+

{draft.original ? "Edit saved content" : "New saved content"}

+ {dirty ? Unsaved changes : null} +
+ {draft.error ?

{draft.error}

: null} +
+ + +
+ + {draft.titleError ?

{draft.titleError}

: null} +