From 0efa2ac157a971d5ed40b2574415733e7949c11c Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 9 Jul 2026 11:14:28 -0400 Subject: [PATCH 01/20] =?UTF-8?q?feat(cli):=20add=20`path=20p=20cache=20sy?= =?UTF-8?q?nc`=20=E2=80=94=20incremental=20session=20ingestion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `path p cache sync [types…]` enumerates sessions across the installed agent harnesses (claude/gemini/codex/opencode/cursor/pi, or just the named ones) and derives into the cache only what is new or changed. The manifest at ~/.toolpath/sync.json maps artifact type → session id → {project?, cache_id, last_activity?, message_count, synced_at}; a session re-derives only when its fingerprint (last_activity + message_count) differs. Manifest writes are atomic (temp + rename, 0600) and checkpointed per type; sync overwrites what it re-derives (refresh semantics) and never deletes — the cache is an archive, not a mirror. Enumeration reuses cmd_share::gather_sessions; per-session derivation goes through new *_with variants of the cmd_import session helpers so listing and derivation share provider managers (and tests can inject fixture resolvers). Real-data numbers: 34 sessions / 129 MB of Claude JSONL → 5.7 s cold, 0.9 s incremental no-op. path-cli 0.15.0 → 0.16.0 (+ toolpath-cli shim bump). --- CHANGELOG.md | 31 ++ CLAUDE.md | 7 +- Cargo.lock | 2 +- Cargo.toml | 4 +- README.md | 4 + crates/path-cli/Cargo.toml | 2 +- crates/path-cli/src/cmd_cache.rs | 10 + crates/path-cli/src/cmd_import.rs | 71 +++- crates/path-cli/src/cmd_share.rs | 10 + crates/path-cli/src/lib.rs | 2 + crates/path-cli/src/sync.rs | 576 +++++++++++++++++++++++++++ crates/path-cli/tests/integration.rs | 102 +++++ crates/toolpath-cli/Cargo.toml | 4 +- site/_data/crates.json | 4 +- 14 files changed, 814 insertions(+), 15 deletions(-) create mode 100644 crates/path-cli/src/sync.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c13fa974..1f44c78d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ All notable changes to the Toolpath workspace are documented here. +## `path p cache sync` — incremental session ingestion — 2026-07-09 + +Adds `path p cache sync [types…]`, the first step toward a cache that fills +itself: it enumerates sessions across the installed agent harnesses and +derives into the cache only what is new or changed since the last sync, so +users no longer have to `p import` each session by hand. + +- **`path-cli`** (0.16.0): + - `path p cache sync [claude|gemini|codex|opencode|cursor|pi]…` syncs the + named artifact types; with no arguments it syncs every harness. + Enumeration reuses the `path share` aggregation, and per-session + derivation goes through the same provider managers, so listing and + derivation always agree on provider roots. + - The sync manifest at `~/.toolpath/sync.json` maps artifact type → + session id → `{project?, cache_id, last_activity?, message_count, + synced_at}`. A session is re-derived when its `last_activity` or + `message_count` differs from the record; otherwise it is skipped. + The manifest is written atomically (temp file + rename, `0600`) and + checkpointed after each type, so an interrupted first run keeps the + types it finished. + - Sync owns refresh semantics: it overwrites cache entries it + re-derives (no `--force` needed), while manual `p import` keeps its + error-on-hit default. Sessions deleted upstream keep both their cache + document and their manifest record — the cache is an archive, not a + mirror. + - Per-type summary on stderr (`claude 3 new, 1 updated, 240 unchanged`). + On a default run, harnesses with no sessions stay silent; explicit + types always report. Derivation failures warn and tally as `failed` + without aborting the run. +- **`toolpath-cli`** (0.16.0): version bump only (tracks `path-cli`). + ## Derive: resolve duplicate step ids — 2026-07-01 - **`toolpath-convo`** (0.11.1): `derive_path` now guarantees the derived diff --git a/CLAUDE.md b/CLAUDE.md index b6c63e53..3e6d748f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,6 +129,8 @@ cargo run -p path-cli -- p export pathbase --input # Plumbing: manage the cache cargo run -p path-cli -- p cache ls cargo run -p path-cli -- p cache rm +cargo run -p path-cli -- p cache sync # ingest new/changed sessions from every harness +cargo run -p path-cli -- p cache sync claude codex # only these artifact types # Inspect / analyze cargo run -p path-cli -- p render dot --input doc.json @@ -165,7 +167,7 @@ cargo run -p path-cli -- auth logout and no deprecation shim. They all now live exclusively under `path p …`. -The **cache** at `~/.toolpath/documents/.json` is the single landing zone for every `import` (and for `import pathbase` downloads). Cache id is `-` — e.g. `claude-abc123`, `git-main`, `pathbase-alex-pathstash-path-pr-42` (Pathbase paths key on `--`, anon paths on `anon-pathstash-`). Files are `0600`, parent directory `0700`. `$TOOLPATH_CONFIG_DIR` overrides the root. Default behavior: error on cache hit; pass `--force` to overwrite. `--no-cache` sends the JSON to stdout for shell composition. +The **cache** at `~/.toolpath/documents/.json` is the single landing zone for every `import` (and for `import pathbase` downloads). Cache id is `-` — e.g. `claude-abc123`, `git-main`, `pathbase-alex-pathstash-path-pr-42` (Pathbase paths key on `--`, anon paths on `anon-pathstash-`). Files are `0600`, parent directory `0700`. `$TOOLPATH_CONFIG_DIR` overrides the root. Default behavior: error on cache hit; pass `--force` to overwrite. `--no-cache` sends the JSON to stdout for shell composition. `p cache sync` fills the cache incrementally from the installed agent harnesses (see "Things to know") and always overwrites what it re-derives. `path auth login` prints `/auth/cli`; the user opens it, logs in, and pastes the 8-character code back into the CLI. The CLI calls @@ -206,7 +208,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-cursor`: 78 unit + 8 integration round-trip + 1 real-DB sanity + 1 doc test (state.vscdb SQLite reader, bubble store + composer header parsing, content-addressed blob lookup, projector with full TOOL_TABLE coverage, JSONL transcript ingest in `examples/dump_fixture.rs`) - `toolpath-pi`: 133 unit + 26 integration + 5 doc tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `path-cli`: 326 unit + 105 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy` incl. Copilot, Copilot import/list/show/**export** via `COPILOT_HOME` + Copilot in the `path share` aggregator + Copilot in the cross-harness conformance matrix, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. +- `path-cli`: 336 unit + 101 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — manifest fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. - `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary) Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- p validate --input "$f"; done` @@ -276,3 +278,4 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. +- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests agent sessions into the cache — no args syncs all six harnesses. It enumerates via the `path share` aggregation (`cmd_share::gather_sessions`) and derives each session through the *same* provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`), so listing and derivation agree on provider roots and tests can inject fixture resolvers. A session is re-derived only when its fingerprint (`last_activity` + `message_count`) differs from the manifest at `~/.toolpath/sync.json` (artifact type → session id → `{project?, cache_id, last_activity?, message_count, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type). Sync always writes the cache with force — refresh semantics — and never deletes: sessions removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. Caveats: enumeration costs the providers' metadata pass (full parse per JSONL session file — a stat-level fast path is the planned optimization before `path query` runs sync implicitly), and cursor composers without a workspace are skipped, same as `share`. diff --git a/Cargo.lock b/Cargo.lock index 301920f1..032f4c23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2441,7 +2441,7 @@ dependencies = [ [[package]] name = "path-cli" -version = "0.15.0" +version = "0.16.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 835a95e6..b1f7bfca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,8 +36,8 @@ toolpath-cursor = { version = "0.2.0", path = "crates/toolpath-cursor" } toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" } toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" } toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" } -toolpath-pi = { version = "0.6.1", path = "crates/toolpath-pi" } -path-cli = { version = "0.15.0", path = "crates/path-cli" } +toolpath-pi = { version = "0.6.0", path = "crates/toolpath-pi" } +path-cli = { version = "0.16.0", path = "crates/path-cli" } pathbase-client = { version = "0.2.0", path = "crates/pathbase-client" } reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] } diff --git a/README.md b/README.md index fdca4fc1..33259e97 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,10 @@ path p import opencode # List what's in the cache path p cache ls +# Ingest new/changed agent sessions into the cache (all harnesses, or named ones) +path p cache sync +path p cache sync claude codex + # Export a cached document back into a Claude Code session path p export claude --input claude- --project /path/to/resume diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index 328767c2..575c972f 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "path-cli" -version = "0.15.0" +version = "0.16.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/path-cli/src/cmd_cache.rs b/crates/path-cli/src/cmd_cache.rs index e6a08c2d..88bfe854 100644 --- a/crates/path-cli/src/cmd_cache.rs +++ b/crates/path-cli/src/cmd_cache.rs @@ -23,12 +23,22 @@ pub enum CacheOp { /// Cache id (filename without `.json`) id: String, }, + /// Ingest agent sessions into the cache, deriving only what is new + /// or changed since the last sync (tracked in `$CONFIG_DIR/sync.json`) + #[cfg(not(target_os = "emscripten"))] + Sync { + /// Artifact types to sync (default: every agent harness) + #[arg(value_enum)] + types: Vec, + }, } pub fn run(op: CacheOp) -> Result<()> { match op { CacheOp::Ls => run_ls(), CacheOp::Rm { id } => run_rm(&id), + #[cfg(not(target_os = "emscripten"))] + CacheOp::Sync { types } => crate::sync::run(types), } } diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index 48ee8ad2..c191c279 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -484,7 +484,17 @@ fn derive_claude_with_manager( /// Used by `cmd_share` after its picker has resolved the pair; mirrors the /// `(Some(p), Some(s), _)` arm in [`derive_claude_with_manager`]. pub(crate) fn derive_claude_session(project: &str, session: &str) -> Result { - let manager = toolpath_claude::ClaudeConvo::new(); + derive_claude_session_with(&toolpath_claude::ClaudeConvo::new(), project, session) +} + +/// [`derive_claude_session`] against a caller-supplied manager, so sync +/// derives from the same roots it enumerated (and tests can inject a +/// fixture resolver). +pub(crate) fn derive_claude_session_with( + manager: &toolpath_claude::ClaudeConvo, + project: &str, + session: &str, +) -> Result { let cfg = toolpath_claude::derive::DeriveConfig { project_path: Some(project.to_string()), include_thinking: false, @@ -709,7 +719,21 @@ pub(crate) fn derive_gemini_session( session: &str, include_thinking: bool, ) -> Result { - let manager = toolpath_gemini::GeminiConvo::new(); + derive_gemini_session_with( + &toolpath_gemini::GeminiConvo::new(), + project, + session, + include_thinking, + ) +} + +/// [`derive_gemini_session`] against a caller-supplied manager. +pub(crate) fn derive_gemini_session_with( + manager: &toolpath_gemini::GeminiConvo, + project: &str, + session: &str, + include_thinking: bool, +) -> Result { let cfg = toolpath_gemini::derive::DeriveConfig { project_path: Some(project.to_string()), include_thinking, @@ -896,7 +920,14 @@ fn derive_codex(session: Option, all: bool) -> Result> { /// Derive a single Codex session given an explicit session id. pub(crate) fn derive_codex_session(session: &str) -> Result { - let manager = toolpath_codex::CodexConvo::new(); + derive_codex_session_with(&toolpath_codex::CodexConvo::new(), session) +} + +/// [`derive_codex_session`] against a caller-supplied manager. +pub(crate) fn derive_codex_session_with( + manager: &toolpath_codex::CodexConvo, + session: &str, +) -> Result { let config = toolpath_codex::derive::DeriveConfig { project_path: None }; let s = manager .read_session(session) @@ -1180,7 +1211,20 @@ pub(crate) fn derive_opencode_session( session: &str, no_snapshot_diffs: bool, ) -> Result { - let manager = toolpath_opencode::OpencodeConvo::new(); + derive_opencode_session_with( + &toolpath_opencode::OpencodeConvo::new(), + session, + no_snapshot_diffs, + ) +} + +/// [`derive_opencode_session`] against a caller-supplied manager. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn derive_opencode_session_with( + manager: &toolpath_opencode::OpencodeConvo, + session: &str, + no_snapshot_diffs: bool, +) -> Result { let config = toolpath_opencode::derive::DeriveConfig { no_snapshot_diffs, ..Default::default() @@ -1353,7 +1397,15 @@ fn derive_cursor( /// Derive a single cursor composer given an explicit composer id. #[cfg(not(target_os = "emscripten"))] pub(crate) fn derive_cursor_session(session: &str) -> Result { - let manager = toolpath_cursor::CursorConvo::new(); + derive_cursor_session_with(&toolpath_cursor::CursorConvo::new(), session) +} + +/// [`derive_cursor_session`] against a caller-supplied manager. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn derive_cursor_session_with( + manager: &toolpath_cursor::CursorConvo, + session: &str, +) -> Result { let s = manager .read_session(session) .map_err(|e| anyhow::anyhow!("{}", e))?; @@ -1554,6 +1606,15 @@ pub(crate) fn derive_pi_session( } else { toolpath_pi::PiConvo::new() }; + derive_pi_session_with(&manager, project, session) +} + +/// [`derive_pi_session`] against a caller-supplied manager. +pub(crate) fn derive_pi_session_with( + manager: &toolpath_pi::PiConvo, + project: &str, + session: &str, +) -> Result { let config = toolpath_pi::DeriveConfig::default(); let session = manager .read_session(project, session) diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index 1eb4f96f..4c5a1ed0 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -79,6 +79,16 @@ pub(crate) enum Harness { } impl Harness { + /// Every harness, in presentation order. + pub(crate) const ALL: [Harness; 6] = [ + Harness::Claude, + Harness::Gemini, + Harness::Codex, + Harness::Opencode, + Harness::Cursor, + Harness::Pi, + ]; + pub(crate) fn name(&self) -> &'static str { match self { Harness::Claude => "claude", diff --git a/crates/path-cli/src/lib.rs b/crates/path-cli/src/lib.rs index 784cd337..011718e0 100644 --- a/crates/path-cli/src/lib.rs +++ b/crates/path-cli/src/lib.rs @@ -33,6 +33,8 @@ mod query; mod schema; #[cfg(all(not(target_os = "emscripten"), feature = "embedded-picker"))] mod skim_picker; +#[cfg(not(target_os = "emscripten"))] +mod sync; mod term; use anyhow::Result; diff --git a/crates/path-cli/src/sync.rs b/crates/path-cli/src/sync.rs new file mode 100644 index 00000000..3a116672 --- /dev/null +++ b/crates/path-cli/src/sync.rs @@ -0,0 +1,576 @@ +//! `path p cache sync` — incremental ingestion of agent sessions into +//! the document cache. +//! +//! Enumerates sessions across the requested agent harnesses (the same +//! aggregation `path share` uses), compares each against the sync +//! manifest at `$CONFIG_DIR/sync.json`, and derives + caches only what +//! is new or changed. The manifest maps artifact type → session id → +//! the fingerprint recorded at last sync (`last_activity` + +//! `message_count`), so an unchanged session costs a metadata read +//! instead of a re-derivation, and running sync twice in a row is a +//! no-op. Sessions deleted upstream keep both their cache document and +//! their manifest record — the cache is an archive, not a mirror. + +#![cfg(not(target_os = "emscripten"))] + +use anyhow::{Context, Result, anyhow}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +use crate::cmd_cache::write_cached; +use crate::cmd_import::DerivedDoc; +use crate::cmd_share::{Harness, HarnessArg, HarnessBundle, SessionRow, gather_sessions}; +use crate::config::config_dir; + +const MANIFEST_FILE: &str = "sync.json"; + +/// What the manifest remembers about one synced session. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct SyncRecord { + /// Project path, for the project-keyed providers (claude/gemini/pi). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) project: Option, + /// Cache entry the derived document was written to. + pub(crate) cache_id: String, + /// Fingerprint: the session's last activity as reported at sync time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) last_activity: Option>, + /// Fingerprint: message count as reported at sync time. + pub(crate) message_count: usize, + pub(crate) synced_at: DateTime, +} + +/// The sync manifest: artifact type (`"claude"`, `"codex"`, …) → +/// session id → record. Kept as `BTreeMap`s so the JSON on disk is +/// stably ordered. +pub(crate) type Manifest = BTreeMap>; + +/// Per-type tally of what one sync run did. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SyncOutcome { + pub(crate) new: usize, + pub(crate) updated: usize, + pub(crate) unchanged: usize, + pub(crate) failed: usize, +} + +impl SyncOutcome { + fn total(&self) -> usize { + self.new + self.updated + self.unchanged + self.failed + } +} + +pub(crate) fn run(types: Vec) -> Result<()> { + let explicit = !types.is_empty(); + let types = resolve_types(&types); + let bundle = HarnessBundle::from_environment(); + let outcomes = sync_bundle(&bundle, &types)?; + eprint!("{}", render_summary(&outcomes, explicit)); + Ok(()) +} + +/// Explicit args → dedup'd harness list; no args → every harness. +fn resolve_types(args: &[HarnessArg]) -> Vec { + if args.is_empty() { + return Harness::ALL.to_vec(); + } + let mut out: Vec = Vec::with_capacity(args.len()); + for &a in args { + let h = Harness::from_arg(a); + if !out.contains(&h) { + out.push(h); + } + } + out +} + +/// Sync the given harness types from `bundle` into the cache. The +/// manifest is checkpointed after each type so an interrupted first +/// run doesn't forget the types it already finished. +pub(crate) fn sync_bundle( + bundle: &HarnessBundle, + types: &[Harness], +) -> Result> { + let mut manifest = load_manifest()?; + // Only ranking depends on cwd and sync ignores row order, so any + // directory works here. + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let mut out = Vec::with_capacity(types.len()); + for &harness in types { + let rows = gather_sessions(bundle, &cwd, Some(harness), None); + let mut records = manifest.get(harness.name()).cloned().unwrap_or_default(); + let outcome = sync_rows(bundle, &rows, &mut records)?; + if !records.is_empty() { + manifest.insert(harness.name().to_string(), records); + save_manifest(&manifest)?; + } + out.push((harness, outcome)); + } + Ok(out) +} + +/// Sync one harness's rows against its manifest records. Derivation +/// failures are warned and tallied, not fatal; cache-write failures +/// (disk, permissions) abort. +fn sync_rows( + bundle: &HarnessBundle, + rows: &[SessionRow], + records: &mut BTreeMap, +) -> Result { + let mut outcome = SyncOutcome::default(); + for row in rows { + let existing = records.get(&row.session_id); + let is_new = existing.is_none(); + if let Some(rec) = existing + && rec.last_activity == row.last_activity + && rec.message_count == row.message_count + { + outcome.unchanged += 1; + continue; + } + match derive_row(bundle, row) { + Ok(derived) => { + // force: sync owns refresh semantics — a re-sync or a + // prior manual `p import` of the same session must not + // error on the existing cache entry. + write_cached(&derived.cache_id, &derived.doc, true)?; + records.insert( + row.session_id.clone(), + SyncRecord { + project: row.project.clone(), + cache_id: derived.cache_id, + last_activity: row.last_activity, + message_count: row.message_count, + synced_at: Utc::now(), + }, + ); + if is_new { + outcome.new += 1; + } else { + outcome.updated += 1; + } + } + Err(e) => { + eprintln!( + "warning: sync {}: session {}: {e}", + row.harness.name(), + row.session_id + ); + outcome.failed += 1; + } + } + } + Ok(outcome) +} + +/// Derive one session through the same manager the row was enumerated +/// from, so listing and derivation always agree on provider roots. +fn derive_row(bundle: &HarnessBundle, row: &SessionRow) -> Result { + use crate::cmd_import as imp; + let project = || { + row.project + .as_deref() + .ok_or_else(|| anyhow!("session {} has no project path", row.session_id)) + }; + match row.harness { + Harness::Claude => { + imp::derive_claude_session_with(mgr(&bundle.claude)?, project()?, &row.session_id) + } + Harness::Gemini => imp::derive_gemini_session_with( + mgr(&bundle.gemini)?, + project()?, + &row.session_id, + false, + ), + Harness::Pi => imp::derive_pi_session_with(mgr(&bundle.pi)?, project()?, &row.session_id), + Harness::Codex => imp::derive_codex_session_with(mgr(&bundle.codex)?, &row.session_id), + Harness::Opencode => { + imp::derive_opencode_session_with(mgr(&bundle.opencode)?, &row.session_id, false) + } + Harness::Cursor => imp::derive_cursor_session_with(mgr(&bundle.cursor)?, &row.session_id), + } +} + +fn mgr(slot: &Option) -> Result<&T> { + slot.as_ref() + .ok_or_else(|| anyhow!("provider not available")) +} + +/// One stderr line per harness. Types the user didn't name are shown +/// only when they had sessions, so a default run doesn't list every +/// uninstalled harness. +fn render_summary(outcomes: &[(Harness, SyncOutcome)], explicit: bool) -> String { + let mut s = String::new(); + for (harness, o) in outcomes { + if o.total() == 0 && !explicit { + continue; + } + s.push_str(&format!( + "{} {} new, {} updated, {} unchanged", + harness.symbol(), + o.new, + o.updated, + o.unchanged + )); + if o.failed > 0 { + s.push_str(&format!(", {} failed", o.failed)); + } + s.push('\n'); + } + if s.is_empty() { + s.push_str("nothing to sync\n"); + } + s +} + +// ── manifest IO ──────────────────────────────────────────────────────── + +fn manifest_path() -> Result { + Ok(config_dir()?.join(MANIFEST_FILE)) +} + +pub(crate) fn load_manifest() -> Result { + let path = manifest_path()?; + let json = match std::fs::read_to_string(&path) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Manifest::default()), + Err(e) => return Err(anyhow!("read {}: {e}", path.display())), + }; + serde_json::from_str(&json).with_context(|| { + format!( + "parse {}; delete it to re-sync from scratch", + path.display() + ) + }) +} + +/// Write the manifest atomically (temp file + rename) with the same +/// permissions as the rest of `$CONFIG_DIR`. +fn save_manifest(manifest: &Manifest) -> Result<()> { + let path = manifest_path()?; + let dir = path.parent().expect("manifest path has a parent"); + std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)); + } + let json = serde_json::to_string_pretty(manifest)?; + let tmp = dir.join(format!("{MANIFEST_FILE}.{}.tmp", std::process::id())); + std::fs::write(&tmp, json).with_context(|| format!("write {}", tmp.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)) + .with_context(|| format!("chmod 0600 {}", tmp.display()))?; + } + std::fs::rename(&tmp, &path) + .with_context(|| format!("rename {} → {}", tmp.display(), path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{CONFIG_DIR_ENV, TEST_ENV_LOCK}; + use std::path::Path; + + /// Run `f` with `$TOOLPATH_CONFIG_DIR` pinned to `/.toolpath`; + /// `f` receives the tempdir root for building provider fixtures. + fn with_cfg R, R>(f: F) -> R { + let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let temp = tempfile::tempdir().unwrap(); + let prev = std::env::var_os(CONFIG_DIR_ENV); + unsafe { + std::env::set_var(CONFIG_DIR_ENV, temp.path().join(".toolpath")); + } + let result = f(temp.path()); + unsafe { + match prev { + Some(v) => std::env::set_var(CONFIG_DIR_ENV, v), + None => std::env::remove_var(CONFIG_DIR_ENV), + } + } + result + } + + fn write_claude_session(home: &Path, project_slug: &str, session: &str, prompt: &str) { + let project_dir = home.join(".claude/projects").join(project_slug); + std::fs::create_dir_all(&project_dir).unwrap(); + let user = format!( + r#"{{"type":"user","uuid":"u-{session}","timestamp":"2024-01-02T00:00:00Z","cwd":"/test/project","message":{{"role":"user","content":"{prompt}"}}}}"# + ); + let asst = format!( + r#"{{"type":"assistant","uuid":"a-{session}","timestamp":"2024-01-02T00:00:01Z","message":{{"role":"assistant","content":"hi"}}}}"# + ); + std::fs::write( + project_dir.join(format!("{session}.jsonl")), + format!("{user}\n{asst}\n"), + ) + .unwrap(); + } + + fn claude_bundle(home: &Path) -> HarnessBundle { + let resolver = toolpath_claude::PathResolver::new().with_claude_dir(home.join(".claude")); + HarnessBundle { + claude: Some(toolpath_claude::ClaudeConvo::with_resolver(resolver)), + ..Default::default() + } + } + + fn cached_step_count(cache_id: &str) -> usize { + let path = crate::cmd_cache::cache_path(cache_id).unwrap(); + let json = std::fs::read_to_string(path).unwrap(); + let doc = toolpath::v1::Graph::from_json(&json).unwrap(); + doc.single_path().map(|p| p.steps.len()).unwrap_or(0) + } + + #[test] + fn manifest_roundtrips_and_missing_is_empty() { + with_cfg(|_| { + assert!(load_manifest().unwrap().is_empty()); + + let mut manifest = Manifest::default(); + manifest.entry("claude".to_string()).or_default().insert( + "sess-1".to_string(), + SyncRecord { + project: Some("/test/project".to_string()), + cache_id: "claude-p1".to_string(), + last_activity: Some("2024-01-02T00:00:01Z".parse().unwrap()), + message_count: 2, + synced_at: "2026-07-09T00:00:00Z".parse().unwrap(), + }, + ); + save_manifest(&manifest).unwrap(); + assert_eq!(load_manifest().unwrap(), manifest); + }); + } + + #[cfg(unix)] + #[test] + fn manifest_file_is_0600() { + use std::os::unix::fs::PermissionsExt; + with_cfg(|_| { + save_manifest(&Manifest::default()).unwrap(); + let mode = std::fs::metadata(manifest_path().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + }); + } + + #[test] + fn corrupt_manifest_errors_with_hint() { + with_cfg(|_| { + save_manifest(&Manifest::default()).unwrap(); + std::fs::write(manifest_path().unwrap(), "not json").unwrap(); + let err = load_manifest().unwrap_err(); + assert!(err.to_string().contains("re-sync from scratch")); + }); + } + + #[test] + fn first_sync_ingests_then_second_is_unchanged() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + write_claude_session(home, "-test-project", "sess-bbb", "Fix a bug"); + let bundle = claude_bundle(home); + + let outcomes = sync_bundle(&bundle, &[Harness::Claude]).unwrap(); + assert_eq!(outcomes.len(), 1); + let (_, first) = outcomes[0]; + assert_eq!( + (first.new, first.updated, first.unchanged, first.failed), + (2, 0, 0, 0) + ); + + let manifest = load_manifest().unwrap(); + let records = manifest.get("claude").unwrap(); + assert_eq!(records.len(), 2); + let rec = records.get("sess-aaa").unwrap(); + assert_eq!(rec.project.as_deref(), Some("/test/project")); + assert_eq!(rec.message_count, 2); + assert!( + crate::cmd_cache::cache_path(&rec.cache_id) + .unwrap() + .exists(), + "cache doc must exist for {}", + rec.cache_id + ); + + let (_, second) = sync_bundle(&bundle, &[Harness::Claude]).unwrap()[0]; + assert_eq!( + (second.new, second.updated, second.unchanged, second.failed), + (0, 0, 2, 0) + ); + }); + } + + #[test] + fn changed_session_is_rederived() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + sync_bundle(&bundle, &[Harness::Claude]).unwrap(); + + let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] + .cache_id + .clone(); + let steps_before = cached_step_count(&cache_id); + + // Session continues: a later user turn lands in the file. + let file = home.join(".claude/projects/-test-project/sess-aaa.jsonl"); + let mut body = std::fs::read_to_string(&file).unwrap(); + body.push_str( + r#"{"type":"user","uuid":"u-2","timestamp":"2024-01-02T00:05:00Z","cwd":"/test/project","message":{"role":"user","content":"And another thing"}}"#, + ); + body.push('\n'); + std::fs::write(&file, body).unwrap(); + + let (_, outcome) = sync_bundle(&bundle, &[Harness::Claude]).unwrap()[0]; + assert_eq!( + ( + outcome.new, + outcome.updated, + outcome.unchanged, + outcome.failed + ), + (0, 1, 0, 0) + ); + assert!( + cached_step_count(&cache_id) > steps_before, + "re-derived doc must contain the appended turn" + ); + }); + } + + #[test] + fn sync_touches_only_requested_types() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + + let outcomes = sync_bundle(&bundle, &[Harness::Codex]).unwrap(); + assert_eq!(outcomes[0].1, SyncOutcome::default()); + assert!( + load_manifest().unwrap().is_empty(), + "codex-only sync must not ingest claude sessions" + ); + }); + } + + #[test] + fn sync_overwrites_cache_entry_it_does_not_remember() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + sync_bundle(&bundle, &[Harness::Claude]).unwrap(); + + // Losing the manifest (or a prior manual `p import`) leaves a + // cache entry sync doesn't know about; re-syncing must + // overwrite it, not die on the exists-check. + std::fs::remove_file(manifest_path().unwrap()).unwrap(); + let (_, outcome) = sync_bundle(&bundle, &[Harness::Claude]).unwrap()[0]; + assert_eq!((outcome.new, outcome.failed), (1, 0)); + }); + } + + #[test] + fn failed_derivation_is_tallied_and_skipped() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + let cwd = std::env::current_dir().unwrap(); + let mut rows = gather_sessions(&bundle, &cwd, Some(Harness::Claude), None); + rows.push(SessionRow { + harness: Harness::Claude, + project: Some("/test/project".to_string()), + cwd: None, + session_id: "does-not-exist".to_string(), + title: String::new(), + last_activity: None, + message_count: 0, + matches_cwd: false, + }); + + let mut records = BTreeMap::new(); + let outcome = sync_rows(&bundle, &rows, &mut records).unwrap(); + assert_eq!((outcome.new, outcome.failed), (1, 1)); + assert!(records.contains_key("sess-aaa")); + assert!( + !records.contains_key("does-not-exist"), + "failed sessions must not be recorded as synced" + ); + }); + } + + #[test] + fn derive_row_errors_when_provider_missing() { + let bundle = HarnessBundle::default(); + let row = SessionRow { + harness: Harness::Claude, + project: Some("/test/project".to_string()), + cwd: None, + session_id: "sess".to_string(), + title: String::new(), + last_activity: None, + message_count: 0, + matches_cwd: false, + }; + let Err(err) = derive_row(&bundle, &row) else { + panic!("derive_row must fail without a claude manager"); + }; + assert!(err.to_string().contains("provider not available")); + } + + #[test] + fn resolve_types_defaults_to_all_and_dedups() { + assert_eq!(resolve_types(&[]), Harness::ALL.to_vec()); + assert_eq!( + resolve_types(&[HarnessArg::Codex, HarnessArg::Claude, HarnessArg::Codex]), + vec![Harness::Codex, Harness::Claude] + ); + } + + #[test] + fn render_summary_hides_empty_types_unless_explicit() { + let outcomes = vec![ + ( + Harness::Claude, + SyncOutcome { + new: 2, + updated: 1, + unchanged: 3, + failed: 0, + }, + ), + (Harness::Cursor, SyncOutcome::default()), + ]; + let default_run = render_summary(&outcomes, false); + assert!(default_run.contains("claude")); + assert!(!default_run.contains("cursor")); + + let explicit_run = render_summary(&outcomes, true); + assert!(explicit_run.contains("cursor")); + } + + #[test] + fn render_summary_shows_failures_and_empty_case() { + let outcomes = vec![( + Harness::Codex, + SyncOutcome { + new: 0, + updated: 0, + unchanged: 1, + failed: 2, + }, + )]; + let s = render_summary(&outcomes, false); + assert!(s.contains("2 failed")); + + assert_eq!(render_summary(&[], false), "nothing to sync\n"); + } +} diff --git a/crates/path-cli/tests/integration.rs b/crates/path-cli/tests/integration.rs index 375b491a..b7b20160 100644 --- a/crates/path-cli/tests/integration.rs +++ b/crates/path-cli/tests/integration.rs @@ -658,6 +658,108 @@ fn cache_ls_after_import_lists_entry() { .stdout(predicate::str::contains("git-")); } +/// A `$HOME` with one Claude session. Returns (home-tempdir, session file). +fn claude_home_fixture() -> (tempfile::TempDir, PathBuf) { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("proj"); + std::fs::create_dir_all(&project).unwrap(); + // toolpath-claude maps '/', '_', and '.' to '-' when sanitizing project + // paths into directory slugs — mirror that here so the fixture lands + // where the resolver looks for it. + let project_slug = project + .to_string_lossy() + .replace([std::path::MAIN_SEPARATOR, '_', '.'], "-"); + let project_dir = temp.path().join(".claude/projects").join(&project_slug); + std::fs::create_dir_all(&project_dir).unwrap(); + let session_file = project_dir.join("session-abc.jsonl"); + std::fs::write( + &session_file, + format!( + r#"{{"type":"user","uuid":"u-1","timestamp":"2024-01-01T00:00:00Z","cwd":"{cwd}","message":{{"role":"user","content":"hi"}}}} +{{"type":"assistant","uuid":"a-1","timestamp":"2024-01-01T00:00:01Z","message":{{"role":"assistant","content":"hello"}}}} +"#, + cwd = project.display() + ), + ) + .unwrap(); + (temp, session_file) +} + +#[test] +fn cache_sync_ingests_reskips_and_updates() { + let (home, session_file) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + let sync = || { + let mut c = cmd(); + c.env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "cache", "sync", "claude"]); + c + }; + + // First run derives the session into the cache and records it. + sync() + .assert() + .success() + .stderr(predicate::str::contains("1 new, 0 updated, 0 unchanged")); + let manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(cfg.path().join("sync.json")).unwrap()) + .unwrap(); + let record = &manifest["claude"]["session-abc"]; + let cache_id = record["cache_id"].as_str().unwrap(); + assert!( + cfg.path() + .join(format!("documents/{cache_id}.json")) + .exists() + ); + + // Nothing changed: the second run derives nothing. + sync() + .assert() + .success() + .stderr(predicate::str::contains("0 new, 0 updated, 1 unchanged")); + + // The session grows a turn; the third run re-derives it. + let mut body = std::fs::read_to_string(&session_file).unwrap(); + body.push_str( + r#"{"type":"user","uuid":"u-2","timestamp":"2024-01-01T00:05:00Z","cwd":"/x","message":{"role":"user","content":"more"}}"#, + ); + body.push('\n'); + std::fs::write(&session_file, body).unwrap(); + sync() + .assert() + .success() + .stderr(predicate::str::contains("0 new, 1 updated, 0 unchanged")); +} + +#[test] +fn cache_sync_default_run_with_no_sessions_reports_nothing() { + let home = tempfile::tempdir().unwrap(); + let cfg = tempfile::tempdir().unwrap(); + cmd() + .env("HOME", home.path()) + // opencode resolves through $XDG_DATA_HOME before $HOME — drop it + // so the sandboxed run can't see the developer's real database. + .env_remove("XDG_DATA_HOME") + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "cache", "sync"]) + .assert() + .success() + .stderr(predicate::str::contains("nothing to sync")); + assert!(!cfg.path().join("sync.json").exists()); +} + +#[test] +fn cache_sync_rejects_unknown_type() { + let cfg = tempfile::tempdir().unwrap(); + cmd() + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "cache", "sync", "frobnicate"]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid value")); +} + #[test] fn export_pathbase_repo_flag_requires_login() { // `export pathbase` without --repo falls through to the anonymous diff --git a/crates/toolpath-cli/Cargo.toml b/crates/toolpath-cli/Cargo.toml index dd3ddc7f..54cf9fe6 100644 --- a/crates/toolpath-cli/Cargo.toml +++ b/crates/toolpath-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-cli" -version = "0.15.0" +version = "0.16.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/empathic/toolpath" @@ -14,7 +14,7 @@ name = "path" path = "src/main.rs" [dependencies] -path-cli = { path = "../path-cli", version = "0.15.0" } +path-cli = { path = "../path-cli", version = "0.16.0" } anyhow = "1.0" [workspace] diff --git a/site/_data/crates.json b/site/_data/crates.json index dc86dffe..2ba774e7 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -113,7 +113,7 @@ }, { "name": "path-cli", - "version": "0.15.0", + "version": "0.16.0", "description": "Unified CLI (binary: path)", "docs": "https://docs.rs/path-cli", "crate": "https://crates.io/crates/path-cli", @@ -121,7 +121,7 @@ }, { "name": "toolpath-cli", - "version": "0.15.0", + "version": "0.16.0", "description": "Deprecated alias for path-cli", "docs": "https://docs.rs/toolpath-cli", "crate": "https://crates.io/crates/toolpath-cli", From 6f7191eed6b60df1824ad77625a7c3dcf42da13e Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 9 Jul 2026 11:27:18 -0400 Subject: [PATCH 02/20] =?UTF-8?q?refactor(cli):=20rename=20SessionRow=20?= =?UTF-8?q?=E2=86=92=20ArtifactRow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions are just the first artifact kind sync and share operate on; the row type shouldn't bake that in. Fields keep their session-specific names until a second artifact kind actually lands. --- crates/path-cli/src/cmd_share.rs | 41 ++++++++++++++++---------------- crates/path-cli/src/sync.rs | 10 ++++---- 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index 4c5a1ed0..bc8d73fa 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -149,9 +149,10 @@ impl Harness { } } -/// One row in the unified session picker. +/// One artifact surfaced by a provider — today always an agent session. +/// Rows feed both the unified `share` picker and `p cache sync`. #[derive(Debug, Clone)] -pub(crate) struct SessionRow { +pub(crate) struct ArtifactRow { pub(crate) harness: Harness, /// Project path for keyed providers; `None` for codex/opencode. pub(crate) project: Option, @@ -207,7 +208,7 @@ pub(crate) fn gather_sessions( cwd: &std::path::Path, harness_filter: Option, project_filter: Option<&std::path::Path>, -) -> Vec { +) -> Vec { let mut rows = Vec::new(); let canonical_cwd = canonicalize_or_self(cwd); let canonical_project = project_filter.map(canonicalize_or_self); @@ -270,7 +271,7 @@ fn collect_claude( mgr: &toolpath_claude::ClaudeConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { let projects = match mgr.list_projects() { Ok(ps) if !ps.is_empty() => ps, @@ -297,7 +298,7 @@ fn collect_claude( }; let matches_cwd = paths_match(project_path, canonical_cwd); for m in metas { - out.push(SessionRow { + out.push(ArtifactRow { harness: Harness::Claude, project: Some(m.project_path), cwd: None, @@ -317,7 +318,7 @@ fn collect_gemini( mgr: &toolpath_gemini::GeminiConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { let projects = match mgr.list_projects() { Ok(ps) if !ps.is_empty() => ps, @@ -344,7 +345,7 @@ fn collect_gemini( }; let matches_cwd = paths_match(project_path, canonical_cwd); for m in metas { - out.push(SessionRow { + out.push(ArtifactRow { harness: Harness::Gemini, project: Some(m.project_path), cwd: None, @@ -364,7 +365,7 @@ fn collect_pi( mgr: &toolpath_pi::PiConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { let projects = match mgr.list_projects() { Ok(ps) if !ps.is_empty() => ps, @@ -395,7 +396,7 @@ fn collect_pi( let last_activity = chrono::DateTime::parse_from_rfc3339(&m.timestamp) .ok() .map(|d| d.with_timezone(&Utc)); - out.push(SessionRow { + out.push(ArtifactRow { harness: Harness::Pi, project: Some(project.clone()), cwd: None, @@ -415,7 +416,7 @@ fn collect_codex( mgr: &toolpath_codex::CodexConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { let metas = match mgr.list_sessions() { Ok(m) if !m.is_empty() => m, @@ -442,7 +443,7 @@ fn collect_codex( .as_deref() .map(|p| paths_match(p, canonical_cwd)) .unwrap_or(false); - out.push(SessionRow { + out.push(ArtifactRow { harness: Harness::Codex, project: None, cwd: cwd_str, @@ -504,7 +505,7 @@ fn collect_opencode( mgr: &toolpath_opencode::OpencodeConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { let metas = match mgr.io().list_session_metadata(None) { Ok(m) if !m.is_empty() => m, @@ -528,7 +529,7 @@ fn collect_opencode( (_, false) => m.title.clone(), _ => "(no prompt)".to_string(), }; - out.push(SessionRow { + out.push(ArtifactRow { harness: Harness::Opencode, project: None, cwd: Some(cwd_str), @@ -545,7 +546,7 @@ fn collect_cursor( mgr: &toolpath_cursor::CursorConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { let metas = match mgr.io().list_session_metadata() { Ok(m) if !m.is_empty() => m, @@ -577,7 +578,7 @@ fn collect_cursor( (_, Some(n)) if !n.is_empty() => n.clone(), _ => "(no prompt)".to_string(), }; - out.push(SessionRow { + out.push(ArtifactRow { harness: Harness::Cursor, project: None, cwd: Some(cwd_str), @@ -1012,7 +1013,7 @@ fn share_explicit( /// The display column is space-padded rather than tab-separated so the /// columns line up consistently across pickers — terminal tab stops /// produce ugly variable gaps in both fzf and skim. -fn format_picker_row(row: &SessionRow) -> String { +fn format_picker_row(row: &ArtifactRow) -> String { let key = row .project .clone() @@ -1336,7 +1337,7 @@ mod tests { #[test] #[cfg(unix)] fn paths_match_canonicalizes_through_symlink() { - // `paths_match` is the function that produces `SessionRow.matches_cwd` + // `paths_match` is the function that produces `ArtifactRow.matches_cwd` // (collect_* all delegate to it). Without canonicalization, a user who // navigated to a project via a symlink would see their cwd-row sink // in the picker because the symlink path string ≠ the project path @@ -1377,7 +1378,7 @@ mod tests { #[test] fn parse_picker_row_roundtrips_keyed() { - let row = SessionRow { + let row = ArtifactRow { harness: Harness::Claude, project: Some("/tmp/proj".to_string()), cwd: None, @@ -1399,7 +1400,7 @@ mod tests { #[test] fn parse_picker_row_roundtrips_session_keyed() { - let row = SessionRow { + let row = ArtifactRow { harness: Harness::Codex, project: None, cwd: Some("/work/proj".to_string()), @@ -1419,7 +1420,7 @@ mod tests { #[test] fn parse_picker_row_carries_title_with_unicode() { - let row = SessionRow { + let row = ArtifactRow { harness: Harness::Gemini, project: Some("/work/proj".to_string()), cwd: None, diff --git a/crates/path-cli/src/sync.rs b/crates/path-cli/src/sync.rs index 3a116672..ca72ea09 100644 --- a/crates/path-cli/src/sync.rs +++ b/crates/path-cli/src/sync.rs @@ -21,7 +21,7 @@ use std::path::PathBuf; use crate::cmd_cache::write_cached; use crate::cmd_import::DerivedDoc; -use crate::cmd_share::{Harness, HarnessArg, HarnessBundle, SessionRow, gather_sessions}; +use crate::cmd_share::{ArtifactRow, Harness, HarnessArg, HarnessBundle, gather_sessions}; use crate::config::config_dir; const MANIFEST_FILE: &str = "sync.json"; @@ -116,7 +116,7 @@ pub(crate) fn sync_bundle( /// (disk, permissions) abort. fn sync_rows( bundle: &HarnessBundle, - rows: &[SessionRow], + rows: &[ArtifactRow], records: &mut BTreeMap, ) -> Result { let mut outcome = SyncOutcome::default(); @@ -167,7 +167,7 @@ fn sync_rows( /// Derive one session through the same manager the row was enumerated /// from, so listing and derivation always agree on provider roots. -fn derive_row(bundle: &HarnessBundle, row: &SessionRow) -> Result { +fn derive_row(bundle: &HarnessBundle, row: &ArtifactRow) -> Result { use crate::cmd_import as imp; let project = || { row.project @@ -485,7 +485,7 @@ mod tests { let bundle = claude_bundle(home); let cwd = std::env::current_dir().unwrap(); let mut rows = gather_sessions(&bundle, &cwd, Some(Harness::Claude), None); - rows.push(SessionRow { + rows.push(ArtifactRow { harness: Harness::Claude, project: Some("/test/project".to_string()), cwd: None, @@ -510,7 +510,7 @@ mod tests { #[test] fn derive_row_errors_when_provider_missing() { let bundle = HarnessBundle::default(); - let row = SessionRow { + let row = ArtifactRow { harness: Harness::Claude, project: Some("/test/project".to_string()), cwd: None, From 7bee315623cf07bb40bbf6626e0dd5e633bff50d Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 9 Jul 2026 11:44:30 -0400 Subject: [PATCH 03/20] refactor(cli): one ArtifactType enum; generalize ArtifactRow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ArtifactType (in sync.rs, pub, clap::ValueEnum) replaces the Harness/HarnessArg pair and is now the single enum naming artifact sources everywhere: p cache sync types, share/resume --harness, resume's source inference, and cmd_import's cache-id prefixes (the five wrap_paths_* clones collapse into one wrap_paths keyed on it). It lives ungated so emscripten builds of cmd_import still see it; the sync engine sits behind the cfg in a private submodule. ArtifactRow sheds its session-specific shape: harness → artifact_type, project → path, and message_count is gone — last_activity alone is the sync fingerprint, and the manifest record slims to {path?, cache_id, last_activity?, synced_at}. Pi's listing reports session start rather than last activity, so collect_pi now stats the session file's mtime; without that (or the old message_count), a growing pi session would never re-sync. --- CHANGELOG.md | 15 +- CLAUDE.md | 3 +- crates/path-cli/src/cmd_cache.rs | 2 +- crates/path-cli/src/cmd_import.rs | 153 ++-- crates/path-cli/src/cmd_resume.rs | 164 ++-- crates/path-cli/src/cmd_share.rs | 341 ++------ crates/path-cli/src/lib.rs | 3 +- crates/path-cli/src/sync.rs | 1138 ++++++++++++++------------ crates/path-cli/tests/resume.rs | 21 +- crates/path-cli/tests/support/mod.rs | 5 +- 10 files changed, 879 insertions(+), 966 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f44c78d..70bd4100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,12 +16,15 @@ users no longer have to `p import` each session by hand. derivation goes through the same provider managers, so listing and derivation always agree on provider roots. - The sync manifest at `~/.toolpath/sync.json` maps artifact type → - session id → `{project?, cache_id, last_activity?, message_count, - synced_at}`. A session is re-derived when its `last_activity` or - `message_count` differs from the record; otherwise it is skipped. - The manifest is written atomically (temp file + rename, `0600`) and - checkpointed after each type, so an interrupted first run keeps the - types it finished. + artifact id → `{path?, cache_id, last_activity?, synced_at}`. An + artifact is re-derived when its `last_activity` differs from the + record; otherwise it is skipped. The manifest is written atomically + (temp file + rename, `0600`) and checkpointed after each type, so an + interrupted first run keeps the types it finished. + - `ArtifactType` (in `sync.rs`) is the single enum naming artifact + sources, used everywhere the CLI names them: `p cache sync` types, + `share`/`resume` `--harness`, and import cache-id prefixes. It + replaces the former `Harness`/`HarnessArg` pair. - Sync owns refresh semantics: it overwrites cache entries it re-derives (no `--force` needed), while manual `p import` keeps its error-on-hit default. Sessions deleted upstream keep both their cache diff --git a/CLAUDE.md b/CLAUDE.md index 3e6d748f..780239dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,4 +278,5 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. -- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests agent sessions into the cache — no args syncs all six harnesses. It enumerates via the `path share` aggregation (`cmd_share::gather_sessions`) and derives each session through the *same* provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`), so listing and derivation agree on provider roots and tests can inject fixture resolvers. A session is re-derived only when its fingerprint (`last_activity` + `message_count`) differs from the manifest at `~/.toolpath/sync.json` (artifact type → session id → `{project?, cache_id, last_activity?, message_count, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type). Sync always writes the cache with force — refresh semantics — and never deletes: sessions removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. Caveats: enumeration costs the providers' metadata pass (full parse per JSONL session file — a stat-level fast path is the planned optimization before `path query` runs sync implicitly), and cursor composers without a workspace are skipped, same as `share`. +- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs all six agent harnesses. It enumerates via the `path share` aggregation (`cmd_share::gather_artifacts`, producing `ArtifactRow`s) and derives each session through the *same* provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`), so listing and derivation agree on provider roots and tests can inject fixture resolvers. An artifact is re-derived only when its `last_activity` fingerprint differs from the manifest at `~/.toolpath/sync.json` (artifact type → artifact id → `{path?, cache_id, last_activity?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type). Pi's listing reports session *start* time, so its rows use file mtime as `last_activity`. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. Caveats: enumeration costs the providers' metadata pass (full parse per JSONL session file — a stat-level fast path is the planned optimization before `path query` runs sync implicitly), and cursor composers without a workspace are skipped, same as `share`. +- `ArtifactType` (`crates/path-cli/src/sync.rs`) is the one enum naming artifact sources — claude/gemini/codex/opencode/cursor/pi today, other kinds (git, github) when sync learns them. It derives `clap::ValueEnum` and is used directly by `p cache sync` types, `share`/`resume` `--harness`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). There is deliberately no parallel `Harness`/`HarnessArg` enum anywhere; don't add one. diff --git a/crates/path-cli/src/cmd_cache.rs b/crates/path-cli/src/cmd_cache.rs index 88bfe854..5b584280 100644 --- a/crates/path-cli/src/cmd_cache.rs +++ b/crates/path-cli/src/cmd_cache.rs @@ -29,7 +29,7 @@ pub enum CacheOp { Sync { /// Artifact types to sync (default: every agent harness) #[arg(value_enum)] - types: Vec, + types: Vec, }, } diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index c191c279..4549d249 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use toolpath::v1::Graph; use crate::cmd_cache::{make_id, write_cached}; +use crate::sync::ArtifactType; #[derive(Subcommand, Debug)] pub enum ImportSource { @@ -421,7 +422,10 @@ fn derive_claude_with_manager( .read_all_conversations(&p) .map_err(|e| anyhow::anyhow!("{}", e))?; let cfg = make_config(&p); - return wrap_paths_claude(toolpath_claude::derive::derive_project(&convos, &cfg)); + return wrap_paths( + ArtifactType::Claude, + toolpath_claude::derive::derive_project(&convos, &cfg), + ); } (Some(p), None, false) => { #[cfg(not(target_os = "emscripten"))] @@ -436,9 +440,10 @@ fn derive_claude_with_manager( anyhow::anyhow!("No conversations found for project: {}", p) })?; let cfg = make_config(&p); - return wrap_paths_claude(vec![toolpath_claude::derive::derive_path( - &convo, &cfg, - )]); + return wrap_paths( + ArtifactType::Claude, + vec![toolpath_claude::derive::derive_path(&convo, &cfg)], + ); } } #[cfg(target_os = "emscripten")] @@ -448,7 +453,10 @@ fn derive_claude_with_manager( .map_err(|e| anyhow::anyhow!("{}", e))? .ok_or_else(|| anyhow::anyhow!("No conversations found for project: {}", p))?; let cfg = make_config(&p); - return wrap_paths_claude(vec![toolpath_claude::derive::derive_path(&convo, &cfg)]); + return wrap_paths( + ArtifactType::Claude, + vec![toolpath_claude::derive::derive_path(&convo, &cfg)], + ); } } (None, _, _) => { @@ -477,7 +485,7 @@ fn derive_claude_with_manager( let cfg = make_config(project_path); paths.push(toolpath_claude::derive::derive_path(&convo, &cfg)); } - wrap_paths_claude(paths) + wrap_paths(ArtifactType::Claude, paths) } /// Derive a single Claude conversation given an explicit project + session. @@ -503,18 +511,18 @@ pub(crate) fn derive_claude_session_with( .read_conversation(project, session) .map_err(|e| anyhow::anyhow!("{}", e))?; let path = toolpath_claude::derive::derive_path(&convo, &cfg); - let cache_id = make_id("claude", &path.path.id); + let cache_id = make_id(ArtifactType::Claude.name(), &path.path.id); Ok(DerivedDoc { cache_id, doc: Graph::from_path(path), }) } -fn wrap_paths_claude(paths: Vec) -> Result> { +fn wrap_paths(t: ArtifactType, paths: Vec) -> Result> { Ok(paths .into_iter() .map(|p| { - let cache_id = make_id("claude", &p.path.id); + let cache_id = make_id(t.name(), &p.path.id); DerivedDoc { cache_id, doc: Graph::from_path(p), @@ -654,7 +662,10 @@ fn derive_gemini_with_manager( .read_all_conversations(&p) .map_err(|e| anyhow::anyhow!("{}", e))?; let cfg = make_config(&p); - return wrap_paths_gemini(toolpath_gemini::derive::derive_project(&convos, &cfg)); + return wrap_paths( + ArtifactType::Gemini, + toolpath_gemini::derive::derive_project(&convos, &cfg), + ); } (Some(p), None, false) => { #[cfg(not(target_os = "emscripten"))] @@ -669,9 +680,10 @@ fn derive_gemini_with_manager( anyhow::anyhow!("No conversations found for project: {}", p) })?; let cfg = make_config(&p); - return wrap_paths_gemini(vec![toolpath_gemini::derive::derive_path( - &convo, &cfg, - )]); + return wrap_paths( + ArtifactType::Gemini, + vec![toolpath_gemini::derive::derive_path(&convo, &cfg)], + ); } } #[cfg(target_os = "emscripten")] @@ -681,7 +693,10 @@ fn derive_gemini_with_manager( .map_err(|e| anyhow::anyhow!("{}", e))? .ok_or_else(|| anyhow::anyhow!("No conversations found for project: {}", p))?; let cfg = make_config(&p); - return wrap_paths_gemini(vec![toolpath_gemini::derive::derive_path(&convo, &cfg)]); + return wrap_paths( + ArtifactType::Gemini, + vec![toolpath_gemini::derive::derive_path(&convo, &cfg)], + ); } } (None, _, _) => { @@ -710,7 +725,7 @@ fn derive_gemini_with_manager( let cfg = make_config(project_path); paths.push(toolpath_gemini::derive::derive_path(&convo, &cfg)); } - wrap_paths_gemini(paths) + wrap_paths(ArtifactType::Gemini, paths) } /// Derive a single Gemini conversation given an explicit project + session. @@ -742,26 +757,13 @@ pub(crate) fn derive_gemini_session_with( .read_conversation(project, session) .map_err(|e| anyhow::anyhow!("{}", e))?; let path = toolpath_gemini::derive::derive_path(&convo, &cfg); - let cache_id = make_id("gemini", &path.path.id); + let cache_id = make_id(ArtifactType::Gemini.name(), &path.path.id); Ok(DerivedDoc { cache_id, doc: Graph::from_path(path), }) } -fn wrap_paths_gemini(paths: Vec) -> Result> { - Ok(paths - .into_iter() - .map(|p| { - let cache_id = make_id("gemini", &p.path.id); - DerivedDoc { - cache_id, - doc: Graph::from_path(p), - } - }) - .collect()) -} - #[cfg(not(target_os = "emscripten"))] fn pick_gemini_in_project( manager: &toolpath_gemini::GeminiConvo, @@ -875,7 +877,10 @@ fn derive_codex(session: Option, all: bool) -> Result> { if sessions.is_empty() { anyhow::bail!("No Codex sessions found in ~/.codex/sessions"); } - return wrap_paths_codex(toolpath_codex::derive::derive_project(&sessions, &config)); + return wrap_paths( + ArtifactType::Codex, + toolpath_codex::derive::derive_project(&sessions, &config), + ); } (None, false) => { #[cfg(not(target_os = "emscripten"))] @@ -889,9 +894,10 @@ fn derive_codex(session: Option, all: bool) -> Result> { .ok_or_else(|| { anyhow::anyhow!("No Codex sessions found in ~/.codex/sessions") })?; - return wrap_paths_codex(vec![toolpath_codex::derive::derive_path( - &s, &config, - )]); + return wrap_paths( + ArtifactType::Codex, + vec![toolpath_codex::derive::derive_path(&s, &config)], + ); } } } @@ -903,7 +909,10 @@ fn derive_codex(session: Option, all: bool) -> Result> { .ok_or_else(|| { anyhow::anyhow!("No Codex sessions found in ~/.codex/sessions") })?; - return wrap_paths_codex(vec![toolpath_codex::derive::derive_path(&s, &config)]); + return wrap_paths( + ArtifactType::Codex, + vec![toolpath_codex::derive::derive_path(&s, &config)], + ); } } }; @@ -915,7 +924,7 @@ fn derive_codex(session: Option, all: bool) -> Result> { .map_err(|e| anyhow::anyhow!("{}", e))?; paths.push(toolpath_codex::derive::derive_path(&s, &config)); } - wrap_paths_codex(paths) + wrap_paths(ArtifactType::Codex, paths) } /// Derive a single Codex session given an explicit session id. @@ -933,26 +942,13 @@ pub(crate) fn derive_codex_session_with( .read_session(session) .map_err(|e| anyhow::anyhow!("{}", e))?; let path = toolpath_codex::derive::derive_path(&s, &config); - let cache_id = make_id("codex", &path.path.id); + let cache_id = make_id(ArtifactType::Codex.name(), &path.path.id); Ok(DerivedDoc { cache_id, doc: Graph::from_path(path), }) } -fn wrap_paths_codex(paths: Vec) -> Result> { - Ok(paths - .into_iter() - .map(|p| { - let cache_id = make_id("codex", &p.path.id); - DerivedDoc { - cache_id, - doc: Graph::from_path(p), - } - }) - .collect()) -} - #[cfg(not(target_os = "emscripten"))] fn pick_codex(manager: &toolpath_codex::CodexConvo) -> Result>> { if !fuzzy::available() { @@ -1177,7 +1173,7 @@ fn derive_opencode( for m in &metas { out.push(derive_one(&m.id)?); } - return wrap_paths_opencode(out); + return wrap_paths(ArtifactType::Opencode, out); } (None, false) => match pick_opencode(&manager, project.as_deref())? { Some(picks) => picks, @@ -1186,13 +1182,14 @@ fn derive_opencode( .most_recent_session() .map_err(|e| anyhow::anyhow!("{}", e))? .ok_or_else(|| anyhow::anyhow!("No opencode sessions found"))?; - return wrap_paths_opencode(vec![ - toolpath_opencode::derive::derive_path_with_resolver( + return wrap_paths( + ArtifactType::Opencode, + vec![toolpath_opencode::derive::derive_path_with_resolver( &s, &config, manager.resolver(), - ), - ]); + )], + ); } }, }; @@ -1201,7 +1198,7 @@ fn derive_opencode( for sid in &session_ids { paths.push(derive_one(sid)?); } - wrap_paths_opencode(paths) + wrap_paths(ArtifactType::Opencode, paths) } } @@ -1234,26 +1231,13 @@ pub(crate) fn derive_opencode_session_with( .map_err(|e| anyhow::anyhow!("{}", e))?; let path = toolpath_opencode::derive::derive_path_with_resolver(&s, &config, manager.resolver()); - let cache_id = make_id("opencode", &path.path.id); + let cache_id = make_id(ArtifactType::Opencode.name(), &path.path.id); Ok(DerivedDoc { cache_id, doc: Graph::from_path(path), }) } -fn wrap_paths_opencode(paths: Vec) -> Result> { - Ok(paths - .into_iter() - .map(|p| { - let cache_id = make_id("opencode", &p.path.id); - DerivedDoc { - cache_id, - doc: Graph::from_path(p), - } - }) - .collect()) -} - #[cfg(not(target_os = "emscripten"))] fn pick_opencode( manager: &toolpath_opencode::OpencodeConvo, @@ -1362,7 +1346,7 @@ fn derive_cursor( for m in &filtered { out.push(derive_one(&m.id)?); } - return wrap_paths_cursor(out); + return wrap_paths(ArtifactType::Cursor, out); } (None, false) => match pick_cursor(&manager, workspace_filter.as_deref())? { Some(picks) => picks, @@ -1381,7 +1365,7 @@ fn derive_cursor( .unwrap_or_else(chrono::DateTime::::default) }) .ok_or_else(|| anyhow::anyhow!("No Cursor composers found"))?; - return wrap_paths_cursor(vec![derive_one(&pick.id)?]); + return wrap_paths(ArtifactType::Cursor, vec![derive_one(&pick.id)?]); } }, }; @@ -1390,7 +1374,7 @@ fn derive_cursor( for sid in &session_ids { paths.push(derive_one(sid)?); } - wrap_paths_cursor(paths) + wrap_paths(ArtifactType::Cursor, paths) } } @@ -1411,26 +1395,13 @@ pub(crate) fn derive_cursor_session_with( .map_err(|e| anyhow::anyhow!("{}", e))?; let cfg = toolpath_cursor::DeriveConfig::default(); let path = toolpath_cursor::derive_path(&s, &cfg); - let cache_id = make_id("cursor", &path.path.id); + let cache_id = make_id(ArtifactType::Cursor.name(), &path.path.id); Ok(DerivedDoc { cache_id, doc: Graph::from_path(path), }) } -fn wrap_paths_cursor(paths: Vec) -> Result> { - Ok(paths - .into_iter() - .map(|p| { - let cache_id = make_id("cursor", &p.path.id); - DerivedDoc { - cache_id, - doc: Graph::from_path(p), - } - }) - .collect()) -} - #[cfg(not(target_os = "emscripten"))] fn pick_cursor( manager: &toolpath_cursor::CursorConvo, @@ -1533,7 +1504,7 @@ fn derive_pi_with_manager( anyhow::bail!("No Pi sessions found for project: {}", p); } let doc = toolpath_pi::derive::derive_graph(&sessions, None, &config); - let cache_id = make_id("pi", &doc_inner_id(&doc)); + let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); return Ok(vec![DerivedDoc { cache_id, doc }]); } (Some(p), None, false) => { @@ -1549,7 +1520,7 @@ fn derive_pi_with_manager( anyhow::anyhow!("No Pi sessions found for project: {}", p) })?; let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); - let cache_id = make_id("pi", &doc_inner_id(&doc)); + let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); return Ok(vec![DerivedDoc { cache_id, doc }]); } } @@ -1560,7 +1531,7 @@ fn derive_pi_with_manager( .map_err(|e| anyhow::anyhow!("{}", e))? .ok_or_else(|| anyhow::anyhow!("No Pi sessions found for project: {}", p))?; let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); - let cache_id = make_id("pi", &doc_inner_id(&doc)); + let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); return Ok(vec![DerivedDoc { cache_id, doc }]); } } @@ -1588,7 +1559,7 @@ fn derive_pi_with_manager( .read_session(project_path, session_id) .map_err(|e| anyhow::anyhow!("{}", e))?; let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); - let cache_id = make_id("pi", &doc_inner_id(&doc)); + let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); docs.push(DerivedDoc { cache_id, doc }); } Ok(docs) @@ -1620,7 +1591,7 @@ pub(crate) fn derive_pi_session_with( .read_session(project, session) .map_err(|e| anyhow::anyhow!("{}", e))?; let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); - let cache_id = make_id("pi", &doc_inner_id(&doc)); + let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); Ok(DerivedDoc { cache_id, doc }) } diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index ae9e21d4..c835bab5 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -44,10 +44,7 @@ use anyhow::{Context, Result}; use clap::Args; use std::path::PathBuf; -/// Re-exported so external callers (integration tests, future consumers) -/// can construct [`ResumeArgs`] without depending on the `cmd_share` -/// module directly. -pub use crate::cmd_share::HarnessArg; +use crate::sync::ArtifactType; #[derive(Args, Debug)] pub struct ResumeArgs { @@ -65,7 +62,7 @@ pub struct ResumeArgs { /// Pin the resume target. Skips the interactive picker. #[arg(long, value_enum)] - pub harness: Option, + pub harness: Option, /// Skip the cache entirely when fetching from Pathbase: don't read /// an existing entry, don't write the fetched body. Useful for @@ -123,40 +120,38 @@ use toolpath::v1::{Graph, Path as TPath, PathOrRef}; /// Read a path's source harness from `meta.source` (set by /// `toolpath-convo::derive_path` to the provider id), falling back to /// actor-string sniffing across the path's steps. -pub(crate) fn infer_source_harness(path: &TPath) -> Option { - use crate::cmd_share::Harness; +pub(crate) fn infer_source_harness(path: &TPath) -> Option { let meta_source = path.meta.as_ref().and_then(|m| m.source.as_deref()); if let Some(source) = meta_source { match source { - "claude-code" => return Some(Harness::Claude), - "gemini-cli" => return Some(Harness::Gemini), - "codex" => return Some(Harness::Codex), - "copilot" => return Some(Harness::Copilot), - "opencode" => return Some(Harness::Opencode), - "cursor" => return Some(Harness::Cursor), - "pi" => return Some(Harness::Pi), + "claude-code" => return Some(ArtifactType::Claude), + "gemini-cli" => return Some(ArtifactType::Gemini), + "codex" => return Some(ArtifactType::Codex), + "opencode" => return Some(ArtifactType::Opencode), + "cursor" => return Some(ArtifactType::Cursor), + "pi" => return Some(ArtifactType::Pi), _ => {} // fall through to actor sniffing } } for step in &path.steps { let actor = &step.step.actor; if actor.starts_with("agent:claude-code") { - return Some(Harness::Claude); + return Some(ArtifactType::Claude); } if actor.starts_with("agent:gemini-cli") || actor.starts_with("agent:gemini") { - return Some(Harness::Gemini); + return Some(ArtifactType::Gemini); } if actor.starts_with("agent:codex") { - return Some(Harness::Codex); + return Some(ArtifactType::Codex); } if actor.starts_with("agent:opencode") { - return Some(Harness::Opencode); + return Some(ArtifactType::Opencode); } if actor.starts_with("agent:cursor") { - return Some(Harness::Cursor); + return Some(ArtifactType::Cursor); } if actor.starts_with("agent:pi") { - return Some(Harness::Pi); + return Some(ArtifactType::Pi); } } None @@ -197,9 +192,7 @@ pub(crate) fn ensure_path_with_agent(g: &Graph) -> Result<&TPath> { /// Resolve the user-supplied `` argument into a parsed `Graph` /// plus the source harness inferred from its single inline path (if /// any). See spec § "Input resolution" for the order. -pub(crate) fn resolve_input( - args: &ResumeArgs, -) -> Result<(Graph, Option)> { +pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option)> { let raw = args.input.as_str(); enum Shape<'a> { @@ -303,14 +296,13 @@ pub(crate) fn binary_on_path(name: &str, path_override: Option<&std::path::Path> /// (macOS) / `xdg-open` (Linux) always work. Treat cursor as available /// when either path is open. pub(crate) fn harness_available( - harness: crate::cmd_share::Harness, + harness: ArtifactType, path_override: Option<&std::path::Path>, ) -> bool { - use crate::cmd_share::Harness; if binary_on_path(harness.name(), path_override) { return true; } - if harness == Harness::Cursor { + if harness == ArtifactType::Cursor { #[cfg(target_os = "macos")] { return binary_on_path("open", path_override); @@ -323,14 +315,13 @@ pub(crate) fn harness_available( false } -const ALL_HARNESSES: &[crate::cmd_share::Harness] = &[ - crate::cmd_share::Harness::Claude, - crate::cmd_share::Harness::Gemini, - crate::cmd_share::Harness::Codex, - crate::cmd_share::Harness::Copilot, - crate::cmd_share::Harness::Opencode, - crate::cmd_share::Harness::Cursor, - crate::cmd_share::Harness::Pi, +const ALL_HARNESSES: &[ArtifactType] = &[ + ArtifactType::Claude, + ArtifactType::Gemini, + ArtifactType::Codex, + ArtifactType::Opencode, + ArtifactType::Cursor, + ArtifactType::Pi, ]; /// Decide which harness to resume in. @@ -341,14 +332,11 @@ const ALL_HARNESSES: &[crate::cmd_share::Harness] = &[ /// /// `path_override` is `None` in production; tests pass `Some(dir)` to fake `$PATH`. pub(crate) fn pick_harness( - arg: Option, - source: Option, + arg: Option, + source: Option, path_override: Option<&std::path::Path>, -) -> Result { - use crate::cmd_share::Harness; - - if let Some(a) = arg { - let h = Harness::from_arg(a); +) -> Result { + if let Some(h) = arg { if !harness_available(h, path_override) { anyhow::bail!( "harness `{}` isn't on PATH; install it or pick another with `--harness`", @@ -358,7 +346,7 @@ pub(crate) fn pick_harness( return Ok(h); } - let installed: Vec = ALL_HARNESSES + let installed: Vec = ALL_HARNESSES .iter() .copied() .filter(|h| harness_available(*h, path_override)) @@ -374,9 +362,9 @@ pub(crate) fn pick_harness( } fn interactive_pick( - installed: &[crate::cmd_share::Harness], - source: Option, -) -> Result { + installed: &[ArtifactType], + source: Option, +) -> Result { if !crate::fuzzy::available() { let hint = if crate::fuzzy::embedded_picker_available() { "rerun in a terminal" @@ -421,32 +409,29 @@ fn interactive_pick( /// Static map from harness to resume-argv shape. Lives here because /// it's a per-harness CLI convention, not a projection concern. -pub(crate) fn argv_for(harness: crate::cmd_share::Harness, session_id: &str) -> Vec { - use crate::cmd_share::Harness; +pub(crate) fn argv_for(harness: ArtifactType, session_id: &str) -> Vec { match harness { - Harness::Claude => vec!["-r".into(), session_id.into()], - Harness::Gemini => vec!["--resume".into(), session_id.into()], - Harness::Codex => vec!["resume".into(), session_id.into()], - Harness::Copilot => vec!["--resume".into(), session_id.into()], - Harness::Opencode => vec!["--session".into(), session_id.into()], + ArtifactType::Claude => vec!["-r".into(), session_id.into()], + ArtifactType::Gemini => vec!["--resume".into(), session_id.into()], + ArtifactType::Codex => vec!["resume".into(), session_id.into()], + ArtifactType::Opencode => vec!["--session".into(), session_id.into()], // Cursor.app has no "open composer by id" flag — we exec the // workspace path so Cursor opens on that folder; the projected // composer appears at the top of the chat list. - Harness::Cursor => { + ArtifactType::Cursor => { let _ = session_id; vec![".".into()] } - Harness::Pi => vec!["--session".into(), session_id.into()], + ArtifactType::Pi => vec!["--session".into(), session_id.into()], } } pub(crate) fn invocation_for( - harness: crate::cmd_share::Harness, + harness: ArtifactType, session_id: &str, cwd: &std::path::Path, ) -> (String, Vec) { - use crate::cmd_share::Harness; - if harness == Harness::Cursor { + if harness == ArtifactType::Cursor { return cursor_invocation(cwd); } (harness.name().to_string(), argv_for(harness, session_id)) @@ -479,18 +464,16 @@ fn cursor_invocation(cwd: &std::path::Path) -> (String, Vec) { /// returning the projected session id. pub(crate) fn project_into_harness( path: &TPath, - harness: crate::cmd_share::Harness, + harness: ArtifactType, cwd: &std::path::Path, ) -> Result { - use crate::cmd_share::Harness; match harness { - Harness::Claude => crate::cmd_export::project_claude(path, cwd), - Harness::Gemini => crate::cmd_export::project_gemini(path, cwd), - Harness::Codex => crate::cmd_export::project_codex(path, cwd), - Harness::Copilot => crate::cmd_export::project_copilot(path, cwd), - Harness::Opencode => crate::cmd_export::project_opencode(path, cwd), - Harness::Cursor => crate::cmd_export::project_cursor(path, cwd), - Harness::Pi => crate::cmd_export::project_pi(path, cwd), + ArtifactType::Claude => crate::cmd_export::project_claude(path, cwd), + ArtifactType::Gemini => crate::cmd_export::project_gemini(path, cwd), + ArtifactType::Codex => crate::cmd_export::project_codex(path, cwd), + ArtifactType::Opencode => crate::cmd_export::project_opencode(path, cwd), + ArtifactType::Cursor => crate::cmd_export::project_cursor(path, cwd), + ArtifactType::Pi => crate::cmd_export::project_pi(path, cwd), } } @@ -627,7 +610,7 @@ mod tests { let args = ResumeArgs { input: doc_file.to_string_lossy().to_string(), cwd: Some(cwd.path().to_path_buf()), - harness: Some(HarnessArg::Claude), + harness: Some(ArtifactType::Claude), no_cache: false, force: false, url: None, @@ -642,7 +625,6 @@ mod tests { assert_eq!(cap.cwd, std::fs::canonicalize(cwd.path()).unwrap()); } - use crate::cmd_share::Harness; use toolpath::v1::{Graph, PathMeta, PathOrRef}; fn make_step_with_actor(id: &str, actor: &str) -> toolpath::v1::Step { @@ -672,7 +654,7 @@ mod tests { source: Some("claude-code".to_string()), ..Default::default() }); - assert_eq!(infer_source_harness(&path), Some(Harness::Claude)); + assert_eq!(infer_source_harness(&path), Some(ArtifactType::Claude)); } #[test] @@ -682,25 +664,25 @@ mod tests { source: Some("something-bespoke".to_string()), ..Default::default() }); - assert_eq!(infer_source_harness(&path), Some(Harness::Gemini)); + assert_eq!(infer_source_harness(&path), Some(ArtifactType::Gemini)); } #[test] fn infer_source_harness_actor_sniff_codex() { let path = make_path_with_actor("agent:codex"); - assert_eq!(infer_source_harness(&path), Some(Harness::Codex)); + assert_eq!(infer_source_harness(&path), Some(ArtifactType::Codex)); } #[test] fn infer_source_harness_actor_sniff_opencode() { let path = make_path_with_actor("agent:opencode"); - assert_eq!(infer_source_harness(&path), Some(Harness::Opencode)); + assert_eq!(infer_source_harness(&path), Some(ArtifactType::Opencode)); } #[test] fn infer_source_harness_actor_sniff_pi() { let path = make_path_with_actor("agent:pi"); - assert_eq!(infer_source_harness(&path), Some(Harness::Pi)); + assert_eq!(infer_source_harness(&path), Some(ArtifactType::Pi)); } #[test] @@ -771,7 +753,7 @@ mod tests { }; let (g, harness) = resolve_input(&args).unwrap(); let _path = ensure_path_with_agent(&g).unwrap(); - assert_eq!(harness, Some(Harness::Claude)); + assert_eq!(harness, Some(ArtifactType::Claude)); } #[test] @@ -805,7 +787,7 @@ mod tests { }; let (g, harness) = resolve_input(&args).unwrap(); let _ = ensure_path_with_agent(&g).unwrap(); - assert_eq!(harness, Some(Harness::Codex)); + assert_eq!(harness, Some(ArtifactType::Codex)); } #[test] @@ -876,7 +858,7 @@ mod tests { let (g, harness) = result.expect("resolve_input should reuse cache without refetching"); let _ = ensure_path_with_agent(&g).unwrap(); - assert_eq!(harness, Some(Harness::Codex)); + assert_eq!(harness, Some(ArtifactType::Codex)); } #[test] @@ -923,10 +905,10 @@ mod tests { #[test] fn pick_harness_explicit_arg_validates_path() { let td = fake_path_with(&["claude"]); - let result = pick_harness(Some(HarnessArg::Claude), None, Some(td.path())); - assert_eq!(result.unwrap(), Harness::Claude); + let result = pick_harness(Some(ArtifactType::Claude), None, Some(td.path())); + assert_eq!(result.unwrap(), ArtifactType::Claude); - let err = pick_harness(Some(HarnessArg::Gemini), None, Some(td.path())).unwrap_err(); + let err = pick_harness(Some(ArtifactType::Gemini), None, Some(td.path())).unwrap_err(); assert!(err.to_string().contains("`gemini` isn't on PATH")); } @@ -934,21 +916,21 @@ mod tests { #[test] fn cursor_available_via_open_fallback_on_macos() { let td = fake_path_with(&["open"]); - assert!(harness_available(Harness::Cursor, Some(td.path()))); - let picked = pick_harness(Some(HarnessArg::Cursor), None, Some(td.path())); - assert_eq!(picked.unwrap(), Harness::Cursor); + assert!(harness_available(ArtifactType::Cursor, Some(td.path()))); + let picked = pick_harness(Some(ArtifactType::Cursor), None, Some(td.path())); + assert_eq!(picked.unwrap(), ArtifactType::Cursor); } #[test] fn cursor_unavailable_when_no_launcher_at_all() { let td = fake_path_with(&["claude"]); - assert!(!harness_available(Harness::Cursor, Some(td.path()))); + assert!(!harness_available(ArtifactType::Cursor, Some(td.path()))); } #[test] fn cursor_invocation_includes_workspace_path() { let cwd = std::path::PathBuf::from("/tmp/some-workspace"); - let (binary, argv) = invocation_for(Harness::Cursor, "ignored-session-id", &cwd); + let (binary, argv) = invocation_for(ArtifactType::Cursor, "ignored-session-id", &cwd); assert!( argv.iter().any(|a| a == "/tmp/some-workspace"), "workspace path must appear in argv; got {argv:?}", @@ -962,7 +944,7 @@ mod tests { #[test] fn pick_harness_zero_installed_errors() { let td = fake_path_with(&[]); - let err = pick_harness(None, Some(Harness::Claude), Some(td.path())).unwrap_err(); + let err = pick_harness(None, Some(ArtifactType::Claude), Some(td.path())).unwrap_err(); assert!( err.to_string().contains("no installed harnesses") || err.to_string().contains("no harnesses on PATH"), @@ -974,23 +956,23 @@ mod tests { #[test] fn argv_for_returns_harness_specific_shape() { assert_eq!( - argv_for(Harness::Claude, "abc"), + argv_for(ArtifactType::Claude, "abc"), vec!["-r".to_string(), "abc".to_string()] ); assert_eq!( - argv_for(Harness::Gemini, "abc"), + argv_for(ArtifactType::Gemini, "abc"), vec!["--resume".to_string(), "abc".to_string()] ); assert_eq!( - argv_for(Harness::Codex, "abc"), + argv_for(ArtifactType::Codex, "abc"), vec!["resume".to_string(), "abc".to_string()] ); assert_eq!( - argv_for(Harness::Opencode, "abc"), + argv_for(ArtifactType::Opencode, "abc"), vec!["--session".to_string(), "abc".to_string()] ); assert_eq!( - argv_for(Harness::Pi, "abc"), + argv_for(ArtifactType::Pi, "abc"), vec!["--session".to_string(), "abc".to_string()] ); } @@ -1004,7 +986,7 @@ mod tests { let cwd = tempfile::tempdir().unwrap(); let path = make_convo_path_for_resume("claude-code://resume-test-session"); - let session_id = project_into_harness(&path, Harness::Claude, cwd.path()).unwrap(); + let session_id = project_into_harness(&path, ArtifactType::Claude, cwd.path()).unwrap(); assert!(!session_id.is_empty()); } diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index bc8d73fa..19ddfb14 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -5,22 +5,11 @@ use anyhow::Result; use chrono::{DateTime, Utc}; -use clap::{Args, ValueEnum}; +use clap::Args; use std::path::PathBuf; use crate::cmd_export::RepoSpec; - -#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] -#[value(rename_all = "lower")] -pub enum HarnessArg { - Claude, - Gemini, - Codex, - Copilot, - Opencode, - Cursor, - Pi, -} +use crate::sync::ArtifactType; #[derive(Args, Debug)] pub struct ShareArgs { @@ -49,7 +38,7 @@ pub struct ShareArgs { /// Narrow the picker to one harness, or skip the picker entirely /// when used with --session. #[arg(long, value_enum)] - pub harness: Option, + pub harness: Option, /// Skip the picker. Requires --harness; requires --project for /// claude/gemini/pi. @@ -66,102 +55,18 @@ pub struct ShareArgs { pub no_cache: bool, } -/// Which agent harness a session was produced by. -#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] -pub(crate) enum Harness { - Claude, - Gemini, - Codex, - Copilot, - Opencode, - Cursor, - Pi, -} - -impl Harness { - /// Every harness, in presentation order. - pub(crate) const ALL: [Harness; 6] = [ - Harness::Claude, - Harness::Gemini, - Harness::Codex, - Harness::Opencode, - Harness::Cursor, - Harness::Pi, - ]; - - pub(crate) fn name(&self) -> &'static str { - match self { - Harness::Claude => "claude", - Harness::Gemini => "gemini", - Harness::Codex => "codex", - Harness::Copilot => "copilot", - Harness::Opencode => "opencode", - Harness::Cursor => "cursor", - Harness::Pi => "pi", - } - } - - /// Padded so all symbols line up in the fzf column. Longest is - /// "opencode" (8); pad shorter names to match. - pub(crate) fn symbol(&self) -> &'static str { - match self { - Harness::Claude => "claude ", - Harness::Gemini => "gemini ", - Harness::Codex => "codex ", - Harness::Copilot => "copilot ", - Harness::Opencode => "opencode", - Harness::Cursor => "cursor ", - Harness::Pi => "pi ", - } - } - - /// True when the underlying provider keys sessions by project path. - /// claude/gemini/pi: true. codex/opencode/cursor: false (sessions - /// store cwd per-row, not as a directory key — cursor stores it as - /// `workspaceIdentifier.uri.fsPath` on each composer). - pub(crate) fn project_keyed(&self) -> bool { - matches!(self, Harness::Claude | Harness::Gemini | Harness::Pi) - } - - pub(crate) fn from_arg(arg: HarnessArg) -> Self { - match arg { - HarnessArg::Claude => Harness::Claude, - HarnessArg::Gemini => Harness::Gemini, - HarnessArg::Codex => Harness::Codex, - HarnessArg::Copilot => Harness::Copilot, - HarnessArg::Opencode => Harness::Opencode, - HarnessArg::Cursor => Harness::Cursor, - HarnessArg::Pi => Harness::Pi, - } - } - - pub(crate) fn parse(s: &str) -> Option { - match s { - "claude" => Some(Harness::Claude), - "gemini" => Some(Harness::Gemini), - "codex" => Some(Harness::Codex), - "copilot" => Some(Harness::Copilot), - "opencode" => Some(Harness::Opencode), - "cursor" => Some(Harness::Cursor), - "pi" => Some(Harness::Pi), - _ => None, - } - } -} - /// One artifact surfaced by a provider — today always an agent session. /// Rows feed both the unified `share` picker and `p cache sync`. #[derive(Debug, Clone)] pub(crate) struct ArtifactRow { - pub(crate) harness: Harness, + pub(crate) artifact_type: ArtifactType, /// Project path for keyed providers; `None` for codex/opencode. - pub(crate) project: Option, + pub(crate) path: Option, /// Recorded cwd from the session (codex/opencode only). pub(crate) cwd: Option, pub(crate) session_id: String, pub(crate) title: String, pub(crate) last_activity: Option>, - pub(crate) message_count: usize, pub(crate) matches_cwd: bool, } @@ -182,7 +87,7 @@ pub(crate) struct HarnessBundle { impl HarnessBundle { /// Build the production bundle. Each provider is included /// unconditionally (its `new()` doesn't fail on a missing home dir); - /// `gather_sessions` skips the ones whose listing returns empty/NotFound. + /// `gather_artifacts` skips the ones whose listing returns empty/NotFound. pub(crate) fn from_environment() -> Self { Self { claude: Some(toolpath_claude::ClaudeConvo::new()), @@ -203,49 +108,44 @@ impl HarnessBundle { /// Filters: `harness_filter` keeps only rows from one harness; `project_filter` /// keeps only rows whose project (for keyed) or cwd (for session-keyed) /// canonicalizes to that path. -pub(crate) fn gather_sessions( +pub(crate) fn gather_artifacts( bundle: &HarnessBundle, cwd: &std::path::Path, - harness_filter: Option, + harness_filter: Option, project_filter: Option<&std::path::Path>, ) -> Vec { let mut rows = Vec::new(); let canonical_cwd = canonicalize_or_self(cwd); let canonical_project = project_filter.map(canonicalize_or_self); - let want = |h: Harness| harness_filter.is_none_or(|f| f == h); + let want = |h: ArtifactType| harness_filter.is_none_or(|f| f == h); - if want(Harness::Claude) + if want(ArtifactType::Claude) && let Some(mgr) = &bundle.claude { collect_claude(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); } - if want(Harness::Gemini) + if want(ArtifactType::Gemini) && let Some(mgr) = &bundle.gemini { collect_gemini(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); } - if want(Harness::Pi) + if want(ArtifactType::Pi) && let Some(mgr) = &bundle.pi { collect_pi(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); } - if want(Harness::Codex) + if want(ArtifactType::Codex) && let Some(mgr) = &bundle.codex { collect_codex(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); } - if want(Harness::Copilot) - && let Some(mgr) = &bundle.copilot - { - collect_copilot(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); - } - if want(Harness::Opencode) + if want(ArtifactType::Opencode) && let Some(mgr) = &bundle.opencode { collect_opencode(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); } - if want(Harness::Cursor) + if want(ArtifactType::Cursor) && let Some(mgr) = &bundle.cursor { collect_cursor(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); @@ -299,15 +199,14 @@ fn collect_claude( let matches_cwd = paths_match(project_path, canonical_cwd); for m in metas { out.push(ArtifactRow { - harness: Harness::Claude, - project: Some(m.project_path), + artifact_type: ArtifactType::Claude, + path: Some(m.project_path), cwd: None, session_id: m.session_id, title: m .first_user_message .unwrap_or_else(|| "(no prompt)".to_string()), last_activity: m.last_activity, - message_count: m.message_count, matches_cwd, }); } @@ -346,15 +245,14 @@ fn collect_gemini( let matches_cwd = paths_match(project_path, canonical_cwd); for m in metas { out.push(ArtifactRow { - harness: Harness::Gemini, - project: Some(m.project_path), + artifact_type: ArtifactType::Gemini, + path: Some(m.project_path), cwd: None, session_id: m.session_uuid, title: m .first_user_message .unwrap_or_else(|| "(no prompt)".to_string()), last_activity: m.last_activity, - message_count: m.message_count, matches_cwd, }); } @@ -392,20 +290,28 @@ fn collect_pi( }; let matches_cwd = paths_match(project_path, canonical_cwd); for m in metas { - // SessionMeta.timestamp is a String; parse to DateTime when possible. - let last_activity = chrono::DateTime::parse_from_rfc3339(&m.timestamp) + // Pi's SessionMeta.timestamp is the session *start*, so it + // never moves as the session grows; prefer the file's mtime + // as the change-detecting last_activity, falling back to + // the header timestamp when the stat fails. + let last_activity = std::fs::metadata(&m.file_path) + .and_then(|md| md.modified()) .ok() - .map(|d| d.with_timezone(&Utc)); + .map(DateTime::::from) + .or_else(|| { + chrono::DateTime::parse_from_rfc3339(&m.timestamp) + .ok() + .map(|d| d.with_timezone(&Utc)) + }); out.push(ArtifactRow { - harness: Harness::Pi, - project: Some(project.clone()), + artifact_type: ArtifactType::Pi, + path: Some(project.clone()), cwd: None, session_id: m.id, title: m .first_user_message .unwrap_or_else(|| "(no prompt)".to_string()), last_activity, - message_count: m.entry_count, matches_cwd, }); } @@ -444,15 +350,14 @@ fn collect_codex( .map(|p| paths_match(p, canonical_cwd)) .unwrap_or(false); out.push(ArtifactRow { - harness: Harness::Codex, - project: None, + artifact_type: ArtifactType::Codex, + path: None, cwd: cwd_str, session_id: m.id, title: m .first_user_message .unwrap_or_else(|| "(no prompt)".to_string()), last_activity: m.last_activity, - message_count: m.line_count, matches_cwd, }); } @@ -530,13 +435,12 @@ fn collect_opencode( _ => "(no prompt)".to_string(), }; out.push(ArtifactRow { - harness: Harness::Opencode, - project: None, + artifact_type: ArtifactType::Opencode, + path: None, cwd: Some(cwd_str), session_id: m.id, title, last_activity: m.last_activity, - message_count: m.message_count, matches_cwd, }); } @@ -579,13 +483,12 @@ fn collect_cursor( _ => "(no prompt)".to_string(), }; out.push(ArtifactRow { - harness: Harness::Cursor, - project: None, + artifact_type: ArtifactType::Cursor, + path: None, cwd: Some(cwd_str), session_id: m.id, title, last_activity: m.last_activity, - message_count: m.message_count, matches_cwd, }); } @@ -642,7 +545,7 @@ fn is_not_found_cursor(err: &toolpath_cursor::CursorError) -> bool { } pub fn run(args: ShareArgs) -> Result<()> { - let harness = args.harness.map(Harness::from_arg); + let harness = args.harness; if args.session.is_some() && harness.is_none() { anyhow::bail!("--session requires --harness"); @@ -671,7 +574,7 @@ pub fn run(args: ShareArgs) -> Result<()> { let cwd = std::env::current_dir()?; let bundle = HarnessBundle::from_environment(); let project_filter = args.project.as_deref(); - let rows = gather_sessions(&bundle, &cwd, harness, project_filter); + let rows = gather_artifacts(&bundle, &cwd, harness, project_filter); if rows.is_empty() { return bail_no_sessions(&bundle, project_filter); @@ -731,9 +634,9 @@ pub fn run(args: ShareArgs) -> Result<()> { repo: args.repo.clone(), name: args.name.clone(), public: args.public, - harness: Some(harness_to_arg(h)), + harness: Some(h), session: None, // unused by share_explicit - project: if h.project_keyed() { + project: if h.path_keyed() { Some(PathBuf::from(&key)) } else { None @@ -747,18 +650,6 @@ pub fn run(args: ShareArgs) -> Result<()> { share_explicit(h, &session, &explicit, auth, base_url) } -fn harness_to_arg(h: Harness) -> HarnessArg { - match h { - Harness::Claude => HarnessArg::Claude, - Harness::Gemini => HarnessArg::Gemini, - Harness::Codex => HarnessArg::Codex, - Harness::Copilot => HarnessArg::Copilot, - Harness::Opencode => HarnessArg::Opencode, - Harness::Cursor => HarnessArg::Cursor, - Harness::Pi => HarnessArg::Pi, - } -} - fn bail_no_sessions( bundle: &HarnessBundle, project_filter: Option<&std::path::Path>, @@ -958,13 +849,13 @@ fn home_relative(path: &std::path::Path, home: Option<&std::path::Path>) -> Stri } fn share_explicit( - harness: Harness, + harness: ArtifactType, session: &str, args: &ShareArgs, auth: crate::cmd_pathbase::AuthMode, base_url: String, ) -> Result<()> { - let project = match (harness.project_keyed(), args.project.as_ref()) { + let project = match (harness.path_keyed(), args.project.as_ref()) { (true, Some(p)) => Some(p.to_string_lossy().into_owned()), (true, None) => anyhow::bail!( "--project required when --harness is {} and --session is set", @@ -1015,23 +906,23 @@ fn share_explicit( /// produce ugly variable gaps in both fzf and skim. fn format_picker_row(row: &ArtifactRow) -> String { let key = row - .project + .path .clone() .or_else(|| row.cwd.clone()) .unwrap_or_default(); let scope = if row.matches_cwd { "·" } else { " " }; - let leading = format!("{scope} {}", row.harness.symbol()); + let leading = format!("{scope} {}", row.artifact_type.symbol()); let display = render_row( Some(&leading), row.last_activity, - &count(row.message_count, "msgs"), + "", Some(&project_short(&key)), &row.title, ); let title = clean_for_picker_display(&row.title); format!( "{}\t{}\t{}\t{}\t{}", - row.harness.name(), + row.artifact_type.name(), tab_safe(&key), tab_safe(&row.session_id), display, @@ -1042,9 +933,9 @@ fn format_picker_row(row: &ArtifactRow) -> String { /// Inverse of [`format_picker_row`] — pulls (harness, key, session, /// title) back out of the line the picker returned. Returns `None` if /// the line is malformed. -fn parse_picker_row(line: &str) -> Option<(Harness, String, String, String)> { +fn parse_picker_row(line: &str) -> Option<(ArtifactType, String, String, String)> { let mut parts = line.split('\t'); - let h = Harness::parse(parts.next()?)?; + let h = ArtifactType::parse(parts.next()?)?; let key = parts.next()?.to_string(); let session = parts.next()?.to_string(); if session.is_empty() { @@ -1056,29 +947,26 @@ fn parse_picker_row(line: &str) -> Option<(Harness, String, String, String)> { Some((h, key, session, title)) } -use crate::fuzzy::{clean_for_picker_display, count, project_short, render_row, tab_safe}; +use crate::fuzzy::{clean_for_picker_display, project_short, render_row, tab_safe}; fn derive_session( - harness: Harness, + harness: ArtifactType, project: Option<&str>, session: &str, ) -> Result { match harness { - Harness::Claude => { - crate::cmd_import::derive_claude_session(project.expect("project_keyed"), session) + ArtifactType::Claude => { + crate::cmd_import::derive_claude_session(project.expect("path_keyed"), session) } - Harness::Gemini => crate::cmd_import::derive_gemini_session( - project.expect("project_keyed"), - session, - false, - ), - Harness::Pi => { - crate::cmd_import::derive_pi_session(project.expect("project_keyed"), session, None) + ArtifactType::Gemini => { + crate::cmd_import::derive_gemini_session(project.expect("path_keyed"), session, false) + } + ArtifactType::Pi => { + crate::cmd_import::derive_pi_session(project.expect("path_keyed"), session, None) } - Harness::Codex => crate::cmd_import::derive_codex_session(session), - Harness::Copilot => crate::cmd_import::derive_copilot_session(session), - Harness::Opencode => crate::cmd_import::derive_opencode_session(session, false), - Harness::Cursor => crate::cmd_import::derive_cursor_session(session), + ArtifactType::Codex => crate::cmd_import::derive_codex_session(session), + ArtifactType::Opencode => crate::cmd_import::derive_opencode_session(session, false), + ArtifactType::Cursor => crate::cmd_import::derive_cursor_session(session), } } @@ -1086,58 +974,6 @@ fn derive_session( mod tests { use super::*; - #[test] - fn harness_name_and_symbol_are_distinct() { - let all = [ - Harness::Claude, - Harness::Gemini, - Harness::Codex, - Harness::Opencode, - Harness::Cursor, - Harness::Pi, - ]; - let names: Vec<&str> = all.iter().map(|h| h.name()).collect(); - let symbols: Vec<&str> = all.iter().map(|h| h.symbol()).collect(); - assert_eq!(names.len(), 6); - assert_eq!( - names.iter().collect::>().len(), - 6, - "names must be unique" - ); - assert_eq!( - symbols - .iter() - .collect::>() - .len(), - 6, - "symbols must be unique" - ); - } - - #[test] - fn harness_project_keyed_matches_design() { - assert!(Harness::Claude.project_keyed()); - assert!(Harness::Gemini.project_keyed()); - assert!(Harness::Pi.project_keyed()); - assert!(!Harness::Codex.project_keyed()); - assert!(!Harness::Opencode.project_keyed()); - assert!(!Harness::Cursor.project_keyed()); - } - - #[test] - fn harness_from_arg_roundtrips() { - for (arg, harness) in [ - (HarnessArg::Claude, Harness::Claude), - (HarnessArg::Gemini, Harness::Gemini), - (HarnessArg::Codex, Harness::Codex), - (HarnessArg::Opencode, Harness::Opencode), - (HarnessArg::Cursor, Harness::Cursor), - (HarnessArg::Pi, Harness::Pi), - ] { - assert_eq!(Harness::from_arg(arg), harness); - } - } - use std::path::Path; use tempfile::TempDir; @@ -1168,7 +1004,7 @@ mod tests { } #[test] - fn gather_sessions_includes_claude_rows_for_a_project() { + fn gather_artifacts_includes_claude_rows_for_a_project() { let temp = TempDir::new().unwrap(); write_claude_session( &temp.path().join(".claude"), @@ -1178,17 +1014,17 @@ mod tests { ); let bundle = claude_only_bundle(temp.path()); let cwd = Path::new("/test/project"); - let rows = gather_sessions(&bundle, cwd, None, None); + let rows = gather_artifacts(&bundle, cwd, None, None); assert_eq!(rows.len(), 1); - assert_eq!(rows[0].harness, Harness::Claude); + assert_eq!(rows[0].artifact_type, ArtifactType::Claude); assert_eq!(rows[0].session_id, "abc-session-one"); - assert_eq!(rows[0].project.as_deref(), Some("/test/project")); + assert_eq!(rows[0].path.as_deref(), Some("/test/project")); assert!(rows[0].matches_cwd, "cwd should match the project path"); } #[test] - fn gather_sessions_marks_non_matching_project_rows() { + fn gather_artifacts_marks_non_matching_project_rows() { let temp = TempDir::new().unwrap(); write_claude_session( &temp.path().join(".claude"), @@ -1198,22 +1034,22 @@ mod tests { ); let bundle = claude_only_bundle(temp.path()); let cwd = Path::new("/some/other/place"); - let rows = gather_sessions(&bundle, cwd, None, None); + let rows = gather_artifacts(&bundle, cwd, None, None); assert_eq!(rows.len(), 1); assert!(!rows[0].matches_cwd); } #[test] - fn gather_sessions_skips_harness_with_no_home_dir() { + fn gather_artifacts_skips_harness_with_no_home_dir() { // Empty bundle => no rows, no panic. let bundle = HarnessBundle::default(); - let rows = gather_sessions(&bundle, Path::new("/anywhere"), None, None); + let rows = gather_artifacts(&bundle, Path::new("/anywhere"), None, None); assert!(rows.is_empty()); } #[test] - fn gather_sessions_filters_by_harness() { + fn gather_artifacts_filters_by_harness() { let temp = TempDir::new().unwrap(); write_claude_session( &temp.path().join(".claude"), @@ -1223,7 +1059,7 @@ mod tests { ); let bundle = claude_only_bundle(temp.path()); let cwd = Path::new("/test/project"); - let rows = gather_sessions(&bundle, cwd, Some(Harness::Codex), None); + let rows = gather_artifacts(&bundle, cwd, Some(ArtifactType::Codex), None); assert!(rows.is_empty(), "filter to codex must drop claude rows"); } @@ -1250,7 +1086,7 @@ mod tests { } #[test] - fn gather_sessions_includes_codex_rows_with_cwd_match() { + fn gather_artifacts_includes_codex_rows_with_cwd_match() { let temp = TempDir::new().unwrap(); write_codex_session( &temp.path().join(".codex"), @@ -1258,9 +1094,9 @@ mod tests { "/work/proj", ); let bundle = codex_only_bundle(temp.path()); - let rows = gather_sessions(&bundle, Path::new("/work/proj"), None, None); + let rows = gather_artifacts(&bundle, Path::new("/work/proj"), None, None); assert_eq!(rows.len(), 1); - assert_eq!(rows[0].harness, Harness::Codex); + assert_eq!(rows[0].artifact_type, ArtifactType::Codex); assert_eq!(rows[0].cwd.as_deref(), Some("/work/proj")); assert!(rows[0].matches_cwd); } @@ -1310,7 +1146,7 @@ mod tests { } #[test] - fn gather_sessions_ranks_cwd_matches_first() { + fn gather_artifacts_ranks_cwd_matches_first() { // Two claude sessions: one in cwd (older), one elsewhere (newer). // Despite the elsewhere row being newer, the cwd-match must come first. let temp = TempDir::new().unwrap(); @@ -1326,7 +1162,7 @@ mod tests { ) .unwrap(); let bundle = claude_only_bundle(temp.path()); - let rows = gather_sessions(&bundle, Path::new("/cwd/project"), None, None); + let rows = gather_artifacts(&bundle, Path::new("/cwd/project"), None, None); assert_eq!(rows.len(), 2); assert_eq!(rows[0].session_id, "in-cwd-session"); @@ -1344,7 +1180,7 @@ mod tests { // string. Verify both arguments are canonicalized. // // Note: we test `paths_match` directly rather than going through - // `gather_sessions` because Claude's project-dir slug encoding is + // `gather_artifacts` because Claude's project-dir slug encoding is // lossy (sanitize_project_path: '/', '_', '.' → '-'; unsanitize: only // '-' → '/'). On macOS, tempdir paths contain '.' and end up under // /private/var/..., so the unsanitized slug never round-trips back to @@ -1379,18 +1215,17 @@ mod tests { #[test] fn parse_picker_row_roundtrips_keyed() { let row = ArtifactRow { - harness: Harness::Claude, - project: Some("/tmp/proj".to_string()), + artifact_type: ArtifactType::Claude, + path: Some("/tmp/proj".to_string()), cwd: None, session_id: "sess-abc".to_string(), title: "Hello\tworld".to_string(), last_activity: None, - message_count: 3, matches_cwd: true, }; let line = format_picker_row(&row); let (harness, key, session, title) = parse_picker_row(&line).unwrap(); - assert_eq!(harness, Harness::Claude); + assert_eq!(harness, ArtifactType::Claude); assert_eq!(key, "/tmp/proj"); assert_eq!(session, "sess-abc"); // tab_safe replaces the tab with a space, but the title content @@ -1401,18 +1236,17 @@ mod tests { #[test] fn parse_picker_row_roundtrips_session_keyed() { let row = ArtifactRow { - harness: Harness::Codex, - project: None, + artifact_type: ArtifactType::Codex, + path: None, cwd: Some("/work/proj".to_string()), session_id: "0190abcd".to_string(), title: "(no prompt)".to_string(), last_activity: None, - message_count: 0, matches_cwd: false, }; let line = format_picker_row(&row); let (harness, key, session, title) = parse_picker_row(&line).unwrap(); - assert_eq!(harness, Harness::Codex); + assert_eq!(harness, ArtifactType::Codex); assert_eq!(key, "/work/proj"); // codex has no project; cwd carried as the keyed slot assert_eq!(session, "0190abcd"); assert_eq!(title, "(no prompt)"); @@ -1421,13 +1255,12 @@ mod tests { #[test] fn parse_picker_row_carries_title_with_unicode() { let row = ArtifactRow { - harness: Harness::Gemini, - project: Some("/work/proj".to_string()), + artifact_type: ArtifactType::Gemini, + path: Some("/work/proj".to_string()), cwd: None, session_id: "11111111-2222-3333-4444-555555555555".to_string(), title: "Add the share command — finally".to_string(), last_activity: None, - message_count: 42, matches_cwd: true, }; let line = format_picker_row(&row); diff --git a/crates/path-cli/src/lib.rs b/crates/path-cli/src/lib.rs index 011718e0..d9ed8c8a 100644 --- a/crates/path-cli/src/lib.rs +++ b/crates/path-cli/src/lib.rs @@ -33,8 +33,7 @@ mod query; mod schema; #[cfg(all(not(target_os = "emscripten"), feature = "embedded-picker"))] mod skim_picker; -#[cfg(not(target_os = "emscripten"))] -mod sync; +pub mod sync; mod term; use anyhow::Result; diff --git a/crates/path-cli/src/sync.rs b/crates/path-cli/src/sync.rs index ca72ea09..73eb8721 100644 --- a/crates/path-cli/src/sync.rs +++ b/crates/path-cli/src/sync.rs @@ -1,576 +1,698 @@ -//! `path p cache sync` — incremental ingestion of agent sessions into -//! the document cache. +//! `path p cache sync` — incremental ingestion of artifacts into the +//! document cache — and [`ArtifactType`], the single enum naming the +//! artifact sources the CLI operates over. //! -//! Enumerates sessions across the requested agent harnesses (the same -//! aggregation `path share` uses), compares each against the sync -//! manifest at `$CONFIG_DIR/sync.json`, and derives + caches only what -//! is new or changed. The manifest maps artifact type → session id → -//! the fingerprint recorded at last sync (`last_activity` + -//! `message_count`), so an unchanged session costs a metadata read -//! instead of a re-derivation, and running sync twice in a row is a -//! no-op. Sessions deleted upstream keep both their cache document and -//! their manifest record — the cache is an archive, not a mirror. - -#![cfg(not(target_os = "emscripten"))] - -use anyhow::{Context, Result, anyhow}; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::path::PathBuf; - -use crate::cmd_cache::write_cached; -use crate::cmd_import::DerivedDoc; -use crate::cmd_share::{ArtifactRow, Harness, HarnessArg, HarnessBundle, gather_sessions}; -use crate::config::config_dir; - -const MANIFEST_FILE: &str = "sync.json"; - -/// What the manifest remembers about one synced session. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct SyncRecord { - /// Project path, for the project-keyed providers (claude/gemini/pi). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) project: Option, - /// Cache entry the derived document was written to. - pub(crate) cache_id: String, - /// Fingerprint: the session's last activity as reported at sync time. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) last_activity: Option>, - /// Fingerprint: message count as reported at sync time. - pub(crate) message_count: usize, - pub(crate) synced_at: DateTime, +//! Sync enumerates artifacts across the requested types (today all six +//! are agent-session providers, using the same aggregation `path share` +//! uses), compares each against the sync manifest at +//! `$CONFIG_DIR/sync.json`, and derives + caches only what is new or +//! changed. The manifest maps artifact type → artifact id → the +//! `last_activity` fingerprint recorded at last sync, so an unchanged +//! artifact costs a metadata read instead of a re-derivation, and +//! running sync twice in a row is a no-op. Artifacts deleted upstream +//! keep both their cache document and their manifest record — the +//! cache is an archive, not a mirror. + +/// The kind of artifact an operation ranges over. One enum, used +/// everywhere a command names artifact sources (`p cache sync` types, +/// `share`/`resume` `--harness`, import cache-id prefixes); `name()` +/// doubles as the manifest key and cache-id prefix. Today every +/// variant is an agent-session provider; other artifact kinds (git, +/// github, …) join here when sync learns to ingest them. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, clap::ValueEnum)] +#[value(rename_all = "lower")] +pub enum ArtifactType { + Claude, + Gemini, + Codex, + Opencode, + Cursor, + Pi, } -/// The sync manifest: artifact type (`"claude"`, `"codex"`, …) → -/// session id → record. Kept as `BTreeMap`s so the JSON on disk is -/// stably ordered. -pub(crate) type Manifest = BTreeMap>; - -/// Per-type tally of what one sync run did. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub(crate) struct SyncOutcome { - pub(crate) new: usize, - pub(crate) updated: usize, - pub(crate) unchanged: usize, - pub(crate) failed: usize, -} - -impl SyncOutcome { - fn total(&self) -> usize { - self.new + self.updated + self.unchanged + self.failed +impl ArtifactType { + /// Every artifact type, in presentation order. + pub(crate) const ALL: [ArtifactType; 6] = [ + ArtifactType::Claude, + ArtifactType::Gemini, + ArtifactType::Codex, + ArtifactType::Opencode, + ArtifactType::Cursor, + ArtifactType::Pi, + ]; + + pub(crate) fn name(&self) -> &'static str { + match self { + ArtifactType::Claude => "claude", + ArtifactType::Gemini => "gemini", + ArtifactType::Codex => "codex", + ArtifactType::Opencode => "opencode", + ArtifactType::Cursor => "cursor", + ArtifactType::Pi => "pi", + } } -} - -pub(crate) fn run(types: Vec) -> Result<()> { - let explicit = !types.is_empty(); - let types = resolve_types(&types); - let bundle = HarnessBundle::from_environment(); - let outcomes = sync_bundle(&bundle, &types)?; - eprint!("{}", render_summary(&outcomes, explicit)); - Ok(()) -} -/// Explicit args → dedup'd harness list; no args → every harness. -fn resolve_types(args: &[HarnessArg]) -> Vec { - if args.is_empty() { - return Harness::ALL.to_vec(); - } - let mut out: Vec = Vec::with_capacity(args.len()); - for &a in args { - let h = Harness::from_arg(a); - if !out.contains(&h) { - out.push(h); + /// Padded so all symbols line up in the fzf column. Longest is + /// "opencode" (8); pad shorter names to match. + pub(crate) fn symbol(&self) -> &'static str { + match self { + ArtifactType::Claude => "claude ", + ArtifactType::Gemini => "gemini ", + ArtifactType::Codex => "codex ", + ArtifactType::Opencode => "opencode", + ArtifactType::Cursor => "cursor ", + ArtifactType::Pi => "pi ", } } - out -} -/// Sync the given harness types from `bundle` into the cache. The -/// manifest is checkpointed after each type so an interrupted first -/// run doesn't forget the types it already finished. -pub(crate) fn sync_bundle( - bundle: &HarnessBundle, - types: &[Harness], -) -> Result> { - let mut manifest = load_manifest()?; - // Only ranking depends on cwd and sync ignores row order, so any - // directory works here. - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let mut out = Vec::with_capacity(types.len()); - for &harness in types { - let rows = gather_sessions(bundle, &cwd, Some(harness), None); - let mut records = manifest.get(harness.name()).cloned().unwrap_or_default(); - let outcome = sync_rows(bundle, &rows, &mut records)?; - if !records.is_empty() { - manifest.insert(harness.name().to_string(), records); - save_manifest(&manifest)?; - } - out.push((harness, outcome)); + /// True when the underlying provider keys artifacts by a filesystem + /// path (the project directory). claude/gemini/pi: true. + /// codex/opencode/cursor: false (sessions store cwd per-row, not as + /// a directory key — cursor stores it as + /// `workspaceIdentifier.uri.fsPath` on each composer). + pub(crate) fn path_keyed(&self) -> bool { + matches!( + self, + ArtifactType::Claude | ArtifactType::Gemini | ArtifactType::Pi + ) } - Ok(out) -} -/// Sync one harness's rows against its manifest records. Derivation -/// failures are warned and tallied, not fatal; cache-write failures -/// (disk, permissions) abort. -fn sync_rows( - bundle: &HarnessBundle, - rows: &[ArtifactRow], - records: &mut BTreeMap, -) -> Result { - let mut outcome = SyncOutcome::default(); - for row in rows { - let existing = records.get(&row.session_id); - let is_new = existing.is_none(); - if let Some(rec) = existing - && rec.last_activity == row.last_activity - && rec.message_count == row.message_count - { - outcome.unchanged += 1; - continue; - } - match derive_row(bundle, row) { - Ok(derived) => { - // force: sync owns refresh semantics — a re-sync or a - // prior manual `p import` of the same session must not - // error on the existing cache entry. - write_cached(&derived.cache_id, &derived.doc, true)?; - records.insert( - row.session_id.clone(), - SyncRecord { - project: row.project.clone(), - cache_id: derived.cache_id, - last_activity: row.last_activity, - message_count: row.message_count, - synced_at: Utc::now(), - }, - ); - if is_new { - outcome.new += 1; - } else { - outcome.updated += 1; - } - } - Err(e) => { - eprintln!( - "warning: sync {}: session {}: {e}", - row.harness.name(), - row.session_id - ); - outcome.failed += 1; - } + pub(crate) fn parse(s: &str) -> Option { + match s { + "claude" => Some(ArtifactType::Claude), + "gemini" => Some(ArtifactType::Gemini), + "codex" => Some(ArtifactType::Codex), + "opencode" => Some(ArtifactType::Opencode), + "cursor" => Some(ArtifactType::Cursor), + "pi" => Some(ArtifactType::Pi), + _ => None, } } - Ok(outcome) } -/// Derive one session through the same manager the row was enumerated -/// from, so listing and derivation always agree on provider roots. -fn derive_row(bundle: &HarnessBundle, row: &ArtifactRow) -> Result { - use crate::cmd_import as imp; - let project = || { - row.project - .as_deref() - .ok_or_else(|| anyhow!("session {} has no project path", row.session_id)) - }; - match row.harness { - Harness::Claude => { - imp::derive_claude_session_with(mgr(&bundle.claude)?, project()?, &row.session_id) - } - Harness::Gemini => imp::derive_gemini_session_with( - mgr(&bundle.gemini)?, - project()?, - &row.session_id, - false, - ), - Harness::Pi => imp::derive_pi_session_with(mgr(&bundle.pi)?, project()?, &row.session_id), - Harness::Codex => imp::derive_codex_session_with(mgr(&bundle.codex)?, &row.session_id), - Harness::Opencode => { - imp::derive_opencode_session_with(mgr(&bundle.opencode)?, &row.session_id, false) - } - Harness::Cursor => imp::derive_cursor_session_with(mgr(&bundle.cursor)?, &row.session_id), +#[cfg(not(target_os = "emscripten"))] +pub(crate) use engine::*; + +#[cfg(not(target_os = "emscripten"))] +mod engine { + use anyhow::{Context, Result, anyhow}; + use chrono::{DateTime, Utc}; + use serde::{Deserialize, Serialize}; + use std::collections::BTreeMap; + use std::path::PathBuf; + + use super::ArtifactType; + use crate::cmd_cache::write_cached; + use crate::cmd_import::DerivedDoc; + use crate::cmd_share::{ArtifactRow, HarnessBundle, gather_artifacts}; + use crate::config::config_dir; + + const MANIFEST_FILE: &str = "sync.json"; + + /// What the manifest remembers about one synced artifact. + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] + pub(crate) struct SyncRecord { + /// Filesystem path the artifact is keyed under, for path-keyed + /// providers (claude/gemini/pi: the project directory). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) path: Option, + /// Cache entry the derived document was written to. + pub(crate) cache_id: String, + /// Fingerprint: the artifact's last activity as reported at + /// sync time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) last_activity: Option>, + pub(crate) synced_at: DateTime, } -} -fn mgr(slot: &Option) -> Result<&T> { - slot.as_ref() - .ok_or_else(|| anyhow!("provider not available")) -} + /// The sync manifest: artifact type (`"claude"`, `"codex"`, …) → + /// artifact id → record. Kept as `BTreeMap`s so the JSON on disk is + /// stably ordered. + pub(crate) type Manifest = BTreeMap>; + + /// Per-type tally of what one sync run did. + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] + pub(crate) struct SyncOutcome { + pub(crate) new: usize, + pub(crate) updated: usize, + pub(crate) unchanged: usize, + pub(crate) failed: usize, + } -/// One stderr line per harness. Types the user didn't name are shown -/// only when they had sessions, so a default run doesn't list every -/// uninstalled harness. -fn render_summary(outcomes: &[(Harness, SyncOutcome)], explicit: bool) -> String { - let mut s = String::new(); - for (harness, o) in outcomes { - if o.total() == 0 && !explicit { - continue; + impl SyncOutcome { + fn total(&self) -> usize { + self.new + self.updated + self.unchanged + self.failed } - s.push_str(&format!( - "{} {} new, {} updated, {} unchanged", - harness.symbol(), - o.new, - o.updated, - o.unchanged - )); - if o.failed > 0 { - s.push_str(&format!(", {} failed", o.failed)); - } - s.push('\n'); - } - if s.is_empty() { - s.push_str("nothing to sync\n"); } - s -} - -// ── manifest IO ──────────────────────────────────────────────────────── - -fn manifest_path() -> Result { - Ok(config_dir()?.join(MANIFEST_FILE)) -} -pub(crate) fn load_manifest() -> Result { - let path = manifest_path()?; - let json = match std::fs::read_to_string(&path) { - Ok(s) => s, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Manifest::default()), - Err(e) => return Err(anyhow!("read {}: {e}", path.display())), - }; - serde_json::from_str(&json).with_context(|| { - format!( - "parse {}; delete it to re-sync from scratch", - path.display() - ) - }) -} - -/// Write the manifest atomically (temp file + rename) with the same -/// permissions as the rest of `$CONFIG_DIR`. -fn save_manifest(manifest: &Manifest) -> Result<()> { - let path = manifest_path()?; - let dir = path.parent().expect("manifest path has a parent"); - std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)); - } - let json = serde_json::to_string_pretty(manifest)?; - let tmp = dir.join(format!("{MANIFEST_FILE}.{}.tmp", std::process::id())); - std::fs::write(&tmp, json).with_context(|| format!("write {}", tmp.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)) - .with_context(|| format!("chmod 0600 {}", tmp.display()))?; + pub(crate) fn run(types: Vec) -> Result<()> { + let explicit = !types.is_empty(); + let types = resolve_types(&types); + let bundle = HarnessBundle::from_environment(); + let outcomes = sync_bundle(&bundle, &types)?; + eprint!("{}", render_summary(&outcomes, explicit)); + Ok(()) } - std::fs::rename(&tmp, &path) - .with_context(|| format!("rename {} → {}", tmp.display(), path.display())) -} -#[cfg(test)] -mod tests { - use super::*; - use crate::config::{CONFIG_DIR_ENV, TEST_ENV_LOCK}; - use std::path::Path; - - /// Run `f` with `$TOOLPATH_CONFIG_DIR` pinned to `/.toolpath`; - /// `f` receives the tempdir root for building provider fixtures. - fn with_cfg R, R>(f: F) -> R { - let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let temp = tempfile::tempdir().unwrap(); - let prev = std::env::var_os(CONFIG_DIR_ENV); - unsafe { - std::env::set_var(CONFIG_DIR_ENV, temp.path().join(".toolpath")); + /// Explicit args → dedup'd type list; no args → every type. + fn resolve_types(args: &[ArtifactType]) -> Vec { + if args.is_empty() { + return ArtifactType::ALL.to_vec(); } - let result = f(temp.path()); - unsafe { - match prev { - Some(v) => std::env::set_var(CONFIG_DIR_ENV, v), - None => std::env::remove_var(CONFIG_DIR_ENV), + let mut out: Vec = Vec::with_capacity(args.len()); + for &t in args { + if !out.contains(&t) { + out.push(t); } } - result + out } - fn write_claude_session(home: &Path, project_slug: &str, session: &str, prompt: &str) { - let project_dir = home.join(".claude/projects").join(project_slug); - std::fs::create_dir_all(&project_dir).unwrap(); - let user = format!( - r#"{{"type":"user","uuid":"u-{session}","timestamp":"2024-01-02T00:00:00Z","cwd":"/test/project","message":{{"role":"user","content":"{prompt}"}}}}"# - ); - let asst = format!( - r#"{{"type":"assistant","uuid":"a-{session}","timestamp":"2024-01-02T00:00:01Z","message":{{"role":"assistant","content":"hi"}}}}"# - ); - std::fs::write( - project_dir.join(format!("{session}.jsonl")), - format!("{user}\n{asst}\n"), - ) - .unwrap(); + /// Sync the given artifact types from `bundle` into the cache. The + /// manifest is checkpointed after each type so an interrupted first + /// run doesn't forget the types it already finished. + pub(crate) fn sync_bundle( + bundle: &HarnessBundle, + types: &[ArtifactType], + ) -> Result> { + let mut manifest = load_manifest()?; + // Only ranking depends on cwd and sync ignores row order, so any + // directory works here. + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let mut out = Vec::with_capacity(types.len()); + for &artifact_type in types { + let rows = gather_artifacts(bundle, &cwd, Some(artifact_type), None); + let mut records = manifest + .get(artifact_type.name()) + .cloned() + .unwrap_or_default(); + let outcome = sync_rows(bundle, &rows, &mut records)?; + if !records.is_empty() { + manifest.insert(artifact_type.name().to_string(), records); + save_manifest(&manifest)?; + } + out.push((artifact_type, outcome)); + } + Ok(out) } - fn claude_bundle(home: &Path) -> HarnessBundle { - let resolver = toolpath_claude::PathResolver::new().with_claude_dir(home.join(".claude")); - HarnessBundle { - claude: Some(toolpath_claude::ClaudeConvo::with_resolver(resolver)), - ..Default::default() + /// Sync one type's rows against its manifest records. Derivation + /// failures are warned and tallied, not fatal; cache-write failures + /// (disk, permissions) abort. + fn sync_rows( + bundle: &HarnessBundle, + rows: &[ArtifactRow], + records: &mut BTreeMap, + ) -> Result { + let mut outcome = SyncOutcome::default(); + for row in rows { + let existing = records.get(&row.session_id); + let is_new = existing.is_none(); + if let Some(rec) = existing + && rec.last_activity == row.last_activity + { + outcome.unchanged += 1; + continue; + } + match derive_row(bundle, row) { + Ok(derived) => { + // force: sync owns refresh semantics — a re-sync or a + // prior manual `p import` of the same session must not + // error on the existing cache entry. + write_cached(&derived.cache_id, &derived.doc, true)?; + records.insert( + row.session_id.clone(), + SyncRecord { + path: row.path.clone(), + cache_id: derived.cache_id, + last_activity: row.last_activity, + synced_at: Utc::now(), + }, + ); + if is_new { + outcome.new += 1; + } else { + outcome.updated += 1; + } + } + Err(e) => { + eprintln!( + "warning: sync {}: {}: {e}", + row.artifact_type.name(), + row.session_id + ); + outcome.failed += 1; + } + } } + Ok(outcome) } - fn cached_step_count(cache_id: &str) -> usize { - let path = crate::cmd_cache::cache_path(cache_id).unwrap(); - let json = std::fs::read_to_string(path).unwrap(); - let doc = toolpath::v1::Graph::from_json(&json).unwrap(); - doc.single_path().map(|p| p.steps.len()).unwrap_or(0) + /// Derive one artifact through the same manager the row was + /// enumerated from, so listing and derivation always agree on + /// provider roots. + fn derive_row(bundle: &HarnessBundle, row: &ArtifactRow) -> Result { + use crate::cmd_import as imp; + let path = || { + row.path + .as_deref() + .ok_or_else(|| anyhow!("artifact {} has no path", row.session_id)) + }; + match row.artifact_type { + ArtifactType::Claude => { + imp::derive_claude_session_with(mgr(&bundle.claude)?, path()?, &row.session_id) + } + ArtifactType::Gemini => imp::derive_gemini_session_with( + mgr(&bundle.gemini)?, + path()?, + &row.session_id, + false, + ), + ArtifactType::Pi => { + imp::derive_pi_session_with(mgr(&bundle.pi)?, path()?, &row.session_id) + } + ArtifactType::Codex => { + imp::derive_codex_session_with(mgr(&bundle.codex)?, &row.session_id) + } + ArtifactType::Opencode => { + imp::derive_opencode_session_with(mgr(&bundle.opencode)?, &row.session_id, false) + } + ArtifactType::Cursor => { + imp::derive_cursor_session_with(mgr(&bundle.cursor)?, &row.session_id) + } + } } - #[test] - fn manifest_roundtrips_and_missing_is_empty() { - with_cfg(|_| { - assert!(load_manifest().unwrap().is_empty()); - - let mut manifest = Manifest::default(); - manifest.entry("claude".to_string()).or_default().insert( - "sess-1".to_string(), - SyncRecord { - project: Some("/test/project".to_string()), - cache_id: "claude-p1".to_string(), - last_activity: Some("2024-01-02T00:00:01Z".parse().unwrap()), - message_count: 2, - synced_at: "2026-07-09T00:00:00Z".parse().unwrap(), - }, - ); - save_manifest(&manifest).unwrap(); - assert_eq!(load_manifest().unwrap(), manifest); - }); + fn mgr(slot: &Option) -> Result<&T> { + slot.as_ref() + .ok_or_else(|| anyhow!("provider not available")) } - #[cfg(unix)] - #[test] - fn manifest_file_is_0600() { - use std::os::unix::fs::PermissionsExt; - with_cfg(|_| { - save_manifest(&Manifest::default()).unwrap(); - let mode = std::fs::metadata(manifest_path().unwrap()) - .unwrap() - .permissions() - .mode() - & 0o777; - assert_eq!(mode, 0o600); - }); + /// One stderr line per artifact type. Types the user didn't name + /// are shown only when they had artifacts, so a default run doesn't + /// list every uninstalled provider. + fn render_summary(outcomes: &[(ArtifactType, SyncOutcome)], explicit: bool) -> String { + let mut s = String::new(); + for (artifact_type, o) in outcomes { + if o.total() == 0 && !explicit { + continue; + } + s.push_str(&format!( + "{} {} new, {} updated, {} unchanged", + artifact_type.symbol(), + o.new, + o.updated, + o.unchanged + )); + if o.failed > 0 { + s.push_str(&format!(", {} failed", o.failed)); + } + s.push('\n'); + } + if s.is_empty() { + s.push_str("nothing to sync\n"); + } + s } - #[test] - fn corrupt_manifest_errors_with_hint() { - with_cfg(|_| { - save_manifest(&Manifest::default()).unwrap(); - std::fs::write(manifest_path().unwrap(), "not json").unwrap(); - let err = load_manifest().unwrap_err(); - assert!(err.to_string().contains("re-sync from scratch")); - }); - } + // ── manifest IO ──────────────────────────────────────────────────── - #[test] - fn first_sync_ingests_then_second_is_unchanged() { - with_cfg(|home| { - write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); - write_claude_session(home, "-test-project", "sess-bbb", "Fix a bug"); - let bundle = claude_bundle(home); - - let outcomes = sync_bundle(&bundle, &[Harness::Claude]).unwrap(); - assert_eq!(outcomes.len(), 1); - let (_, first) = outcomes[0]; - assert_eq!( - (first.new, first.updated, first.unchanged, first.failed), - (2, 0, 0, 0) - ); + fn manifest_path() -> Result { + Ok(config_dir()?.join(MANIFEST_FILE)) + } - let manifest = load_manifest().unwrap(); - let records = manifest.get("claude").unwrap(); - assert_eq!(records.len(), 2); - let rec = records.get("sess-aaa").unwrap(); - assert_eq!(rec.project.as_deref(), Some("/test/project")); - assert_eq!(rec.message_count, 2); - assert!( - crate::cmd_cache::cache_path(&rec.cache_id) - .unwrap() - .exists(), - "cache doc must exist for {}", - rec.cache_id - ); + pub(crate) fn load_manifest() -> Result { + let path = manifest_path()?; + let json = match std::fs::read_to_string(&path) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Manifest::default()), + Err(e) => return Err(anyhow!("read {}: {e}", path.display())), + }; + serde_json::from_str(&json).with_context(|| { + format!( + "parse {}; delete it to re-sync from scratch", + path.display() + ) + }) + } - let (_, second) = sync_bundle(&bundle, &[Harness::Claude]).unwrap()[0]; - assert_eq!( - (second.new, second.updated, second.unchanged, second.failed), - (0, 0, 2, 0) - ); - }); + /// Write the manifest atomically (temp file + rename) with the same + /// permissions as the rest of `$CONFIG_DIR`. + fn save_manifest(manifest: &Manifest) -> Result<()> { + let path = manifest_path()?; + let dir = path.parent().expect("manifest path has a parent"); + std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)); + } + let json = serde_json::to_string_pretty(manifest)?; + let tmp = dir.join(format!("{MANIFEST_FILE}.{}.tmp", std::process::id())); + std::fs::write(&tmp, json).with_context(|| format!("write {}", tmp.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)) + .with_context(|| format!("chmod 0600 {}", tmp.display()))?; + } + std::fs::rename(&tmp, &path) + .with_context(|| format!("rename {} → {}", tmp.display(), path.display())) } - #[test] - fn changed_session_is_rederived() { - with_cfg(|home| { - write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); - let bundle = claude_bundle(home); - sync_bundle(&bundle, &[Harness::Claude]).unwrap(); - - let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] - .cache_id - .clone(); - let steps_before = cached_step_count(&cache_id); - - // Session continues: a later user turn lands in the file. - let file = home.join(".claude/projects/-test-project/sess-aaa.jsonl"); - let mut body = std::fs::read_to_string(&file).unwrap(); - body.push_str( - r#"{"type":"user","uuid":"u-2","timestamp":"2024-01-02T00:05:00Z","cwd":"/test/project","message":{"role":"user","content":"And another thing"}}"#, - ); - body.push('\n'); - std::fs::write(&file, body).unwrap(); + #[cfg(test)] + mod tests { + use super::*; + use crate::config::{CONFIG_DIR_ENV, TEST_ENV_LOCK}; + use std::path::Path; + + /// Run `f` with `$TOOLPATH_CONFIG_DIR` pinned to `/.toolpath`; + /// `f` receives the tempdir root for building provider fixtures. + fn with_cfg R, R>(f: F) -> R { + let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let temp = tempfile::tempdir().unwrap(); + let prev = std::env::var_os(CONFIG_DIR_ENV); + unsafe { + std::env::set_var(CONFIG_DIR_ENV, temp.path().join(".toolpath")); + } + let result = f(temp.path()); + unsafe { + match prev { + Some(v) => std::env::set_var(CONFIG_DIR_ENV, v), + None => std::env::remove_var(CONFIG_DIR_ENV), + } + } + result + } - let (_, outcome) = sync_bundle(&bundle, &[Harness::Claude]).unwrap()[0]; - assert_eq!( - ( - outcome.new, - outcome.updated, - outcome.unchanged, - outcome.failed - ), - (0, 1, 0, 0) + fn write_claude_session(home: &Path, project_slug: &str, session: &str, prompt: &str) { + let project_dir = home.join(".claude/projects").join(project_slug); + std::fs::create_dir_all(&project_dir).unwrap(); + let user = format!( + r#"{{"type":"user","uuid":"u-{session}","timestamp":"2024-01-02T00:00:00Z","cwd":"/test/project","message":{{"role":"user","content":"{prompt}"}}}}"# ); - assert!( - cached_step_count(&cache_id) > steps_before, - "re-derived doc must contain the appended turn" + let asst = format!( + r#"{{"type":"assistant","uuid":"a-{session}","timestamp":"2024-01-02T00:00:01Z","message":{{"role":"assistant","content":"hi"}}}}"# ); - }); - } + std::fs::write( + project_dir.join(format!("{session}.jsonl")), + format!("{user}\n{asst}\n"), + ) + .unwrap(); + } - #[test] - fn sync_touches_only_requested_types() { - with_cfg(|home| { - write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); - let bundle = claude_bundle(home); - - let outcomes = sync_bundle(&bundle, &[Harness::Codex]).unwrap(); - assert_eq!(outcomes[0].1, SyncOutcome::default()); - assert!( - load_manifest().unwrap().is_empty(), - "codex-only sync must not ingest claude sessions" - ); - }); - } + fn claude_bundle(home: &Path) -> HarnessBundle { + let resolver = + toolpath_claude::PathResolver::new().with_claude_dir(home.join(".claude")); + HarnessBundle { + claude: Some(toolpath_claude::ClaudeConvo::with_resolver(resolver)), + ..Default::default() + } + } - #[test] - fn sync_overwrites_cache_entry_it_does_not_remember() { - with_cfg(|home| { - write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); - let bundle = claude_bundle(home); - sync_bundle(&bundle, &[Harness::Claude]).unwrap(); - - // Losing the manifest (or a prior manual `p import`) leaves a - // cache entry sync doesn't know about; re-syncing must - // overwrite it, not die on the exists-check. - std::fs::remove_file(manifest_path().unwrap()).unwrap(); - let (_, outcome) = sync_bundle(&bundle, &[Harness::Claude]).unwrap()[0]; - assert_eq!((outcome.new, outcome.failed), (1, 0)); - }); - } + fn cached_step_count(cache_id: &str) -> usize { + let path = crate::cmd_cache::cache_path(cache_id).unwrap(); + let json = std::fs::read_to_string(path).unwrap(); + let doc = toolpath::v1::Graph::from_json(&json).unwrap(); + doc.single_path().map(|p| p.steps.len()).unwrap_or(0) + } - #[test] - fn failed_derivation_is_tallied_and_skipped() { - with_cfg(|home| { - write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); - let bundle = claude_bundle(home); - let cwd = std::env::current_dir().unwrap(); - let mut rows = gather_sessions(&bundle, &cwd, Some(Harness::Claude), None); - rows.push(ArtifactRow { - harness: Harness::Claude, - project: Some("/test/project".to_string()), + fn make_row(artifact_type: ArtifactType, session_id: &str) -> ArtifactRow { + ArtifactRow { + artifact_type, + path: Some("/test/project".to_string()), cwd: None, - session_id: "does-not-exist".to_string(), + session_id: session_id.to_string(), title: String::new(), last_activity: None, - message_count: 0, matches_cwd: false, + } + } + + #[test] + fn manifest_roundtrips_and_missing_is_empty() { + with_cfg(|_| { + assert!(load_manifest().unwrap().is_empty()); + + let mut manifest = Manifest::default(); + manifest.entry("claude".to_string()).or_default().insert( + "sess-1".to_string(), + SyncRecord { + path: Some("/test/project".to_string()), + cache_id: "claude-p1".to_string(), + last_activity: Some("2024-01-02T00:00:01Z".parse().unwrap()), + synced_at: "2026-07-09T00:00:00Z".parse().unwrap(), + }, + ); + save_manifest(&manifest).unwrap(); + assert_eq!(load_manifest().unwrap(), manifest); + }); + } + + #[cfg(unix)] + #[test] + fn manifest_file_is_0600() { + use std::os::unix::fs::PermissionsExt; + with_cfg(|_| { + save_manifest(&Manifest::default()).unwrap(); + let mode = std::fs::metadata(manifest_path().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + }); + } + + #[test] + fn corrupt_manifest_errors_with_hint() { + with_cfg(|_| { + save_manifest(&Manifest::default()).unwrap(); + std::fs::write(manifest_path().unwrap(), "not json").unwrap(); + let err = load_manifest().unwrap_err(); + assert!(err.to_string().contains("re-sync from scratch")); + }); + } + + #[test] + fn first_sync_ingests_then_second_is_unchanged() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + write_claude_session(home, "-test-project", "sess-bbb", "Fix a bug"); + let bundle = claude_bundle(home); + + let outcomes = sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap(); + assert_eq!(outcomes.len(), 1); + let (_, first) = outcomes[0]; + assert_eq!( + (first.new, first.updated, first.unchanged, first.failed), + (2, 0, 0, 0) + ); + + let manifest = load_manifest().unwrap(); + let records = manifest.get("claude").unwrap(); + assert_eq!(records.len(), 2); + let rec = records.get("sess-aaa").unwrap(); + assert_eq!(rec.path.as_deref(), Some("/test/project")); + assert!(rec.last_activity.is_some()); + assert!( + crate::cmd_cache::cache_path(&rec.cache_id) + .unwrap() + .exists(), + "cache doc must exist for {}", + rec.cache_id + ); + + let (_, second) = sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap()[0]; + assert_eq!( + (second.new, second.updated, second.unchanged, second.failed), + (0, 0, 2, 0) + ); + }); + } + + #[test] + fn changed_session_is_rederived() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap(); + + let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] + .cache_id + .clone(); + let steps_before = cached_step_count(&cache_id); + + // Session continues: a later user turn lands in the file. + let file = home.join(".claude/projects/-test-project/sess-aaa.jsonl"); + let mut body = std::fs::read_to_string(&file).unwrap(); + body.push_str( + r#"{"type":"user","uuid":"u-2","timestamp":"2024-01-02T00:05:00Z","cwd":"/test/project","message":{"role":"user","content":"And another thing"}}"#, + ); + body.push('\n'); + std::fs::write(&file, body).unwrap(); + + let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap()[0]; + assert_eq!( + ( + outcome.new, + outcome.updated, + outcome.unchanged, + outcome.failed + ), + (0, 1, 0, 0) + ); + assert!( + cached_step_count(&cache_id) > steps_before, + "re-derived doc must contain the appended turn" + ); + }); + } + + #[test] + fn sync_touches_only_requested_types() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + + let outcomes = sync_bundle(&bundle, &[ArtifactType::Codex]).unwrap(); + assert_eq!(outcomes[0].1, SyncOutcome::default()); + assert!( + load_manifest().unwrap().is_empty(), + "codex-only sync must not ingest claude sessions" + ); + }); + } + + #[test] + fn sync_overwrites_cache_entry_it_does_not_remember() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap(); + + // Losing the manifest (or a prior manual `p import`) leaves a + // cache entry sync doesn't know about; re-syncing must + // overwrite it, not die on the exists-check. + std::fs::remove_file(manifest_path().unwrap()).unwrap(); + let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap()[0]; + assert_eq!((outcome.new, outcome.failed), (1, 0)); + }); + } + + #[test] + fn failed_derivation_is_tallied_and_skipped() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + let cwd = std::env::current_dir().unwrap(); + let mut rows = gather_artifacts(&bundle, &cwd, Some(ArtifactType::Claude), None); + rows.push(make_row(ArtifactType::Claude, "does-not-exist")); + + let mut records = BTreeMap::new(); + let outcome = sync_rows(&bundle, &rows, &mut records).unwrap(); + assert_eq!((outcome.new, outcome.failed), (1, 1)); + assert!(records.contains_key("sess-aaa")); + assert!( + !records.contains_key("does-not-exist"), + "failed artifacts must not be recorded as synced" + ); }); + } + + #[test] + fn derive_row_errors_when_provider_missing() { + let bundle = HarnessBundle::default(); + let row = make_row(ArtifactType::Claude, "sess"); + let Err(err) = derive_row(&bundle, &row) else { + panic!("derive_row must fail without a claude manager"); + }; + assert!(err.to_string().contains("provider not available")); + } - let mut records = BTreeMap::new(); - let outcome = sync_rows(&bundle, &rows, &mut records).unwrap(); - assert_eq!((outcome.new, outcome.failed), (1, 1)); - assert!(records.contains_key("sess-aaa")); - assert!( - !records.contains_key("does-not-exist"), - "failed sessions must not be recorded as synced" + #[test] + fn resolve_types_defaults_to_all_and_dedups() { + assert_eq!(resolve_types(&[]), ArtifactType::ALL.to_vec()); + assert_eq!( + resolve_types(&[ + ArtifactType::Codex, + ArtifactType::Claude, + ArtifactType::Codex + ]), + vec![ArtifactType::Codex, ArtifactType::Claude] ); - }); - } + } - #[test] - fn derive_row_errors_when_provider_missing() { - let bundle = HarnessBundle::default(); - let row = ArtifactRow { - harness: Harness::Claude, - project: Some("/test/project".to_string()), - cwd: None, - session_id: "sess".to_string(), - title: String::new(), - last_activity: None, - message_count: 0, - matches_cwd: false, - }; - let Err(err) = derive_row(&bundle, &row) else { - panic!("derive_row must fail without a claude manager"); - }; - assert!(err.to_string().contains("provider not available")); + #[test] + fn render_summary_hides_empty_types_unless_explicit() { + let outcomes = vec![ + ( + ArtifactType::Claude, + SyncOutcome { + new: 2, + updated: 1, + unchanged: 3, + failed: 0, + }, + ), + (ArtifactType::Cursor, SyncOutcome::default()), + ]; + let default_run = render_summary(&outcomes, false); + assert!(default_run.contains("claude")); + assert!(!default_run.contains("cursor")); + + let explicit_run = render_summary(&outcomes, true); + assert!(explicit_run.contains("cursor")); + } + + #[test] + fn render_summary_shows_failures_and_empty_case() { + let outcomes = vec![( + ArtifactType::Codex, + SyncOutcome { + new: 0, + updated: 0, + unchanged: 1, + failed: 2, + }, + )]; + let s = render_summary(&outcomes, false); + assert!(s.contains("2 failed")); + + assert_eq!(render_summary(&[], false), "nothing to sync\n"); + } } +} + +#[cfg(test)] +mod type_tests { + use super::ArtifactType; #[test] - fn resolve_types_defaults_to_all_and_dedups() { - assert_eq!(resolve_types(&[]), Harness::ALL.to_vec()); - assert_eq!( - resolve_types(&[HarnessArg::Codex, HarnessArg::Claude, HarnessArg::Codex]), - vec![Harness::Codex, Harness::Claude] - ); + fn names_and_symbols_are_distinct() { + let names: std::collections::HashSet<&str> = + ArtifactType::ALL.iter().map(|t| t.name()).collect(); + let symbols: std::collections::HashSet<&str> = + ArtifactType::ALL.iter().map(|t| t.symbol()).collect(); + assert_eq!(names.len(), ArtifactType::ALL.len()); + assert_eq!(symbols.len(), ArtifactType::ALL.len()); } #[test] - fn render_summary_hides_empty_types_unless_explicit() { - let outcomes = vec![ - ( - Harness::Claude, - SyncOutcome { - new: 2, - updated: 1, - unchanged: 3, - failed: 0, - }, - ), - (Harness::Cursor, SyncOutcome::default()), - ]; - let default_run = render_summary(&outcomes, false); - assert!(default_run.contains("claude")); - assert!(!default_run.contains("cursor")); - - let explicit_run = render_summary(&outcomes, true); - assert!(explicit_run.contains("cursor")); + fn path_keyed_matches_design() { + assert!(ArtifactType::Claude.path_keyed()); + assert!(ArtifactType::Gemini.path_keyed()); + assert!(ArtifactType::Pi.path_keyed()); + assert!(!ArtifactType::Codex.path_keyed()); + assert!(!ArtifactType::Opencode.path_keyed()); + assert!(!ArtifactType::Cursor.path_keyed()); } #[test] - fn render_summary_shows_failures_and_empty_case() { - let outcomes = vec![( - Harness::Codex, - SyncOutcome { - new: 0, - updated: 0, - unchanged: 1, - failed: 2, - }, - )]; - let s = render_summary(&outcomes, false); - assert!(s.contains("2 failed")); - - assert_eq!(render_summary(&[], false), "nothing to sync\n"); + fn parse_roundtrips_every_name() { + for t in ArtifactType::ALL { + assert_eq!(ArtifactType::parse(t.name()), Some(t)); + } + assert_eq!(ArtifactType::parse("frobnicate"), None); } } diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index 32cac640..605b1259 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -8,7 +8,8 @@ #![cfg(not(target_os = "emscripten"))] -use path_cli::cmd_resume::{HarnessArg, RecordingExec, ResumeArgs, run_with_strategy}; +use path_cli::cmd_resume::{RecordingExec, ResumeArgs, run_with_strategy}; +use path_cli::sync::ArtifactType; mod support; use support::*; @@ -27,7 +28,7 @@ fn file_input_explicit_claude_projects_and_records_exec() { let recorder = RecordingExec::default(); run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Claude), + args_explicit(doc_file, cwd.path(), ArtifactType::Claude), &recorder, ) .unwrap(); @@ -61,7 +62,7 @@ fn file_input_explicit_gemini_projects_and_records_exec() { let recorder = RecordingExec::default(); run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Gemini), + args_explicit(doc_file, cwd.path(), ArtifactType::Gemini), &recorder, ) .unwrap(); @@ -89,7 +90,7 @@ fn file_input_explicit_codex_projects_and_records_exec() { let recorder = RecordingExec::default(); run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Codex), + args_explicit(doc_file, cwd.path(), ArtifactType::Codex), &recorder, ) .unwrap(); @@ -191,7 +192,7 @@ fn file_input_explicit_opencode_projects_and_records_exec() { let recorder = RecordingExec::default(); run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Opencode), + args_explicit(doc_file, cwd.path(), ArtifactType::Opencode), &recorder, ) .unwrap(); @@ -220,7 +221,7 @@ fn file_input_explicit_pi_projects_and_records_exec() { let recorder = RecordingExec::default(); run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Pi), + args_explicit(doc_file, cwd.path(), ArtifactType::Pi), &recorder, ) .unwrap(); @@ -264,7 +265,7 @@ fn cache_id_input_loads_and_projects() { let resume_args = ResumeArgs { input: cache_id.to_string(), cwd: Some(cwd.path().to_path_buf()), - harness: Some(HarnessArg::Claude), + harness: Some(ArtifactType::Claude), no_cache: false, force: false, url: None, @@ -304,7 +305,7 @@ fn multi_path_graph_returns_clear_error() { let recorder = RecordingExec::default(); let err = run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Claude), + args_explicit(doc_file, cwd.path(), ArtifactType::Claude), &recorder, ) .unwrap_err(); @@ -326,7 +327,7 @@ fn agentless_path_returns_clear_error() { let recorder = RecordingExec::default(); let err = run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Claude), + args_explicit(doc_file, cwd.path(), ArtifactType::Claude), &recorder, ) .unwrap_err(); @@ -345,7 +346,7 @@ fn explicit_harness_not_on_path_errors() { let recorder = RecordingExec::default(); let err = run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Claude), + args_explicit(doc_file, cwd.path(), ArtifactType::Claude), &recorder, ) .unwrap_err(); diff --git a/crates/path-cli/tests/support/mod.rs b/crates/path-cli/tests/support/mod.rs index 578b5af1..29edf255 100644 --- a/crates/path-cli/tests/support/mod.rs +++ b/crates/path-cli/tests/support/mod.rs @@ -11,7 +11,8 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; -use path_cli::cmd_resume::{HarnessArg, ResumeArgs}; +use path_cli::cmd_resume::ResumeArgs; +use path_cli::sync::ArtifactType; /// Process-wide lock for tests that mutate `$HOME`, `$PATH`, or /// `$TOOLPATH_CONFIG_DIR`. Integration tests under `tests/resume.rs` @@ -178,7 +179,7 @@ pub fn write_path_to_temp(dir: &Path, path: toolpath::v1::Path) -> PathBuf { } /// Construct `ResumeArgs` for a file-input + explicit-harness test. -pub fn args_explicit(input: PathBuf, cwd: &Path, harness: HarnessArg) -> ResumeArgs { +pub fn args_explicit(input: PathBuf, cwd: &Path, harness: ArtifactType) -> ResumeArgs { ResumeArgs { input: input.to_string_lossy().to_string(), cwd: Some(cwd.to_path_buf()), From 1f12f15cc9156daa8b1b10b7c85d0d90e2e155b3 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 9 Jul 2026 12:31:24 -0400 Subject: [PATCH 04/20] feat(cli): restore Harness as the harness-only layer over ArtifactType ArtifactType will grow non-session kinds (git, github), and those must be unrepresentable where the CLI means "an agent runtime": you can't resume into a git repo. Harness (cmd_share.rs, ValueEnum) names the six runtimes and is what share/resume --harness and resume's picker, availability checks, invocation, and projection take; it maps into the general enum via artifact_type(), with ArtifactType::harness() as the partial inverse. Sync, the manifest, import prefixes, and ArtifactRow stay on ArtifactType. --- CHANGELOG.md | 10 +- CLAUDE.md | 2 +- crates/path-cli/src/cmd_resume.rs | 144 ++++++++++++--------------- crates/path-cli/src/cmd_share.rs | 71 ++++++++++++- crates/path-cli/tests/resume.rs | 24 ++--- crates/path-cli/tests/support/mod.rs | 4 +- 6 files changed, 153 insertions(+), 102 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70bd4100..4098eec0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,10 +21,12 @@ users no longer have to `p import` each session by hand. record; otherwise it is skipped. The manifest is written atomically (temp file + rename, `0600`) and checkpointed after each type, so an interrupted first run keeps the types it finished. - - `ArtifactType` (in `sync.rs`) is the single enum naming artifact - sources, used everywhere the CLI names them: `p cache sync` types, - `share`/`resume` `--harness`, and import cache-id prefixes. It - replaces the former `Harness`/`HarnessArg` pair. + - `ArtifactType` (in `sync.rs`) is the general enum naming artifact + sources: `p cache sync` types, the manifest keys, and import + cache-id prefixes all use it (it absorbs the former `HarnessArg`). + The `Harness` enum stays as the harness-only layer that + `share`/`resume` `--harness` take — you can't resume into a git + repo — mapping into the general enum via `Harness::artifact_type()`. - Sync owns refresh semantics: it overwrites cache entries it re-derives (no `--force` needed), while manual `p import` keeps its error-on-hit default. Sessions deleted upstream keep both their cache diff --git a/CLAUDE.md b/CLAUDE.md index 780239dc..7f67ff68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -279,4 +279,4 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. - Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs all six agent harnesses. It enumerates via the `path share` aggregation (`cmd_share::gather_artifacts`, producing `ArtifactRow`s) and derives each session through the *same* provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`), so listing and derivation agree on provider roots and tests can inject fixture resolvers. An artifact is re-derived only when its `last_activity` fingerprint differs from the manifest at `~/.toolpath/sync.json` (artifact type → artifact id → `{path?, cache_id, last_activity?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type). Pi's listing reports session *start* time, so its rows use file mtime as `last_activity`. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. Caveats: enumeration costs the providers' metadata pass (full parse per JSONL session file — a stat-level fast path is the planned optimization before `path query` runs sync implicitly), and cursor composers without a workspace are skipped, same as `share`. -- `ArtifactType` (`crates/path-cli/src/sync.rs`) is the one enum naming artifact sources — claude/gemini/codex/opencode/cursor/pi today, other kinds (git, github) when sync learns them. It derives `clap::ValueEnum` and is used directly by `p cache sync` types, `share`/`resume` `--harness`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). There is deliberately no parallel `Harness`/`HarnessArg` enum anywhere; don't add one. +- `ArtifactType` (`crates/path-cli/src/sync.rs`) is the general enum naming artifact sources — claude/gemini/codex/opencode/cursor/pi today, other kinds (git, github) when sync learns them. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`cmd_share.rs`) names the six agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index c835bab5..d522e9c2 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -44,7 +44,10 @@ use anyhow::{Context, Result}; use clap::Args; use std::path::PathBuf; -use crate::sync::ArtifactType; +/// Re-exported so external callers (integration tests, future consumers) +/// can construct [`ResumeArgs`] without depending on the private +/// `cmd_share` module directly. +pub use crate::cmd_share::Harness; #[derive(Args, Debug)] pub struct ResumeArgs { @@ -62,7 +65,7 @@ pub struct ResumeArgs { /// Pin the resume target. Skips the interactive picker. #[arg(long, value_enum)] - pub harness: Option, + pub harness: Option, /// Skip the cache entirely when fetching from Pathbase: don't read /// an existing entry, don't write the fetched body. Useful for @@ -120,38 +123,38 @@ use toolpath::v1::{Graph, Path as TPath, PathOrRef}; /// Read a path's source harness from `meta.source` (set by /// `toolpath-convo::derive_path` to the provider id), falling back to /// actor-string sniffing across the path's steps. -pub(crate) fn infer_source_harness(path: &TPath) -> Option { +pub(crate) fn infer_source_harness(path: &TPath) -> Option { let meta_source = path.meta.as_ref().and_then(|m| m.source.as_deref()); if let Some(source) = meta_source { match source { - "claude-code" => return Some(ArtifactType::Claude), - "gemini-cli" => return Some(ArtifactType::Gemini), - "codex" => return Some(ArtifactType::Codex), - "opencode" => return Some(ArtifactType::Opencode), - "cursor" => return Some(ArtifactType::Cursor), - "pi" => return Some(ArtifactType::Pi), + "claude-code" => return Some(Harness::Claude), + "gemini-cli" => return Some(Harness::Gemini), + "codex" => return Some(Harness::Codex), + "opencode" => return Some(Harness::Opencode), + "cursor" => return Some(Harness::Cursor), + "pi" => return Some(Harness::Pi), _ => {} // fall through to actor sniffing } } for step in &path.steps { let actor = &step.step.actor; if actor.starts_with("agent:claude-code") { - return Some(ArtifactType::Claude); + return Some(Harness::Claude); } if actor.starts_with("agent:gemini-cli") || actor.starts_with("agent:gemini") { - return Some(ArtifactType::Gemini); + return Some(Harness::Gemini); } if actor.starts_with("agent:codex") { - return Some(ArtifactType::Codex); + return Some(Harness::Codex); } if actor.starts_with("agent:opencode") { - return Some(ArtifactType::Opencode); + return Some(Harness::Opencode); } if actor.starts_with("agent:cursor") { - return Some(ArtifactType::Cursor); + return Some(Harness::Cursor); } if actor.starts_with("agent:pi") { - return Some(ArtifactType::Pi); + return Some(Harness::Pi); } } None @@ -192,7 +195,7 @@ pub(crate) fn ensure_path_with_agent(g: &Graph) -> Result<&TPath> { /// Resolve the user-supplied `` argument into a parsed `Graph` /// plus the source harness inferred from its single inline path (if /// any). See spec § "Input resolution" for the order. -pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option)> { +pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option)> { let raw = args.input.as_str(); enum Shape<'a> { @@ -295,14 +298,11 @@ pub(crate) fn binary_on_path(name: &str, path_override: Option<&std::path::Path> /// explicitly from the IDE's command palette, but `open -a Cursor` /// (macOS) / `xdg-open` (Linux) always work. Treat cursor as available /// when either path is open. -pub(crate) fn harness_available( - harness: ArtifactType, - path_override: Option<&std::path::Path>, -) -> bool { +pub(crate) fn harness_available(harness: Harness, path_override: Option<&std::path::Path>) -> bool { if binary_on_path(harness.name(), path_override) { return true; } - if harness == ArtifactType::Cursor { + if harness == Harness::Cursor { #[cfg(target_os = "macos")] { return binary_on_path("open", path_override); @@ -315,15 +315,6 @@ pub(crate) fn harness_available( false } -const ALL_HARNESSES: &[ArtifactType] = &[ - ArtifactType::Claude, - ArtifactType::Gemini, - ArtifactType::Codex, - ArtifactType::Opencode, - ArtifactType::Cursor, - ArtifactType::Pi, -]; - /// Decide which harness to resume in. /// /// - If `arg` is `Some`, validate the named harness is on PATH and return it. @@ -332,10 +323,10 @@ const ALL_HARNESSES: &[ArtifactType] = &[ /// /// `path_override` is `None` in production; tests pass `Some(dir)` to fake `$PATH`. pub(crate) fn pick_harness( - arg: Option, - source: Option, + arg: Option, + source: Option, path_override: Option<&std::path::Path>, -) -> Result { +) -> Result { if let Some(h) = arg { if !harness_available(h, path_override) { anyhow::bail!( @@ -346,7 +337,7 @@ pub(crate) fn pick_harness( return Ok(h); } - let installed: Vec = ALL_HARNESSES + let installed: Vec = Harness::ALL .iter() .copied() .filter(|h| harness_available(*h, path_override)) @@ -361,10 +352,7 @@ pub(crate) fn pick_harness( interactive_pick(&installed, source) } -fn interactive_pick( - installed: &[ArtifactType], - source: Option, -) -> Result { +fn interactive_pick(installed: &[Harness], source: Option) -> Result { if !crate::fuzzy::available() { let hint = if crate::fuzzy::embedded_picker_available() { "rerun in a terminal" @@ -409,29 +397,29 @@ fn interactive_pick( /// Static map from harness to resume-argv shape. Lives here because /// it's a per-harness CLI convention, not a projection concern. -pub(crate) fn argv_for(harness: ArtifactType, session_id: &str) -> Vec { +pub(crate) fn argv_for(harness: Harness, session_id: &str) -> Vec { match harness { - ArtifactType::Claude => vec!["-r".into(), session_id.into()], - ArtifactType::Gemini => vec!["--resume".into(), session_id.into()], - ArtifactType::Codex => vec!["resume".into(), session_id.into()], - ArtifactType::Opencode => vec!["--session".into(), session_id.into()], + Harness::Claude => vec!["-r".into(), session_id.into()], + Harness::Gemini => vec!["--resume".into(), session_id.into()], + Harness::Codex => vec!["resume".into(), session_id.into()], + Harness::Opencode => vec!["--session".into(), session_id.into()], // Cursor.app has no "open composer by id" flag — we exec the // workspace path so Cursor opens on that folder; the projected // composer appears at the top of the chat list. - ArtifactType::Cursor => { + Harness::Cursor => { let _ = session_id; vec![".".into()] } - ArtifactType::Pi => vec!["--session".into(), session_id.into()], + Harness::Pi => vec!["--session".into(), session_id.into()], } } pub(crate) fn invocation_for( - harness: ArtifactType, + harness: Harness, session_id: &str, cwd: &std::path::Path, ) -> (String, Vec) { - if harness == ArtifactType::Cursor { + if harness == Harness::Cursor { return cursor_invocation(cwd); } (harness.name().to_string(), argv_for(harness, session_id)) @@ -464,16 +452,16 @@ fn cursor_invocation(cwd: &std::path::Path) -> (String, Vec) { /// returning the projected session id. pub(crate) fn project_into_harness( path: &TPath, - harness: ArtifactType, + harness: Harness, cwd: &std::path::Path, ) -> Result { match harness { - ArtifactType::Claude => crate::cmd_export::project_claude(path, cwd), - ArtifactType::Gemini => crate::cmd_export::project_gemini(path, cwd), - ArtifactType::Codex => crate::cmd_export::project_codex(path, cwd), - ArtifactType::Opencode => crate::cmd_export::project_opencode(path, cwd), - ArtifactType::Cursor => crate::cmd_export::project_cursor(path, cwd), - ArtifactType::Pi => crate::cmd_export::project_pi(path, cwd), + Harness::Claude => crate::cmd_export::project_claude(path, cwd), + Harness::Gemini => crate::cmd_export::project_gemini(path, cwd), + Harness::Codex => crate::cmd_export::project_codex(path, cwd), + Harness::Opencode => crate::cmd_export::project_opencode(path, cwd), + Harness::Cursor => crate::cmd_export::project_cursor(path, cwd), + Harness::Pi => crate::cmd_export::project_pi(path, cwd), } } @@ -610,7 +598,7 @@ mod tests { let args = ResumeArgs { input: doc_file.to_string_lossy().to_string(), cwd: Some(cwd.path().to_path_buf()), - harness: Some(ArtifactType::Claude), + harness: Some(Harness::Claude), no_cache: false, force: false, url: None, @@ -654,7 +642,7 @@ mod tests { source: Some("claude-code".to_string()), ..Default::default() }); - assert_eq!(infer_source_harness(&path), Some(ArtifactType::Claude)); + assert_eq!(infer_source_harness(&path), Some(Harness::Claude)); } #[test] @@ -664,25 +652,25 @@ mod tests { source: Some("something-bespoke".to_string()), ..Default::default() }); - assert_eq!(infer_source_harness(&path), Some(ArtifactType::Gemini)); + assert_eq!(infer_source_harness(&path), Some(Harness::Gemini)); } #[test] fn infer_source_harness_actor_sniff_codex() { let path = make_path_with_actor("agent:codex"); - assert_eq!(infer_source_harness(&path), Some(ArtifactType::Codex)); + assert_eq!(infer_source_harness(&path), Some(Harness::Codex)); } #[test] fn infer_source_harness_actor_sniff_opencode() { let path = make_path_with_actor("agent:opencode"); - assert_eq!(infer_source_harness(&path), Some(ArtifactType::Opencode)); + assert_eq!(infer_source_harness(&path), Some(Harness::Opencode)); } #[test] fn infer_source_harness_actor_sniff_pi() { let path = make_path_with_actor("agent:pi"); - assert_eq!(infer_source_harness(&path), Some(ArtifactType::Pi)); + assert_eq!(infer_source_harness(&path), Some(Harness::Pi)); } #[test] @@ -753,7 +741,7 @@ mod tests { }; let (g, harness) = resolve_input(&args).unwrap(); let _path = ensure_path_with_agent(&g).unwrap(); - assert_eq!(harness, Some(ArtifactType::Claude)); + assert_eq!(harness, Some(Harness::Claude)); } #[test] @@ -787,7 +775,7 @@ mod tests { }; let (g, harness) = resolve_input(&args).unwrap(); let _ = ensure_path_with_agent(&g).unwrap(); - assert_eq!(harness, Some(ArtifactType::Codex)); + assert_eq!(harness, Some(Harness::Codex)); } #[test] @@ -858,7 +846,7 @@ mod tests { let (g, harness) = result.expect("resolve_input should reuse cache without refetching"); let _ = ensure_path_with_agent(&g).unwrap(); - assert_eq!(harness, Some(ArtifactType::Codex)); + assert_eq!(harness, Some(Harness::Codex)); } #[test] @@ -905,10 +893,10 @@ mod tests { #[test] fn pick_harness_explicit_arg_validates_path() { let td = fake_path_with(&["claude"]); - let result = pick_harness(Some(ArtifactType::Claude), None, Some(td.path())); - assert_eq!(result.unwrap(), ArtifactType::Claude); + let result = pick_harness(Some(Harness::Claude), None, Some(td.path())); + assert_eq!(result.unwrap(), Harness::Claude); - let err = pick_harness(Some(ArtifactType::Gemini), None, Some(td.path())).unwrap_err(); + let err = pick_harness(Some(Harness::Gemini), None, Some(td.path())).unwrap_err(); assert!(err.to_string().contains("`gemini` isn't on PATH")); } @@ -916,21 +904,21 @@ mod tests { #[test] fn cursor_available_via_open_fallback_on_macos() { let td = fake_path_with(&["open"]); - assert!(harness_available(ArtifactType::Cursor, Some(td.path()))); - let picked = pick_harness(Some(ArtifactType::Cursor), None, Some(td.path())); - assert_eq!(picked.unwrap(), ArtifactType::Cursor); + assert!(harness_available(Harness::Cursor, Some(td.path()))); + let picked = pick_harness(Some(Harness::Cursor), None, Some(td.path())); + assert_eq!(picked.unwrap(), Harness::Cursor); } #[test] fn cursor_unavailable_when_no_launcher_at_all() { let td = fake_path_with(&["claude"]); - assert!(!harness_available(ArtifactType::Cursor, Some(td.path()))); + assert!(!harness_available(Harness::Cursor, Some(td.path()))); } #[test] fn cursor_invocation_includes_workspace_path() { let cwd = std::path::PathBuf::from("/tmp/some-workspace"); - let (binary, argv) = invocation_for(ArtifactType::Cursor, "ignored-session-id", &cwd); + let (binary, argv) = invocation_for(Harness::Cursor, "ignored-session-id", &cwd); assert!( argv.iter().any(|a| a == "/tmp/some-workspace"), "workspace path must appear in argv; got {argv:?}", @@ -944,7 +932,7 @@ mod tests { #[test] fn pick_harness_zero_installed_errors() { let td = fake_path_with(&[]); - let err = pick_harness(None, Some(ArtifactType::Claude), Some(td.path())).unwrap_err(); + let err = pick_harness(None, Some(Harness::Claude), Some(td.path())).unwrap_err(); assert!( err.to_string().contains("no installed harnesses") || err.to_string().contains("no harnesses on PATH"), @@ -956,23 +944,23 @@ mod tests { #[test] fn argv_for_returns_harness_specific_shape() { assert_eq!( - argv_for(ArtifactType::Claude, "abc"), + argv_for(Harness::Claude, "abc"), vec!["-r".to_string(), "abc".to_string()] ); assert_eq!( - argv_for(ArtifactType::Gemini, "abc"), + argv_for(Harness::Gemini, "abc"), vec!["--resume".to_string(), "abc".to_string()] ); assert_eq!( - argv_for(ArtifactType::Codex, "abc"), + argv_for(Harness::Codex, "abc"), vec!["resume".to_string(), "abc".to_string()] ); assert_eq!( - argv_for(ArtifactType::Opencode, "abc"), + argv_for(Harness::Opencode, "abc"), vec!["--session".to_string(), "abc".to_string()] ); assert_eq!( - argv_for(ArtifactType::Pi, "abc"), + argv_for(Harness::Pi, "abc"), vec!["--session".to_string(), "abc".to_string()] ); } @@ -986,7 +974,7 @@ mod tests { let cwd = tempfile::tempdir().unwrap(); let path = make_convo_path_for_resume("claude-code://resume-test-session"); - let session_id = project_into_harness(&path, ArtifactType::Claude, cwd.path()).unwrap(); + let session_id = project_into_harness(&path, Harness::Claude, cwd.path()).unwrap(); assert!(!session_id.is_empty()); } diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index 19ddfb14..ffd15c2e 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -11,6 +11,71 @@ use std::path::PathBuf; use crate::cmd_export::RepoSpec; use crate::sync::ArtifactType; +/// An installed agent harness — a runtime sessions can be shared from +/// and resumed into. Deliberately parallel to [`ArtifactType`]: every +/// harness maps onto an artifact type, but not every artifact type is +/// a harness once non-session kinds (git, github) join `ArtifactType` +/// — you can't resume into a git repo. Harness-facing surfaces +/// (`share`/`resume` `--harness`) take this type so non-harness values +/// stay unrepresentable. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, clap::ValueEnum)] +#[value(rename_all = "lower")] +pub enum Harness { + Claude, + Gemini, + Codex, + Opencode, + Cursor, + Pi, +} + +impl Harness { + /// Every harness, in presentation order. + pub(crate) const ALL: [Harness; 6] = [ + Harness::Claude, + Harness::Gemini, + Harness::Codex, + Harness::Opencode, + Harness::Cursor, + Harness::Pi, + ]; + + /// The artifact type this harness's sessions ingest as. + pub(crate) fn artifact_type(&self) -> ArtifactType { + match self { + Harness::Claude => ArtifactType::Claude, + Harness::Gemini => ArtifactType::Gemini, + Harness::Codex => ArtifactType::Codex, + Harness::Opencode => ArtifactType::Opencode, + Harness::Cursor => ArtifactType::Cursor, + Harness::Pi => ArtifactType::Pi, + } + } + + pub(crate) fn name(&self) -> &'static str { + self.artifact_type().name() + } + + pub(crate) fn symbol(&self) -> &'static str { + self.artifact_type().symbol() + } +} + +impl ArtifactType { + /// The harness this artifact type's sessions come from — `None` + /// for artifact types that aren't agent harnesses (none yet). + pub(crate) fn harness(&self) -> Option { + match self { + ArtifactType::Claude => Some(Harness::Claude), + ArtifactType::Gemini => Some(Harness::Gemini), + ArtifactType::Codex => Some(Harness::Codex), + ArtifactType::Opencode => Some(Harness::Opencode), + ArtifactType::Cursor => Some(Harness::Cursor), + ArtifactType::Pi => Some(Harness::Pi), + } + } +} + #[derive(Args, Debug)] pub struct ShareArgs { /// Pathbase server URL (defaults to the stored session's server) @@ -38,7 +103,7 @@ pub struct ShareArgs { /// Narrow the picker to one harness, or skip the picker entirely /// when used with --session. #[arg(long, value_enum)] - pub harness: Option, + pub harness: Option, /// Skip the picker. Requires --harness; requires --project for /// claude/gemini/pi. @@ -545,7 +610,7 @@ fn is_not_found_cursor(err: &toolpath_cursor::CursorError) -> bool { } pub fn run(args: ShareArgs) -> Result<()> { - let harness = args.harness; + let harness = args.harness.map(|h| h.artifact_type()); if args.session.is_some() && harness.is_none() { anyhow::bail!("--session requires --harness"); @@ -634,7 +699,7 @@ pub fn run(args: ShareArgs) -> Result<()> { repo: args.repo.clone(), name: args.name.clone(), public: args.public, - harness: Some(h), + harness: h.harness(), session: None, // unused by share_explicit project: if h.path_keyed() { Some(PathBuf::from(&key)) diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index 605b1259..faed228a 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -8,8 +8,8 @@ #![cfg(not(target_os = "emscripten"))] +use path_cli::cmd_resume::Harness; use path_cli::cmd_resume::{RecordingExec, ResumeArgs, run_with_strategy}; -use path_cli::sync::ArtifactType; mod support; use support::*; @@ -28,7 +28,7 @@ fn file_input_explicit_claude_projects_and_records_exec() { let recorder = RecordingExec::default(); run_with_strategy( - args_explicit(doc_file, cwd.path(), ArtifactType::Claude), + args_explicit(doc_file, cwd.path(), Harness::Claude), &recorder, ) .unwrap(); @@ -62,7 +62,7 @@ fn file_input_explicit_gemini_projects_and_records_exec() { let recorder = RecordingExec::default(); run_with_strategy( - args_explicit(doc_file, cwd.path(), ArtifactType::Gemini), + args_explicit(doc_file, cwd.path(), Harness::Gemini), &recorder, ) .unwrap(); @@ -90,7 +90,7 @@ fn file_input_explicit_codex_projects_and_records_exec() { let recorder = RecordingExec::default(); run_with_strategy( - args_explicit(doc_file, cwd.path(), ArtifactType::Codex), + args_explicit(doc_file, cwd.path(), Harness::Codex), &recorder, ) .unwrap(); @@ -192,7 +192,7 @@ fn file_input_explicit_opencode_projects_and_records_exec() { let recorder = RecordingExec::default(); run_with_strategy( - args_explicit(doc_file, cwd.path(), ArtifactType::Opencode), + args_explicit(doc_file, cwd.path(), Harness::Opencode), &recorder, ) .unwrap(); @@ -220,11 +220,7 @@ fn file_input_explicit_pi_projects_and_records_exec() { let doc_file = write_path_to_temp(cwd.path(), path); let recorder = RecordingExec::default(); - run_with_strategy( - args_explicit(doc_file, cwd.path(), ArtifactType::Pi), - &recorder, - ) - .unwrap(); + run_with_strategy(args_explicit(doc_file, cwd.path(), Harness::Pi), &recorder).unwrap(); let cap = recorder.captured(); assert_eq!(cap.binary, "pi"); @@ -265,7 +261,7 @@ fn cache_id_input_loads_and_projects() { let resume_args = ResumeArgs { input: cache_id.to_string(), cwd: Some(cwd.path().to_path_buf()), - harness: Some(ArtifactType::Claude), + harness: Some(Harness::Claude), no_cache: false, force: false, url: None, @@ -305,7 +301,7 @@ fn multi_path_graph_returns_clear_error() { let recorder = RecordingExec::default(); let err = run_with_strategy( - args_explicit(doc_file, cwd.path(), ArtifactType::Claude), + args_explicit(doc_file, cwd.path(), Harness::Claude), &recorder, ) .unwrap_err(); @@ -327,7 +323,7 @@ fn agentless_path_returns_clear_error() { let recorder = RecordingExec::default(); let err = run_with_strategy( - args_explicit(doc_file, cwd.path(), ArtifactType::Claude), + args_explicit(doc_file, cwd.path(), Harness::Claude), &recorder, ) .unwrap_err(); @@ -346,7 +342,7 @@ fn explicit_harness_not_on_path_errors() { let recorder = RecordingExec::default(); let err = run_with_strategy( - args_explicit(doc_file, cwd.path(), ArtifactType::Claude), + args_explicit(doc_file, cwd.path(), Harness::Claude), &recorder, ) .unwrap_err(); diff --git a/crates/path-cli/tests/support/mod.rs b/crates/path-cli/tests/support/mod.rs index 29edf255..7a168f49 100644 --- a/crates/path-cli/tests/support/mod.rs +++ b/crates/path-cli/tests/support/mod.rs @@ -11,8 +11,8 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; +use path_cli::cmd_resume::Harness; use path_cli::cmd_resume::ResumeArgs; -use path_cli::sync::ArtifactType; /// Process-wide lock for tests that mutate `$HOME`, `$PATH`, or /// `$TOOLPATH_CONFIG_DIR`. Integration tests under `tests/resume.rs` @@ -179,7 +179,7 @@ pub fn write_path_to_temp(dir: &Path, path: toolpath::v1::Path) -> PathBuf { } /// Construct `ResumeArgs` for a file-input + explicit-harness test. -pub fn args_explicit(input: PathBuf, cwd: &Path, harness: ArtifactType) -> ResumeArgs { +pub fn args_explicit(input: PathBuf, cwd: &Path, harness: Harness) -> ResumeArgs { ResumeArgs { input: input.to_string_lossy().to_string(), cwd: Some(cwd.to_path_buf()), From 2ce188828a31ac4b3a4749197d54ada2c3d8ca2c Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 9 Jul 2026 12:49:35 -0400 Subject: [PATCH 05/20] feat(cli): record message_count in sync.json for harness types SyncRecord regains message_count as an Option, giving session fingerprints a second signal alongside last_activity. It stays a harness-only notion: sync_rows strips it via artifact_type.harness() so future non-session artifact kinds record None, and ArtifactRow carries it as Option populated by the six harness collectors. The share picker shows the msgs column again as a side effect. --- CHANGELOG.md | 10 ++++++---- CLAUDE.md | 2 +- crates/path-cli/src/cmd_share.rs | 18 ++++++++++++++++-- crates/path-cli/src/sync.rs | 17 +++++++++++++++++ 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4098eec0..df99253a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,10 +16,12 @@ users no longer have to `p import` each session by hand. derivation goes through the same provider managers, so listing and derivation always agree on provider roots. - The sync manifest at `~/.toolpath/sync.json` maps artifact type → - artifact id → `{path?, cache_id, last_activity?, synced_at}`. An - artifact is re-derived when its `last_activity` differs from the - record; otherwise it is skipped. The manifest is written atomically - (temp file + rename, `0600`) and checkpointed after each type, so an + artifact id → `{path?, cache_id, last_activity?, message_count?, + synced_at}`. An artifact is re-derived when its fingerprint + (`last_activity`, plus `message_count` for harness types) differs + from the record; non-session artifact kinds have no message notion + and record no count. The manifest is written atomically (temp file + + rename, `0600`) and checkpointed after each type, so an interrupted first run keeps the types it finished. - `ArtifactType` (in `sync.rs`) is the general enum naming artifact sources: `p cache sync` types, the manifest keys, and import diff --git a/CLAUDE.md b/CLAUDE.md index 7f67ff68..4fe45e96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,5 +278,5 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. -- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs all six agent harnesses. It enumerates via the `path share` aggregation (`cmd_share::gather_artifacts`, producing `ArtifactRow`s) and derives each session through the *same* provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`), so listing and derivation agree on provider roots and tests can inject fixture resolvers. An artifact is re-derived only when its `last_activity` fingerprint differs from the manifest at `~/.toolpath/sync.json` (artifact type → artifact id → `{path?, cache_id, last_activity?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type). Pi's listing reports session *start* time, so its rows use file mtime as `last_activity`. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. Caveats: enumeration costs the providers' metadata pass (full parse per JSONL session file — a stat-level fast path is the planned optimization before `path query` runs sync implicitly), and cursor composers without a workspace are skipped, same as `share`. +- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs all six agent harnesses. It enumerates via the `path share` aggregation (`cmd_share::gather_artifacts`, producing `ArtifactRow`s) and derives each session through the *same* provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`), so listing and derivation agree on provider roots and tests can inject fixture resolvers. An artifact is re-derived only when its fingerprint (`last_activity`, plus `message_count` for harness types — non-session artifact kinds have no message notion and record `None`) differs from the manifest at `~/.toolpath/sync.json` (artifact type → artifact id → `{path?, cache_id, last_activity?, message_count?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type). Pi's listing reports session *start* time, so its rows use file mtime as `last_activity`. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. Caveats: enumeration costs the providers' metadata pass (full parse per JSONL session file — a stat-level fast path is the planned optimization before `path query` runs sync implicitly), and cursor composers without a workspace are skipped, same as `share`. - `ArtifactType` (`crates/path-cli/src/sync.rs`) is the general enum naming artifact sources — claude/gemini/codex/opencode/cursor/pi today, other kinds (git, github) when sync learns them. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`cmd_share.rs`) names the six agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index ffd15c2e..00c79216 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -132,6 +132,9 @@ pub(crate) struct ArtifactRow { pub(crate) session_id: String, pub(crate) title: String, pub(crate) last_activity: Option>, + /// Message count — populated only for harness artifact types + /// (agent sessions); `None` for future non-session artifact kinds. + pub(crate) message_count: Option, pub(crate) matches_cwd: bool, } @@ -272,6 +275,7 @@ fn collect_claude( .first_user_message .unwrap_or_else(|| "(no prompt)".to_string()), last_activity: m.last_activity, + message_count: Some(m.message_count), matches_cwd, }); } @@ -318,6 +322,7 @@ fn collect_gemini( .first_user_message .unwrap_or_else(|| "(no prompt)".to_string()), last_activity: m.last_activity, + message_count: Some(m.message_count), matches_cwd, }); } @@ -377,6 +382,7 @@ fn collect_pi( .first_user_message .unwrap_or_else(|| "(no prompt)".to_string()), last_activity, + message_count: Some(m.entry_count), matches_cwd, }); } @@ -423,6 +429,7 @@ fn collect_codex( .first_user_message .unwrap_or_else(|| "(no prompt)".to_string()), last_activity: m.last_activity, + message_count: Some(m.line_count), matches_cwd, }); } @@ -506,6 +513,7 @@ fn collect_opencode( session_id: m.id, title, last_activity: m.last_activity, + message_count: Some(m.message_count), matches_cwd, }); } @@ -554,6 +562,7 @@ fn collect_cursor( session_id: m.id, title, last_activity: m.last_activity, + message_count: Some(m.message_count), matches_cwd, }); } @@ -980,7 +989,9 @@ fn format_picker_row(row: &ArtifactRow) -> String { let display = render_row( Some(&leading), row.last_activity, - "", + &row.message_count + .map(|c| count(c, "msgs")) + .unwrap_or_default(), Some(&project_short(&key)), &row.title, ); @@ -1012,7 +1023,7 @@ fn parse_picker_row(line: &str) -> Option<(ArtifactType, String, String, String) Some((h, key, session, title)) } -use crate::fuzzy::{clean_for_picker_display, project_short, render_row, tab_safe}; +use crate::fuzzy::{clean_for_picker_display, count, project_short, render_row, tab_safe}; fn derive_session( harness: ArtifactType, @@ -1286,6 +1297,7 @@ mod tests { session_id: "sess-abc".to_string(), title: "Hello\tworld".to_string(), last_activity: None, + message_count: None, matches_cwd: true, }; let line = format_picker_row(&row); @@ -1307,6 +1319,7 @@ mod tests { session_id: "0190abcd".to_string(), title: "(no prompt)".to_string(), last_activity: None, + message_count: None, matches_cwd: false, }; let line = format_picker_row(&row); @@ -1326,6 +1339,7 @@ mod tests { session_id: "11111111-2222-3333-4444-555555555555".to_string(), title: "Add the share command — finally".to_string(), last_activity: None, + message_count: None, matches_cwd: true, }; let line = format_picker_row(&row); diff --git a/crates/path-cli/src/sync.rs b/crates/path-cli/src/sync.rs index 73eb8721..ee8c6aaa 100644 --- a/crates/path-cli/src/sync.rs +++ b/crates/path-cli/src/sync.rs @@ -122,6 +122,11 @@ mod engine { /// sync time. #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) last_activity: Option>, + /// Fingerprint: message count at sync time. Recorded only for + /// harness artifact types (agent sessions); non-session + /// artifact kinds have no message notion and stay `None`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) message_count: Option, pub(crate) synced_at: DateTime, } @@ -206,10 +211,14 @@ mod engine { ) -> Result { let mut outcome = SyncOutcome::default(); for row in rows { + // Message counts only make sense for harness types; strip + // them for anything else so the record stays general. + let message_count = row.artifact_type.harness().and(row.message_count); let existing = records.get(&row.session_id); let is_new = existing.is_none(); if let Some(rec) = existing && rec.last_activity == row.last_activity + && rec.message_count == message_count { outcome.unchanged += 1; continue; @@ -226,6 +235,7 @@ mod engine { path: row.path.clone(), cache_id: derived.cache_id, last_activity: row.last_activity, + message_count, synced_at: Utc::now(), }, ); @@ -425,6 +435,7 @@ mod engine { session_id: session_id.to_string(), title: String::new(), last_activity: None, + message_count: None, matches_cwd: false, } } @@ -441,6 +452,7 @@ mod engine { path: Some("/test/project".to_string()), cache_id: "claude-p1".to_string(), last_activity: Some("2024-01-02T00:00:01Z".parse().unwrap()), + message_count: Some(2), synced_at: "2026-07-09T00:00:00Z".parse().unwrap(), }, ); @@ -495,6 +507,11 @@ mod engine { let rec = records.get("sess-aaa").unwrap(); assert_eq!(rec.path.as_deref(), Some("/test/project")); assert!(rec.last_activity.is_some()); + assert_eq!( + rec.message_count, + Some(2), + "harness records must carry the message count" + ); assert!( crate::cmd_cache::cache_path(&rec.cache_id) .unwrap() From 927371c6c29f6988f206f7223b5c989c276715e2 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 9 Jul 2026 13:01:06 -0400 Subject: [PATCH 06/20] =?UTF-8?q?perf(cli):=20stat-level=20sync=20fingerpr?= =?UTF-8?q?ints=20=E2=80=94=20no-op=20sync=20reads=20no=20bodies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change detection previously compared last_activity + message_count, both of which come from listings that parse every session body — the fingerprint cost as much as the thing it was avoiding. Sync now enumerates ArtifactStubs whose fingerprint is the source file's mtime + size (claude: head segment via the bounded first-lines peek of list_conversations; codex: rollout stat, id from the stem's trailing uuid; pi: file stat, id from a one-line header peek) or the DB row's updated-at (opencode: header-only SELECT; cursor: composer headers, bubble-less drafts skipped, workspace-less composers now included). Real data: a 34-session no-op sync drops 0.9s → 11ms. Gemini still enumerates via its metadata listing (main files carry the session id inside the JSON); its fingerprint is a stat of the listed file, and a peek-level listing in toolpath-gemini is the follow-up. message_count stays in the manifest but is informational only, recorded at derive time from the source the derive already read (DerivedDoc.message_count), still restricted to harness types. The stamp is taken before the derive reads the source, so a write racing the derive re-syncs next run instead of going unnoticed. --- CHANGELOG.md | 16 +- CLAUDE.md | 4 +- crates/path-cli/src/cmd_import.rs | 58 +++- crates/path-cli/src/cmd_share.rs | 19 +- crates/path-cli/src/sync.rs | 433 ++++++++++++++++++++++++------ 5 files changed, 419 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df99253a..8d72d25f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,13 +16,15 @@ users no longer have to `p import` each session by hand. derivation goes through the same provider managers, so listing and derivation always agree on provider roots. - The sync manifest at `~/.toolpath/sync.json` maps artifact type → - artifact id → `{path?, cache_id, last_activity?, message_count?, - synced_at}`. An artifact is re-derived when its fingerprint - (`last_activity`, plus `message_count` for harness types) differs - from the record; non-session artifact kinds have no message notion - and record no count. The manifest is written atomically (temp file - + rename, `0600`) and checkpointed after each type, so an - interrupted first run keeps the types it finished. + artifact id → `{path?, cache_id, modified?, size?, message_count?, + synced_at}`. Change detection is stat-level — source mtime + size + for the file-backed providers, the DB row's updated-at for + opencode/cursor — so deciding "nothing changed" reads no session + bodies and a no-op sync is milliseconds. `message_count` is + recorded at derive time for harness types as information, never + compared. The manifest is written atomically (temp file + rename, + `0600`) and checkpointed after each type, so an interrupted first + run keeps the types it finished. - `ArtifactType` (in `sync.rs`) is the general enum naming artifact sources: `p cache sync` types, the manifest keys, and import cache-id prefixes all use it (it absorbs the former `HarnessArg`). diff --git a/CLAUDE.md b/CLAUDE.md index 4fe45e96..484b081f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -208,7 +208,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-cursor`: 78 unit + 8 integration round-trip + 1 real-DB sanity + 1 doc test (state.vscdb SQLite reader, bubble store + composer header parsing, content-addressed blob lookup, projector with full TOOL_TABLE coverage, JSONL transcript ingest in `examples/dump_fixture.rs`) - `toolpath-pi`: 133 unit + 26 integration + 5 doc tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `path-cli`: 336 unit + 101 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — manifest fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. +- `path-cli`: 337 unit + 101 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — manifest fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. - `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary) Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- p validate --input "$f"; done` @@ -278,5 +278,5 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. -- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs all six agent harnesses. It enumerates via the `path share` aggregation (`cmd_share::gather_artifacts`, producing `ArtifactRow`s) and derives each session through the *same* provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`), so listing and derivation agree on provider roots and tests can inject fixture resolvers. An artifact is re-derived only when its fingerprint (`last_activity`, plus `message_count` for harness types — non-session artifact kinds have no message notion and record `None`) differs from the manifest at `~/.toolpath/sync.json` (artifact type → artifact id → `{path?, cache_id, last_activity?, message_count?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type). Pi's listing reports session *start* time, so its rows use file mtime as `last_activity`. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. Caveats: enumeration costs the providers' metadata pass (full parse per JSONL session file — a stat-level fast path is the planned optimization before `path query` runs sync implicitly), and cursor composers without a workspace are skipped, same as `share`. +- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs all six agent harnesses. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: head segment via `list_conversations`' bounded first-lines peek; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Gemini is the one exception: its main files carry the session id inside the JSON, so enumeration still uses the metadata listing (fingerprint is a stat of the listed file all the same); a peek-level listing in `toolpath-gemini` is the follow-up. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`, which also report a `message_count` on `DerivedDoc` — recorded in the manifest for harness types as information, never compared). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, message_count?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. - `ArtifactType` (`crates/path-cli/src/sync.rs`) is the general enum naming artifact sources — claude/gemini/codex/opencode/cursor/pi today, other kinds (git, github) when sync learns them. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`cmd_share.rs`) names the six agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index 4549d249..980104b0 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -202,6 +202,10 @@ pub fn run(args: ImportArgs, pretty: bool) -> Result<()> { pub(crate) struct DerivedDoc { pub(crate) cache_id: String, pub(crate) doc: Graph, + /// Message count from the source read, recorded in the sync + /// manifest. `None` for non-session sources (git, github, + /// pathbase) and for bulk wraps that no longer hold the source. + pub(crate) message_count: Option, } fn emit(docs: &[DerivedDoc], force: bool, no_cache: bool, pretty: bool) -> Result<()> { @@ -325,7 +329,11 @@ fn derive_git( let repo_tag = short_path_hash(&canonical.to_string_lossy()); let inner = doc_inner_id(&doc); let cache_id = make_id("git", &format!("{repo_tag}-{inner}")); - Ok(vec![DerivedDoc { cache_id, doc }]) + Ok(vec![DerivedDoc { + cache_id, + doc, + message_count: None, + }]) } } @@ -387,7 +395,11 @@ fn derive_github( let path = toolpath_github::derive_pull_request(&owner, &repo_name, pr_number, &config)?; let doc = Graph::from_path(path); let cache_id = make_id("github", &format!("{owner}_{repo_name}-{pr_number}")); - Ok(vec![DerivedDoc { cache_id, doc }]) + Ok(vec![DerivedDoc { + cache_id, + doc, + message_count: None, + }]) } } @@ -515,6 +527,7 @@ pub(crate) fn derive_claude_session_with( Ok(DerivedDoc { cache_id, doc: Graph::from_path(path), + message_count: Some(convo.entries.len()), }) } @@ -526,6 +539,7 @@ fn wrap_paths(t: ArtifactType, paths: Vec) -> Result { #[cfg(not(target_os = "emscripten"))] @@ -1521,7 +1543,11 @@ fn derive_pi_with_manager( })?; let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); - return Ok(vec![DerivedDoc { cache_id, doc }]); + return Ok(vec![DerivedDoc { + cache_id, + doc, + message_count: None, + }]); } } #[cfg(target_os = "emscripten")] @@ -1532,7 +1558,11 @@ fn derive_pi_with_manager( .ok_or_else(|| anyhow::anyhow!("No Pi sessions found for project: {}", p))?; let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); - return Ok(vec![DerivedDoc { cache_id, doc }]); + return Ok(vec![DerivedDoc { + cache_id, + doc, + message_count: None, + }]); } } (None, _, _) => { @@ -1560,7 +1590,11 @@ fn derive_pi_with_manager( .map_err(|e| anyhow::anyhow!("{}", e))?; let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); - docs.push(DerivedDoc { cache_id, doc }); + docs.push(DerivedDoc { + cache_id, + doc, + message_count: None, + }); } Ok(docs) } @@ -1592,7 +1626,11 @@ pub(crate) fn derive_pi_session_with( .map_err(|e| anyhow::anyhow!("{}", e))?; let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); - Ok(DerivedDoc { cache_id, doc }) + Ok(DerivedDoc { + cache_id, + doc, + message_count: Some(session.entries.len()), + }) } #[cfg(not(target_os = "emscripten"))] @@ -1768,7 +1806,11 @@ pub(crate) fn pathbase_fetch_to_doc(target: &str, url_flag: Option<&str>) -> Res let cache_id = make_id("pathbase", &format!("{owner}-{repo}-{id}")); let doc = Graph::from_json(&body) .map_err(|e| anyhow::anyhow!("server returned a non-toolpath document: {e}"))?; - Ok(DerivedDoc { cache_id, doc }) + Ok(DerivedDoc { + cache_id, + doc, + message_count: None, + }) } fn derive_pathbase(target: String, url_flag: Option) -> Result> { diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index 00c79216..5f79fe08 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -568,41 +568,34 @@ fn collect_cursor( } } -fn is_not_found_claude(err: &toolpath_claude::ConvoError) -> bool { +pub(crate) fn is_not_found_claude(err: &toolpath_claude::ConvoError) -> bool { use toolpath_claude::ConvoError; matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) || matches!(err, ConvoError::NoHomeDirectory) || matches!(err, ConvoError::ClaudeDirectoryNotFound(_)) } -fn is_not_found_gemini(err: &toolpath_gemini::ConvoError) -> bool { +pub(crate) fn is_not_found_gemini(err: &toolpath_gemini::ConvoError) -> bool { use toolpath_gemini::ConvoError; matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) || matches!(err, ConvoError::NoHomeDirectory) || matches!(err, ConvoError::GeminiDirectoryNotFound(_)) } -fn is_not_found_pi(err: &toolpath_pi::PiError) -> bool { +pub(crate) fn is_not_found_pi(err: &toolpath_pi::PiError) -> bool { use toolpath_pi::PiError; matches!(err, PiError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) || matches!(err, PiError::ProjectNotFound(_)) } -fn is_not_found_codex(err: &toolpath_codex::ConvoError) -> bool { +pub(crate) fn is_not_found_codex(err: &toolpath_codex::ConvoError) -> bool { use toolpath_codex::ConvoError; matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) || matches!(err, ConvoError::NoHomeDirectory) || matches!(err, ConvoError::CodexDirectoryNotFound(_)) } -fn is_not_found_copilot(err: &toolpath_copilot::ConvoError) -> bool { - use toolpath_copilot::ConvoError; - matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) - || matches!(err, ConvoError::NoHomeDirectory) - || matches!(err, ConvoError::CopilotDirectoryNotFound(_)) -} - -fn is_not_found_opencode(err: &toolpath_opencode::ConvoError) -> bool { +pub(crate) fn is_not_found_opencode(err: &toolpath_opencode::ConvoError) -> bool { use toolpath_opencode::ConvoError; matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) || matches!(err, ConvoError::NoHomeDirectory) @@ -610,7 +603,7 @@ fn is_not_found_opencode(err: &toolpath_opencode::ConvoError) -> bool { || matches!(err, ConvoError::DatabaseNotFound(_)) } -fn is_not_found_cursor(err: &toolpath_cursor::CursorError) -> bool { +pub(crate) fn is_not_found_cursor(err: &toolpath_cursor::CursorError) -> bool { use toolpath_cursor::CursorError; matches!(err, CursorError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) || matches!(err, CursorError::NoHomeDirectory) diff --git a/crates/path-cli/src/sync.rs b/crates/path-cli/src/sync.rs index ee8c6aaa..65b3d420 100644 --- a/crates/path-cli/src/sync.rs +++ b/crates/path-cli/src/sync.rs @@ -3,15 +3,14 @@ //! artifact sources the CLI operates over. //! //! Sync enumerates artifacts across the requested types (today all six -//! are agent-session providers, using the same aggregation `path share` -//! uses), compares each against the sync manifest at -//! `$CONFIG_DIR/sync.json`, and derives + caches only what is new or -//! changed. The manifest maps artifact type → artifact id → the -//! `last_activity` fingerprint recorded at last sync, so an unchanged -//! artifact costs a metadata read instead of a re-derivation, and -//! running sync twice in a row is a no-op. Artifacts deleted upstream -//! keep both their cache document and their manifest record — the -//! cache is an archive, not a mirror. +//! are agent-session providers), compares each against the sync +//! manifest at `$CONFIG_DIR/sync.json`, and derives + caches only what +//! is new or changed. Change detection is stat-level: the fingerprint +//! is the source file's mtime + size (or the database row's updated-at +//! for the SQLite-backed providers), so deciding "nothing changed" +//! never reads session bodies. Artifacts deleted upstream keep both +//! their cache document and their manifest record — the cache is an +//! archive, not a mirror. /// The kind of artifact an operation ranges over. One enum, used /// everywhere a command names artifact sources (`p cache sync` types, @@ -99,16 +98,35 @@ mod engine { use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; - use std::path::PathBuf; + use std::path::{Path, PathBuf}; use super::ArtifactType; use crate::cmd_cache::write_cached; use crate::cmd_import::DerivedDoc; - use crate::cmd_share::{ArtifactRow, HarnessBundle, gather_artifacts}; + use crate::cmd_share::{ + HarnessBundle, is_not_found_claude, is_not_found_codex, is_not_found_cursor, + is_not_found_gemini, is_not_found_opencode, is_not_found_pi, + }; use crate::config::config_dir; const MANIFEST_FILE: &str = "sync.json"; + /// A cheaply-enumerated artifact: identity plus the stat-level + /// fingerprint used for change detection. Producing one never + /// parses session bodies. + #[derive(Debug, Clone)] + pub(crate) struct ArtifactStub { + pub(crate) artifact_type: ArtifactType, + pub(crate) id: String, + /// Filesystem path the artifact is keyed under, for path-keyed + /// providers (the project directory). + pub(crate) path: Option, + /// Source mtime (file providers) or updated-at (DB providers). + pub(crate) modified: Option>, + /// Source file size; `None` for DB-backed providers. + pub(crate) size: Option, + } + /// What the manifest remembers about one synced artifact. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub(crate) struct SyncRecord { @@ -118,13 +136,17 @@ mod engine { pub(crate) path: Option, /// Cache entry the derived document was written to. pub(crate) cache_id: String, - /// Fingerprint: the artifact's last activity as reported at - /// sync time. + /// Fingerprint: source mtime (file providers) or updated-at + /// (DB providers) at sync time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) modified: Option>, + /// Fingerprint: source file size at sync time. #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) last_activity: Option>, - /// Fingerprint: message count at sync time. Recorded only for - /// harness artifact types (agent sessions); non-session - /// artifact kinds have no message notion and stay `None`. + pub(crate) size: Option, + /// Message count as of the last derivation. Informational only + /// — never part of change detection — and recorded only for + /// harness artifact types (non-session kinds have no message + /// notion). #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) message_count: Option, pub(crate) synced_at: DateTime, @@ -181,17 +203,14 @@ mod engine { types: &[ArtifactType], ) -> Result> { let mut manifest = load_manifest()?; - // Only ranking depends on cwd and sync ignores row order, so any - // directory works here. - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let mut out = Vec::with_capacity(types.len()); for &artifact_type in types { - let rows = gather_artifacts(bundle, &cwd, Some(artifact_type), None); + let stubs = enumerate_stubs(bundle, artifact_type); let mut records = manifest .get(artifact_type.name()) .cloned() .unwrap_or_default(); - let outcome = sync_rows(bundle, &rows, &mut records)?; + let outcome = sync_stubs(bundle, &stubs, &mut records)?; if !records.is_empty() { manifest.insert(artifact_type.name().to_string(), records); save_manifest(&manifest)?; @@ -201,41 +220,42 @@ mod engine { Ok(out) } - /// Sync one type's rows against its manifest records. Derivation + /// Sync one type's stubs against its manifest records. Derivation /// failures are warned and tallied, not fatal; cache-write failures /// (disk, permissions) abort. - fn sync_rows( + fn sync_stubs( bundle: &HarnessBundle, - rows: &[ArtifactRow], + stubs: &[ArtifactStub], records: &mut BTreeMap, ) -> Result { let mut outcome = SyncOutcome::default(); - for row in rows { - // Message counts only make sense for harness types; strip - // them for anything else so the record stays general. - let message_count = row.artifact_type.harness().and(row.message_count); - let existing = records.get(&row.session_id); + for stub in stubs { + let existing = records.get(&stub.id); let is_new = existing.is_none(); if let Some(rec) = existing - && rec.last_activity == row.last_activity - && rec.message_count == message_count + && rec.modified == stub.modified + && rec.size == stub.size { outcome.unchanged += 1; continue; } - match derive_row(bundle, row) { + match derive_stub(bundle, stub) { Ok(derived) => { // force: sync owns refresh semantics — a re-sync or a // prior manual `p import` of the same session must not // error on the existing cache entry. write_cached(&derived.cache_id, &derived.doc, true)?; records.insert( - row.session_id.clone(), + stub.id.clone(), SyncRecord { - path: row.path.clone(), + path: stub.path.clone(), cache_id: derived.cache_id, - last_activity: row.last_activity, - message_count, + // The stamp was taken before the derive read the + // source, so a write racing the derive re-syncs + // next run instead of going unnoticed. + modified: stub.modified, + size: stub.size, + message_count: stub.artifact_type.harness().and(derived.message_count), synced_at: Utc::now(), }, ); @@ -248,8 +268,8 @@ mod engine { Err(e) => { eprintln!( "warning: sync {}: {}: {e}", - row.artifact_type.name(), - row.session_id + stub.artifact_type.name(), + stub.id ); outcome.failed += 1; } @@ -258,44 +278,283 @@ mod engine { Ok(outcome) } - /// Derive one artifact through the same manager the row was - /// enumerated from, so listing and derivation always agree on - /// provider roots. - fn derive_row(bundle: &HarnessBundle, row: &ArtifactRow) -> Result { + /// Derive one artifact through the same manager it was enumerated + /// from, so listing and derivation always agree on provider roots. + fn derive_stub(bundle: &HarnessBundle, stub: &ArtifactStub) -> Result { use crate::cmd_import as imp; let path = || { - row.path + stub.path .as_deref() - .ok_or_else(|| anyhow!("artifact {} has no path", row.session_id)) + .ok_or_else(|| anyhow!("artifact {} has no path", stub.id)) }; - match row.artifact_type { + match stub.artifact_type { ArtifactType::Claude => { - imp::derive_claude_session_with(mgr(&bundle.claude)?, path()?, &row.session_id) + imp::derive_claude_session_with(mgr(&bundle.claude)?, path()?, &stub.id) + } + ArtifactType::Gemini => { + imp::derive_gemini_session_with(mgr(&bundle.gemini)?, path()?, &stub.id, false) } - ArtifactType::Gemini => imp::derive_gemini_session_with( - mgr(&bundle.gemini)?, - path()?, - &row.session_id, - false, + ArtifactType::Pi => imp::derive_pi_session_with(mgr(&bundle.pi)?, path()?, &stub.id), + ArtifactType::Codex => imp::derive_codex_session_with(mgr(&bundle.codex)?, &stub.id), + ArtifactType::Opencode => { + imp::derive_opencode_session_with(mgr(&bundle.opencode)?, &stub.id, false) + } + ArtifactType::Cursor => imp::derive_cursor_session_with(mgr(&bundle.cursor)?, &stub.id), + } + } + + fn mgr(slot: &Option) -> Result<&T> { + slot.as_ref() + .ok_or_else(|| anyhow!("provider not available")) + } + + // ── stat-level enumeration ───────────────────────────────────────── + + /// (mtime, size) of a file, both `None` when the stat fails. + fn stat_stamp(path: &Path) -> (Option>, Option) { + match std::fs::metadata(path) { + Ok(md) => ( + md.modified().ok().map(DateTime::::from), + Some(md.len()), ), - ArtifactType::Pi => { - imp::derive_pi_session_with(mgr(&bundle.pi)?, path()?, &row.session_id) + Err(_) => (None, None), + } + } + + /// Enumerate one type's artifacts with stat-level fingerprints. + /// Providers that aren't installed produce no stubs; other listing + /// errors warn and skip so one broken provider can't block the rest. + fn enumerate_stubs(bundle: &HarnessBundle, t: ArtifactType) -> Vec { + let mut out = Vec::new(); + match t { + ArtifactType::Claude => { + if let Some(mgr) = &bundle.claude { + stubs_claude(mgr, &mut out); + } + } + ArtifactType::Gemini => { + if let Some(mgr) = &bundle.gemini { + stubs_gemini(mgr, &mut out); + } } ArtifactType::Codex => { - imp::derive_codex_session_with(mgr(&bundle.codex)?, &row.session_id) + if let Some(mgr) = &bundle.codex { + stubs_codex(mgr, &mut out); + } } ArtifactType::Opencode => { - imp::derive_opencode_session_with(mgr(&bundle.opencode)?, &row.session_id, false) + if let Some(mgr) = &bundle.opencode { + stubs_opencode(mgr, &mut out); + } } ArtifactType::Cursor => { - imp::derive_cursor_session_with(mgr(&bundle.cursor)?, &row.session_id) + if let Some(mgr) = &bundle.cursor { + stubs_cursor(mgr, &mut out); + } + } + ArtifactType::Pi => { + if let Some(mgr) = &bundle.pi { + stubs_pi(mgr, &mut out); + } } } + out } - fn mgr(slot: &Option) -> Result<&T> { - slot.as_ref() - .ok_or_else(|| anyhow!("provider not available")) + /// Chain heads via `list_conversations` (bounded first-lines peek + /// per file, no full parse); fingerprint stats the head segment — + /// appends land there, and a rotation surfaces as a new head id. + fn stubs_claude(mgr: &toolpath_claude::ClaudeConvo, out: &mut Vec) { + let projects = match mgr.list_projects() { + Ok(ps) => ps, + Err(e) if is_not_found_claude(&e) => return, + Err(e) => { + eprintln!("warning: claude enumeration failed: {e}"); + return; + } + }; + for project in projects { + let heads = match mgr.list_conversations(&project) { + Ok(h) => h, + Err(e) => { + eprintln!("warning: claude project {project} failed: {e}"); + continue; + } + }; + for head in heads { + let (modified, size) = mgr + .resolver() + .conversation_file(&project, &head) + .map(|p| stat_stamp(&p)) + .unwrap_or((None, None)); + out.push(ArtifactStub { + artifact_type: ArtifactType::Claude, + id: head, + path: Some(project.clone()), + modified, + size, + }); + } + } + } + + /// Gemini main files carry their session id *inside* the JSON, so + /// enumeration still goes through the metadata listing; the + /// fingerprint is a stat of the listed file all the same. A + /// peek-level listing in `toolpath-gemini` would drop the parse. + fn stubs_gemini(mgr: &toolpath_gemini::GeminiConvo, out: &mut Vec) { + let projects = match mgr.list_projects() { + Ok(ps) => ps, + Err(e) if is_not_found_gemini(&e) => return, + Err(e) => { + eprintln!("warning: gemini enumeration failed: {e}"); + return; + } + }; + for project in projects { + let metas = match mgr.list_conversation_metadata(&project) { + Ok(m) => m, + Err(e) => { + eprintln!("warning: gemini project {project} failed: {e}"); + continue; + } + }; + for m in metas { + let (modified, size) = stat_stamp(&m.file_path); + out.push(ArtifactStub { + artifact_type: ArtifactType::Gemini, + id: m.session_uuid, + path: Some(m.project_path), + modified, + size, + }); + } + } + } + + /// Rollout files, stat-only. The artifact id is the trailing UUID of + /// the filename stem (`rollout--`); `read_session` + /// accepts either the UUID or the full stem, so the fallback is safe. + fn stubs_codex(mgr: &toolpath_codex::CodexConvo, out: &mut Vec) { + let files = match mgr.io().list_rollout_files() { + Ok(f) => f, + Err(e) if is_not_found_codex(&e) => return, + Err(e) => { + eprintln!("warning: codex enumeration failed: {e}"); + return; + } + }; + for file in files { + let Some(stem) = file.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let id = stem + .len() + .checked_sub(36) + .and_then(|at| stem.get(at..)) + .filter(|tail| tail.bytes().filter(|&b| b == b'-').count() == 4) + .unwrap_or(stem) + .to_string(); + let (modified, size) = stat_stamp(&file); + out.push(ArtifactStub { + artifact_type: ArtifactType::Codex, + id, + path: None, + modified, + size, + }); + } + } + + /// One header-only `SELECT` — `time_updated` is the fingerprint; no + /// message bodies are loaded. + fn stubs_opencode(mgr: &toolpath_opencode::OpencodeConvo, out: &mut Vec) { + let sessions = match mgr.io().list_sessions(None) { + Ok(s) => s, + Err(e) if is_not_found_opencode(&e) => return, + Err(e) => { + eprintln!("warning: opencode enumeration failed: {e}"); + return; + } + }; + for s in sessions { + out.push(ArtifactStub { + artifact_type: ArtifactType::Opencode, + modified: s.last_activity(), + id: s.id, + path: None, + size: None, + }); + } + } + + /// Composer headers (one `SELECT` plus a per-composer bubble-count + /// check) — `lastUpdatedAt` is the fingerprint. Bubble-less drafts + /// are skipped; unlike `share`, composers without a workspace are + /// included, since sync doesn't need to rank them by project. + fn stubs_cursor(mgr: &toolpath_cursor::CursorConvo, out: &mut Vec) { + let listings = match mgr.io().list_composers() { + Ok(l) => l, + Err(e) if is_not_found_cursor(&e) => return, + Err(e) => { + eprintln!("warning: cursor enumeration failed: {e}"); + return; + } + }; + for l in listings.into_iter().filter(|l| l.has_bubbles) { + out.push(ArtifactStub { + artifact_type: ArtifactType::Cursor, + modified: l.head.last_updated_at_utc(), + id: l.head.composer_id, + path: None, + size: None, + }); + } + } + + /// Session files stat-only; the id comes from a one-line header + /// peek, falling back to the filename stem's `_` + /// shape — the same resolution `read_session` accepts. + fn stubs_pi(mgr: &toolpath_pi::PiConvo, out: &mut Vec) { + let projects = match mgr.list_projects() { + Ok(ps) => ps, + Err(e) if is_not_found_pi(&e) => return, + Err(e) => { + eprintln!("warning: pi enumeration failed: {e}"); + return; + } + }; + for project in projects { + let files = match toolpath_pi::reader::list_session_files(mgr.resolver(), &project) { + Ok(f) => f, + Err(e) => { + eprintln!("warning: pi project {project} failed: {e}"); + continue; + } + }; + for file in files { + let header_id = toolpath_pi::reader::peek_header(&file) + .ok() + .map(|h| h.id) + .filter(|id| !id.is_empty()); + let stem_id = file + .file_stem() + .and_then(|s| s.to_str()) + .and_then(|s| s.split_once('_')) + .map(|(_, rest)| rest.to_string()); + let Some(id) = header_id.or(stem_id) else { + continue; + }; + let (modified, size) = stat_stamp(&file); + out.push(ArtifactStub { + artifact_type: ArtifactType::Pi, + id, + path: Some(project.clone()), + modified, + size, + }); + } + } } /// One stderr line per artifact type. Types the user didn't name @@ -374,7 +633,6 @@ mod engine { mod tests { use super::*; use crate::config::{CONFIG_DIR_ENV, TEST_ENV_LOCK}; - use std::path::Path; /// Run `f` with `$TOOLPATH_CONFIG_DIR` pinned to `/.toolpath`; /// `f` receives the tempdir root for building provider fixtures. @@ -427,16 +685,13 @@ mod engine { doc.single_path().map(|p| p.steps.len()).unwrap_or(0) } - fn make_row(artifact_type: ArtifactType, session_id: &str) -> ArtifactRow { - ArtifactRow { + fn make_stub(artifact_type: ArtifactType, id: &str) -> ArtifactStub { + ArtifactStub { artifact_type, + id: id.to_string(), path: Some("/test/project".to_string()), - cwd: None, - session_id: session_id.to_string(), - title: String::new(), - last_activity: None, - message_count: None, - matches_cwd: false, + modified: None, + size: None, } } @@ -451,7 +706,8 @@ mod engine { SyncRecord { path: Some("/test/project".to_string()), cache_id: "claude-p1".to_string(), - last_activity: Some("2024-01-02T00:00:01Z".parse().unwrap()), + modified: Some("2024-01-02T00:00:01.123456789Z".parse().unwrap()), + size: Some(4096), message_count: Some(2), synced_at: "2026-07-09T00:00:00Z".parse().unwrap(), }, @@ -486,6 +742,20 @@ mod engine { }); } + #[test] + fn enumerate_stubs_stats_claude_sessions() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + let stubs = enumerate_stubs(&bundle, ArtifactType::Claude); + assert_eq!(stubs.len(), 1); + assert_eq!(stubs[0].id, "sess-aaa"); + assert_eq!(stubs[0].path.as_deref(), Some("/test/project")); + assert!(stubs[0].modified.is_some(), "file mtime must be stamped"); + assert!(stubs[0].size.unwrap() > 0, "file size must be stamped"); + }); + } + #[test] fn first_sync_ingests_then_second_is_unchanged() { with_cfg(|home| { @@ -506,11 +776,12 @@ mod engine { assert_eq!(records.len(), 2); let rec = records.get("sess-aaa").unwrap(); assert_eq!(rec.path.as_deref(), Some("/test/project")); - assert!(rec.last_activity.is_some()); + assert!(rec.modified.is_some()); + assert!(rec.size.is_some()); assert_eq!( rec.message_count, Some(2), - "harness records must carry the message count" + "harness records carry the message count from the derive" ); assert!( crate::cmd_cache::cache_path(&rec.cache_id) @@ -540,7 +811,8 @@ mod engine { .clone(); let steps_before = cached_step_count(&cache_id); - // Session continues: a later user turn lands in the file. + // Session continues: a later user turn lands in the file, + // changing its size (and mtime). let file = home.join(".claude/projects/-test-project/sess-aaa.jsonl"); let mut body = std::fs::read_to_string(&file).unwrap(); body.push_str( @@ -602,12 +874,11 @@ mod engine { with_cfg(|home| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); - let cwd = std::env::current_dir().unwrap(); - let mut rows = gather_artifacts(&bundle, &cwd, Some(ArtifactType::Claude), None); - rows.push(make_row(ArtifactType::Claude, "does-not-exist")); + let mut stubs = enumerate_stubs(&bundle, ArtifactType::Claude); + stubs.push(make_stub(ArtifactType::Claude, "does-not-exist")); let mut records = BTreeMap::new(); - let outcome = sync_rows(&bundle, &rows, &mut records).unwrap(); + let outcome = sync_stubs(&bundle, &stubs, &mut records).unwrap(); assert_eq!((outcome.new, outcome.failed), (1, 1)); assert!(records.contains_key("sess-aaa")); assert!( @@ -618,11 +889,11 @@ mod engine { } #[test] - fn derive_row_errors_when_provider_missing() { + fn derive_stub_errors_when_provider_missing() { let bundle = HarnessBundle::default(); - let row = make_row(ArtifactType::Claude, "sess"); - let Err(err) = derive_row(&bundle, &row) else { - panic!("derive_row must fail without a claude manager"); + let stub = make_stub(ArtifactType::Claude, "sess"); + let Err(err) = derive_stub(&bundle, &stub) else { + panic!("derive_stub must fail without a claude manager"); }; assert!(err.to_string().contains("provider not available")); } From 96aafd0bd09767abb7d91c22d24bca8f39ccc301 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 9 Jul 2026 13:16:24 -0400 Subject: [PATCH 07/20] perf(gemini): bounded identity peek; drop message_count from the manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toolpath-gemini 0.7.0 adds PathResolver::list_session_entries — each session's listing id, inner sessionId, and backing file/dir path — and bounds peek_session_id to the first 4 KiB of a main chat file (identity fields lead the JSON; full parse only as fallback, guarded so a sessionId appearing after the messages key is never trusted from the prefix). list_sessions delegates unchanged. Sync's gemini stubs now use it, so no provider reads chat bodies to decide nothing changed. message_count leaves SyncRecord and DerivedDoc entirely: nothing ever read it from the manifest. ArtifactRow keeps its count for the share picker column, which is its only consumer. --- CHANGELOG.md | 15 ++- CLAUDE.md | 4 +- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/path-cli/src/cmd_import.rs | 58 ++-------- crates/path-cli/src/sync.rs | 32 ++---- crates/toolpath-gemini/Cargo.toml | 2 +- crates/toolpath-gemini/src/lib.rs | 2 +- crates/toolpath-gemini/src/paths.rs | 172 +++++++++++++++++++++++++--- site/_data/crates.json | 2 +- 10 files changed, 192 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d72d25f..087acc2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,13 +16,11 @@ users no longer have to `p import` each session by hand. derivation goes through the same provider managers, so listing and derivation always agree on provider roots. - The sync manifest at `~/.toolpath/sync.json` maps artifact type → - artifact id → `{path?, cache_id, modified?, size?, message_count?, - synced_at}`. Change detection is stat-level — source mtime + size + artifact id → `{path?, cache_id, modified?, size?, synced_at}`. Change detection is stat-level — source mtime + size for the file-backed providers, the DB row's updated-at for opencode/cursor — so deciding "nothing changed" reads no session - bodies and a no-op sync is milliseconds. `message_count` is - recorded at derive time for harness types as information, never - compared. The manifest is written atomically (temp file + rename, + bodies and a no-op sync is milliseconds. The manifest is written + atomically (temp file + rename, `0600`) and checkpointed after each type, so an interrupted first run keeps the types it finished. - `ArtifactType` (in `sync.rs`) is the general enum naming artifact @@ -40,6 +38,13 @@ users no longer have to `p import` each session by hand. On a default run, harnesses with no sessions stay silent; explicit types always report. Derivation failures warn and tally as `failed` without aborting the run. +- **`toolpath-gemini`** (0.7.0): new `PathResolver::list_session_entries` + returns each session's listing id, inner `sessionId`, and backing + file/dir path, and `peek_session_id` is now bounded — it scans the + first 4 KiB of a main chat file (identity fields lead the JSON) and + falls back to a full parse only when they don't. `list_sessions` + delegates to it unchanged. This is what lets `p cache sync` fingerprint + gemini sessions without reading chat bodies. - **`toolpath-cli`** (0.16.0): version bump only (tracks `path-cli`). ## Derive: resolve duplicate step ids — 2026-07-01 diff --git a/CLAUDE.md b/CLAUDE.md index 484b081f..c785e4a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -201,7 +201,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-git`: 33 unit + 3 doc tests (derive, branch detection, diffstat) - `toolpath-github`: 32 unit + 3 doc tests (mapping, DAG construction, fixtures) - `toolpath-claude`: 229 unit + 18 integration + 6 doc tests (path resolution, conversation reading, query, chaining, watcher, derive, metadata first-user-message, group_id grouping + once-per-message usage totals) -- `toolpath-gemini`: 161 unit + 29 integration + 5 doc tests (path resolution, chat-file parsing, query, watcher, derive, provider, round-trip fidelity, thoughts-folded-into-output + reasoning breakdown round-trip) +- `toolpath-gemini`: 165 unit + 29 integration + 5 doc tests (path resolution, chat-file parsing, query, watcher, derive, provider, round-trip fidelity, thoughts-folded-into-output + reasoning breakdown round-trip) - `toolpath-codex`: 80 unit + 51 integration + 2 doc tests (rollout parsing, provider assembly, patch-fidelity derive, real-session fixture, source→path fidelity invariants, JSON wire-level round-trip, per-turn token deltas from cumulative counters, reasoning breakdown) - `toolpath-copilot`: 63 unit + 8 integration + 1 doc test (`events.jsonl` envelope/event-type classification incl. `session.start` nested `context` + `tool.execution` `result.content` + `assistant.message` `reasoningText`/`outputTokens`, `session.shutdown` `tokenDetails`, path resolution incl. legacy `history-session-state/`, reader malformed-line tolerance without env races, tolerant `workspace.yaml` parse, `to_view` turn/tool/delegation assembly + per-turn token usage + shutdown-total merge, id-based **and** id-less positional tool pairing, position-stable turn ids, native file-state diff from `result.detailedContent`, `CopilotProjector` round-trip + foreign-tool-name remap). Ships a **real captured feature-elicit session** at `tests/fixtures/real-session.jsonl` (also `test-fixtures/copilot/convo.jsonl`) driving `real_fixture_roundtrip.rs` (forward invariants, projection round-trip fidelity, wire-level serde value-identity). The projector is exercised by the cross-harness matrix in `path-cli`. - `toolpath-opencode`: 52 unit + 19 integration + 1 doc test (SQLite reader, JSON payload serde, provider assembly, snapshot-based derive, tool-input fallback for gitignored paths, reasoning breakdown) @@ -278,5 +278,5 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. -- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs all six agent harnesses. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: head segment via `list_conversations`' bounded first-lines peek; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Gemini is the one exception: its main files carry the session id inside the JSON, so enumeration still uses the metadata listing (fingerprint is a stat of the listed file all the same); a peek-level listing in `toolpath-gemini` is the follow-up. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`, which also report a `message_count` on `DerivedDoc` — recorded in the manifest for harness types as information, never compared). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, message_count?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. +- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs all six agent harnesses. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: head segment via `list_conversations`' bounded first-lines peek; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.7.0), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. - `ArtifactType` (`crates/path-cli/src/sync.rs`) is the general enum naming artifact sources — claude/gemini/codex/opencode/cursor/pi today, other kinds (git, github) when sync learns them. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`cmd_share.rs`) names the six agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/Cargo.lock b/Cargo.lock index 032f4c23..414189ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4155,7 +4155,7 @@ dependencies = [ [[package]] name = "toolpath-gemini" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index b1f7bfca..7684701d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ toolpath = { version = "0.7.0", path = "crates/toolpath" } toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" } toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" } toolpath-claude = { version = "0.12.0", path = "crates/toolpath-claude", default-features = false } -toolpath-gemini = { version = "0.6.0", path = "crates/toolpath-gemini", default-features = false } +toolpath-gemini = { version = "0.7.0", path = "crates/toolpath-gemini", default-features = false } toolpath-codex = { version = "0.6.0", path = "crates/toolpath-codex" } toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" } toolpath-opencode = { version = "0.5.0", path = "crates/toolpath-opencode" } diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index 980104b0..4549d249 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -202,10 +202,6 @@ pub fn run(args: ImportArgs, pretty: bool) -> Result<()> { pub(crate) struct DerivedDoc { pub(crate) cache_id: String, pub(crate) doc: Graph, - /// Message count from the source read, recorded in the sync - /// manifest. `None` for non-session sources (git, github, - /// pathbase) and for bulk wraps that no longer hold the source. - pub(crate) message_count: Option, } fn emit(docs: &[DerivedDoc], force: bool, no_cache: bool, pretty: bool) -> Result<()> { @@ -329,11 +325,7 @@ fn derive_git( let repo_tag = short_path_hash(&canonical.to_string_lossy()); let inner = doc_inner_id(&doc); let cache_id = make_id("git", &format!("{repo_tag}-{inner}")); - Ok(vec![DerivedDoc { - cache_id, - doc, - message_count: None, - }]) + Ok(vec![DerivedDoc { cache_id, doc }]) } } @@ -395,11 +387,7 @@ fn derive_github( let path = toolpath_github::derive_pull_request(&owner, &repo_name, pr_number, &config)?; let doc = Graph::from_path(path); let cache_id = make_id("github", &format!("{owner}_{repo_name}-{pr_number}")); - Ok(vec![DerivedDoc { - cache_id, - doc, - message_count: None, - }]) + Ok(vec![DerivedDoc { cache_id, doc }]) } } @@ -527,7 +515,6 @@ pub(crate) fn derive_claude_session_with( Ok(DerivedDoc { cache_id, doc: Graph::from_path(path), - message_count: Some(convo.entries.len()), }) } @@ -539,7 +526,6 @@ fn wrap_paths(t: ArtifactType, paths: Vec) -> Result { #[cfg(not(target_os = "emscripten"))] @@ -1543,11 +1521,7 @@ fn derive_pi_with_manager( })?; let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); - return Ok(vec![DerivedDoc { - cache_id, - doc, - message_count: None, - }]); + return Ok(vec![DerivedDoc { cache_id, doc }]); } } #[cfg(target_os = "emscripten")] @@ -1558,11 +1532,7 @@ fn derive_pi_with_manager( .ok_or_else(|| anyhow::anyhow!("No Pi sessions found for project: {}", p))?; let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); - return Ok(vec![DerivedDoc { - cache_id, - doc, - message_count: None, - }]); + return Ok(vec![DerivedDoc { cache_id, doc }]); } } (None, _, _) => { @@ -1590,11 +1560,7 @@ fn derive_pi_with_manager( .map_err(|e| anyhow::anyhow!("{}", e))?; let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); - docs.push(DerivedDoc { - cache_id, - doc, - message_count: None, - }); + docs.push(DerivedDoc { cache_id, doc }); } Ok(docs) } @@ -1626,11 +1592,7 @@ pub(crate) fn derive_pi_session_with( .map_err(|e| anyhow::anyhow!("{}", e))?; let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); - Ok(DerivedDoc { - cache_id, - doc, - message_count: Some(session.entries.len()), - }) + Ok(DerivedDoc { cache_id, doc }) } #[cfg(not(target_os = "emscripten"))] @@ -1806,11 +1768,7 @@ pub(crate) fn pathbase_fetch_to_doc(target: &str, url_flag: Option<&str>) -> Res let cache_id = make_id("pathbase", &format!("{owner}-{repo}-{id}")); let doc = Graph::from_json(&body) .map_err(|e| anyhow::anyhow!("server returned a non-toolpath document: {e}"))?; - Ok(DerivedDoc { - cache_id, - doc, - message_count: None, - }) + Ok(DerivedDoc { cache_id, doc }) } fn derive_pathbase(target: String, url_flag: Option) -> Result> { diff --git a/crates/path-cli/src/sync.rs b/crates/path-cli/src/sync.rs index 65b3d420..67018f58 100644 --- a/crates/path-cli/src/sync.rs +++ b/crates/path-cli/src/sync.rs @@ -143,12 +143,6 @@ mod engine { /// Fingerprint: source file size at sync time. #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) size: Option, - /// Message count as of the last derivation. Informational only - /// — never part of change detection — and recorded only for - /// harness artifact types (non-session kinds have no message - /// notion). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) message_count: Option, pub(crate) synced_at: DateTime, } @@ -255,7 +249,6 @@ mod engine { // next run instead of going unnoticed. modified: stub.modified, size: stub.size, - message_count: stub.artifact_type.harness().and(derived.message_count), synced_at: Utc::now(), }, ); @@ -398,10 +391,9 @@ mod engine { } } - /// Gemini main files carry their session id *inside* the JSON, so - /// enumeration still goes through the metadata listing; the - /// fingerprint is a stat of the listed file all the same. A - /// peek-level listing in `toolpath-gemini` would drop the parse. + /// Session entries via a bounded identity peek (`toolpath-gemini` + /// reads at most the first 4 KiB of a main file); the fingerprint + /// stats the main file (or the orphan sub-agent directory). fn stubs_gemini(mgr: &toolpath_gemini::GeminiConvo, out: &mut Vec) { let projects = match mgr.list_projects() { Ok(ps) => ps, @@ -412,19 +404,19 @@ mod engine { } }; for project in projects { - let metas = match mgr.list_conversation_metadata(&project) { - Ok(m) => m, + let entries = match mgr.resolver().list_session_entries(&project) { + Ok(entries) => entries, Err(e) => { eprintln!("warning: gemini project {project} failed: {e}"); continue; } }; - for m in metas { - let (modified, size) = stat_stamp(&m.file_path); + for entry in entries { + let (modified, size) = stat_stamp(&entry.path); out.push(ArtifactStub { artifact_type: ArtifactType::Gemini, - id: m.session_uuid, - path: Some(m.project_path), + id: entry.session_uuid.unwrap_or(entry.id), + path: Some(project.clone()), modified, size, }); @@ -708,7 +700,6 @@ mod engine { cache_id: "claude-p1".to_string(), modified: Some("2024-01-02T00:00:01.123456789Z".parse().unwrap()), size: Some(4096), - message_count: Some(2), synced_at: "2026-07-09T00:00:00Z".parse().unwrap(), }, ); @@ -778,11 +769,6 @@ mod engine { assert_eq!(rec.path.as_deref(), Some("/test/project")); assert!(rec.modified.is_some()); assert!(rec.size.is_some()); - assert_eq!( - rec.message_count, - Some(2), - "harness records carry the message count from the derive" - ); assert!( crate::cmd_cache::cache_path(&rec.cache_id) .unwrap() diff --git a/crates/toolpath-gemini/Cargo.toml b/crates/toolpath-gemini/Cargo.toml index 76fdeedc..7ba036b6 100644 --- a/crates/toolpath-gemini/Cargo.toml +++ b/crates/toolpath-gemini/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-gemini" -version = "0.6.0" +version = "0.7.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-gemini/src/lib.rs b/crates/toolpath-gemini/src/lib.rs index 10891df1..09ab8b5a 100644 --- a/crates/toolpath-gemini/src/lib.rs +++ b/crates/toolpath-gemini/src/lib.rs @@ -15,7 +15,7 @@ pub mod watcher; pub use error::{ConvoError, Result}; pub use io::ConvoIO; -pub use paths::PathResolver; +pub use paths::{PathResolver, SessionEntry}; pub use query::ConversationQuery; pub use reader::ConversationReader; pub use types::{ diff --git a/crates/toolpath-gemini/src/paths.rs b/crates/toolpath-gemini/src/paths.rs index 09bb50e7..cadcc43f 100644 --- a/crates/toolpath-gemini/src/paths.rs +++ b/crates/toolpath-gemini/src/paths.rs @@ -18,6 +18,20 @@ const TMP_DIR: &str = "tmp"; const CHATS_SUBDIR: &str = "chats"; const LOGS_FILE: &str = "logs.json"; +/// One session surfaced by [`PathResolver::list_session_entries`]. +#[derive(Debug, Clone)] +pub struct SessionEntry { + /// Listing key, exactly as [`PathResolver::list_sessions`] returns + /// it: main-file stem or orphan sub-agent directory name. + pub id: String, + /// Inner `sessionId` UUID peeked from a main file (the directory + /// name itself for orphan dirs); `None` when the peek failed. + pub session_uuid: Option, + /// The main chat file, or the orphan sub-agent directory — stat + /// this for change detection. + pub path: PathBuf, +} + #[derive(Debug, Clone)] pub struct PathResolver { home_dir: Option, @@ -194,6 +208,19 @@ impl PathResolver { /// sub-agent bucket and is **not** surfaced as a separate session — /// it gets merged into the main session by `read_session`. pub fn list_sessions(&self, project_path: &str) -> Result> { + Ok(self + .list_session_entries(project_path)? + .into_iter() + .map(|e| e.id) + .collect()) + } + + /// Like [`Self::list_sessions`], but each session comes with the + /// backing main file (or orphan sub-agent directory) and the inner + /// `sessionId` when one could be peeked — enough for stat-level + /// change detection without parsing chat bodies. The peek is + /// bounded; see [`peek_session_id`]. + pub fn list_session_entries(&self, project_path: &str) -> Result> { let chats = match self.chats_dir(project_path) { Ok(p) => p, Err(_) => return Ok(Vec::new()), @@ -202,9 +229,9 @@ impl PathResolver { return Ok(Vec::new()); } - let mut main_stems: Vec = Vec::new(); + let mut mains: Vec = Vec::new(); let mut main_session_uuids: std::collections::HashSet = Default::default(); - let mut dir_uuids: Vec = Vec::new(); + let mut dirs: Vec = Vec::new(); for entry in fs::read_dir(&chats)?.flatten() { let ft = match entry.file_type() { @@ -220,24 +247,33 @@ impl PathResolver { Some(s) => s.to_string(), None => continue, }; - main_stems.push(stem); - if let Some(uuid) = peek_session_id(&path) { - main_session_uuids.insert(uuid); + let session_uuid = peek_session_id(&path); + if let Some(uuid) = &session_uuid { + main_session_uuids.insert(uuid.clone()); } + mains.push(SessionEntry { + id: stem, + session_uuid, + path, + }); } else if ft.is_dir() && let Some(name) = entry.file_name().to_str() { - dir_uuids.push(name.to_string()); + dirs.push(SessionEntry { + id: name.to_string(), + session_uuid: Some(name.to_string()), + path, + }); } } - let mut out = main_stems; - for uuid in dir_uuids { - if !main_session_uuids.contains(&uuid) { - out.push(uuid); + let mut out = mains; + for dir in dirs { + if !main_session_uuids.contains(&dir.id) { + out.push(dir); } } - out.sort(); + out.sort_by(|a, b| a.id.cmp(&b.id)); Ok(out) } @@ -351,10 +387,27 @@ struct ProjectsFile { projects: HashMap, } -/// Read just the top-level `sessionId` field from a chat JSON file -/// without materialising the whole document. Used by `list_sessions` to -/// correlate main files with sibling sub-agent UUID directories. +/// Byte budget for [`peek_session_id`]'s prefix read. Chat files put +/// their identity fields first, so this is plenty in practice. +const PEEK_BYTES: usize = 4096; + +/// Read just the top-level `sessionId` field from a chat JSON file. +/// Bounded: scans the first [`PEEK_BYTES`] of the file and falls back +/// to a full parse only when the field isn't in the prefix. Used by +/// `list_session_entries` to correlate main files with sibling +/// sub-agent UUID directories. fn peek_session_id(path: &std::path::Path) -> Option { + use std::io::Read; + let file = fs::File::open(path).ok()?; + let mut prefix = Vec::with_capacity(PEEK_BYTES); + file.take(PEEK_BYTES as u64).read_to_end(&mut prefix).ok()?; + let whole_file = prefix.len() < PEEK_BYTES; + if let Some(id) = prefix_session_id(&prefix) { + return Some(id); + } + if whole_file { + return None; + } #[derive(Deserialize)] struct Peek { #[serde(rename = "sessionId")] @@ -365,6 +418,31 @@ fn peek_session_id(path: &std::path::Path) -> Option { peek.session_id.filter(|s| !s.is_empty()) } +/// Extract `"sessionId": "…"` from a JSON prefix, trusting it only when +/// it appears before any `"messages"` key — message bodies are the one +/// place user-controlled text could fake the key. +fn prefix_session_id(prefix: &[u8]) -> Option { + let text = match std::str::from_utf8(prefix) { + Ok(t) => t, + // The cut can land mid-codepoint; scan the valid part. + Err(e) => std::str::from_utf8(&prefix[..e.valid_up_to()]).ok()?, + }; + let key_at = text.find("\"sessionId\"")?; + if let Some(messages_at) = text.find("\"messages\"") + && messages_at < key_at + { + return None; + } + let rest = text[key_at + "\"sessionId\"".len()..].trim_start(); + let rest = rest.strip_prefix(':')?.trim_start(); + let rest = rest.strip_prefix('"')?; + let value = &rest[..rest.find('"')?]; + if value.is_empty() || value.contains('\\') { + return None; + } + Some(value.to_string()) +} + /// Canonical `projectHash`: SHA-256 hex of the absolute project path. pub fn project_hash(project_path: &str) -> String { let mut hasher = Sha256::new(); @@ -793,4 +871,70 @@ mod tests { assert!(!sessions.contains(&"sess-uuid-full".to_string())); assert_eq!(sessions.len(), 2); } + + #[test] + fn peek_session_id_reads_id_from_prefix_of_large_file() { + let (_temp, resolver) = setup(); + let chats = resolver.chats_dir("/proj").unwrap(); + fs::create_dir_all(&chats).unwrap(); + let pad = "x".repeat(16 * 1024); + let body = format!( + r#"{{"sessionId":"aaaa-bbbb","projectHash":"h","messages":[{{"content":"{pad}"}}]}}"# + ); + let path = chats.join("session-2026-01-01T00-00-aaaa.json"); + fs::write(&path, body).unwrap(); + assert_eq!(peek_session_id(&path).as_deref(), Some("aaaa-bbbb")); + } + + #[test] + fn peek_session_id_falls_back_when_identity_comes_late() { + let (_temp, resolver) = setup(); + let chats = resolver.chats_dir("/proj").unwrap(); + fs::create_dir_all(&chats).unwrap(); + let pad = "x".repeat(16 * 1024); + let body = format!(r#"{{"messages":[{{"content":"{pad}"}}],"sessionId":"late-id"}}"#); + let path = chats.join("session-2026-01-01T00-00-late.json"); + fs::write(&path, body).unwrap(); + assert_eq!(peek_session_id(&path).as_deref(), Some("late-id")); + } + + #[test] + fn prefix_session_id_rejects_keys_after_messages() { + assert_eq!( + prefix_session_id(br#"{"sessionId":"abc","messages":[]}"#).as_deref(), + Some("abc") + ); + assert_eq!( + prefix_session_id(br#"{"messages":[],"sessionId":"abc"}"#), + None, + "a sessionId after the messages key must not be trusted from the prefix" + ); + assert_eq!(prefix_session_id(br#"{"sessionId":""}"#), None); + } + + #[test] + fn list_session_entries_pairs_ids_with_backing_paths() { + let (_temp, resolver) = setup(); + let chats = resolver.chats_dir("/proj").unwrap(); + fs::create_dir_all(&chats).unwrap(); + let main = chats.join("session-2026-01-01T00-00-aaaa.json"); + fs::write(&main, r#"{"sessionId":"uuid-a","messages":[]}"#).unwrap(); + // uuid-a's sub-agent bucket is claimed by the main file; uuid-b + // is an orphan and must surface as its own session. + fs::create_dir_all(chats.join("uuid-a")).unwrap(); + fs::create_dir_all(chats.join("uuid-b")).unwrap(); + + let entries = resolver.list_session_entries("/proj").unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].id, "session-2026-01-01T00-00-aaaa"); + assert_eq!(entries[0].session_uuid.as_deref(), Some("uuid-a")); + assert_eq!(entries[0].path, main); + assert_eq!(entries[1].id, "uuid-b"); + assert_eq!(entries[1].session_uuid.as_deref(), Some("uuid-b")); + assert_eq!(entries[1].path, chats.join("uuid-b")); + + // The plain listing keeps returning the same ids. + let ids = resolver.list_sessions("/proj").unwrap(); + assert_eq!(ids, vec!["session-2026-01-01T00-00-aaaa", "uuid-b"]); + } } diff --git a/site/_data/crates.json b/site/_data/crates.json index 2ba774e7..de0fc8d6 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -41,7 +41,7 @@ }, { "name": "toolpath-gemini", - "version": "0.6.0", + "version": "0.7.0", "description": "Derive from Gemini CLI conversation logs", "docs": "https://docs.rs/toolpath-gemini", "crate": "https://crates.io/crates/toolpath-gemini", From 37b6bc915ec536cab3ea5748ecf36d29348ed8dd Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 9 Jul 2026 13:21:06 -0400 Subject: [PATCH 08/20] =?UTF-8?q?chore(gemini):=200.7.0=20=E2=86=92=200.6.?= =?UTF-8?q?1=20=E2=80=94=20additive=20change,=20patch=20slot=20for=20pre-1?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_session_entries is purely additive; for 0.y.z crates cargo treats z as the compatible slot and a y bump signals potential breakage, so ^0.6 consumers should get this for free. --- CHANGELOG.md | 2 +- CLAUDE.md | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/toolpath-gemini/Cargo.toml | 2 +- site/_data/crates.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 087acc2d..8e0cdddd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ users no longer have to `p import` each session by hand. On a default run, harnesses with no sessions stay silent; explicit types always report. Derivation failures warn and tally as `failed` without aborting the run. -- **`toolpath-gemini`** (0.7.0): new `PathResolver::list_session_entries` +- **`toolpath-gemini`** (0.6.1): new `PathResolver::list_session_entries` returns each session's listing id, inner `sessionId`, and backing file/dir path, and `peek_session_id` is now bounded — it scans the first 4 KiB of a main chat file (identity fields lead the JSON) and diff --git a/CLAUDE.md b/CLAUDE.md index c785e4a2..b1162a22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,5 +278,5 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. -- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs all six agent harnesses. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: head segment via `list_conversations`' bounded first-lines peek; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.7.0), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. +- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs all six agent harnesses. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: head segment via `list_conversations`' bounded first-lines peek; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. - `ArtifactType` (`crates/path-cli/src/sync.rs`) is the general enum naming artifact sources — claude/gemini/codex/opencode/cursor/pi today, other kinds (git, github) when sync learns them. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`cmd_share.rs`) names the six agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/Cargo.lock b/Cargo.lock index 414189ab..17eb667d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4155,7 +4155,7 @@ dependencies = [ [[package]] name = "toolpath-gemini" -version = "0.7.0" +version = "0.6.1" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 7684701d..8f9cfb9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ toolpath = { version = "0.7.0", path = "crates/toolpath" } toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" } toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" } toolpath-claude = { version = "0.12.0", path = "crates/toolpath-claude", default-features = false } -toolpath-gemini = { version = "0.7.0", path = "crates/toolpath-gemini", default-features = false } +toolpath-gemini = { version = "0.6.1", path = "crates/toolpath-gemini", default-features = false } toolpath-codex = { version = "0.6.0", path = "crates/toolpath-codex" } toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" } toolpath-opencode = { version = "0.5.0", path = "crates/toolpath-opencode" } diff --git a/crates/toolpath-gemini/Cargo.toml b/crates/toolpath-gemini/Cargo.toml index 7ba036b6..b5b09f3e 100644 --- a/crates/toolpath-gemini/Cargo.toml +++ b/crates/toolpath-gemini/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-gemini" -version = "0.7.0" +version = "0.6.1" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/site/_data/crates.json b/site/_data/crates.json index de0fc8d6..c0539598 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -41,7 +41,7 @@ }, { "name": "toolpath-gemini", - "version": "0.7.0", + "version": "0.6.1", "description": "Derive from Gemini CLI conversation logs", "docs": "https://docs.rs/toolpath-gemini", "crate": "https://crates.io/crates/toolpath-gemini", From 21b9b48e5c3ed50bcb9b4804e608a6b574f038e1 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 9 Jul 2026 13:21:38 -0400 Subject: [PATCH 09/20] docs: pre-1.0 additive changes bump the patch slot --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index b1162a22..c48dcee8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,7 +224,7 @@ The Tauri 2 desktop GUI lives in the private [pathbase](https://github.com/empat ## Versioning and release checklist -When changing a crate's public API (new types, new trait impls, new public methods, new dependencies), bump its version. Use semver: patch for bug fixes, minor for additive changes, major for breaking changes. Pre-1.0 crates treat minor as "potentially breaking." +When changing a crate's public API (new types, new trait impls, new public methods, new dependencies), bump its version. For pre-1.0 library crates, cargo treats the **z** position of `0.y.z` as the compatible slot: bug fixes *and additive changes* bump patch (`0.6.0` → `0.6.1`, so `^0.6` consumers like pathbase-app pick them up for free); bump minor only for potentially-breaking changes. `path-cli` is the app, not a library — it bumps minor per feature. **Every version bump must update all of the following:** From 94c43db90d0495fdd7972f99beb4cec7a0e05e3c Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 9 Jul 2026 15:04:38 -0400 Subject: [PATCH 10/20] feat(cli): import and share record what they write in the sync manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every session derive now carries a provenance ArtifactStub on DerivedDoc — identity plus a source stamp captured before the read, using the same per-provider lookups sync's enumeration uses (head-file stat for claude, entries listing for gemini with the canonical inner uuid, resolved rollout stat + uuid for codex, header SELECTs for opencode/cursor, peeked session file for pi). The cache-write sites in p import and share upsert the manifest via sync::record_stub, so the next sync sees those artifacts as unchanged instead of re-deriving them. The multi-select loops route through the derive_*_session_with helpers so picker imports are recorded too; bulk --all derives for claude/gemini/pi/codex carry no provenance and are picked up by the next sync instead. --no-cache paths record nothing. ArtifactType gains Git (7 variants): git imports are recorded — repo path plus the - id — but never re-derived by sync, since repos aren't discoverable. Github and pathbase stay out of the manifest: remote services, not local artifact sources. --- CHANGELOG.md | 8 + CLAUDE.md | 6 +- crates/path-cli/src/cmd_import.rs | 246 ++++++++++++++++++++------- crates/path-cli/src/cmd_share.rs | 9 + crates/path-cli/src/sync.rs | 162 +++++++++++++----- crates/path-cli/tests/integration.rs | 112 ++++++++++++ 6 files changed, 438 insertions(+), 105 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e0cdddd..0d252bb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,14 @@ users no longer have to `p import` each session by hand. On a default run, harnesses with no sessions stay silent; explicit types always report. Derivation failures warn and tally as `failed` without aborting the run. + - `p import` and `share` record what they write: session derives carry + a provenance stub (source stamped before the read), and the cache + write upserts the manifest — so sync never re-derives an artifact + that import or share just produced. `ArtifactType` gains `Git`: + git imports are recorded (repo path + `-` id) + but never re-derived by sync, since repos aren't discoverable. + Github and pathbase stay out of the manifest — remote services, + not artifact sources. - **`toolpath-gemini`** (0.6.1): new `PathResolver::list_session_entries` returns each session's listing id, inner `sessionId`, and backing file/dir path, and `peek_session_id` is now bounded — it scans the diff --git a/CLAUDE.md b/CLAUDE.md index c48dcee8..9977264e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -208,7 +208,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-cursor`: 78 unit + 8 integration round-trip + 1 real-DB sanity + 1 doc test (state.vscdb SQLite reader, bubble store + composer header parsing, content-addressed blob lookup, projector with full TOOL_TABLE coverage, JSONL transcript ingest in `examples/dump_fixture.rs`) - `toolpath-pi`: 133 unit + 26 integration + 5 doc tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `path-cli`: 337 unit + 101 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — manifest fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. +- `path-cli`: 338 unit + 104 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. - `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary) Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- p validate --input "$f"; done` @@ -278,5 +278,5 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. -- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs all six agent harnesses. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: head segment via `list_conversations`' bounded first-lines peek; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. -- `ArtifactType` (`crates/path-cli/src/sync.rs`) is the general enum naming artifact sources — claude/gemini/codex/opencode/cursor/pi today, other kinds (git, github) when sync learns them. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`cmd_share.rs`) names the six agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. +- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs all six agent harnesses. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: head segment via `list_conversations`' bounded first-lines peek; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactStub` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_stub` so the next sync sees those artifacts as unchanged instead of re-deriving them. Bulk `--all` derives for claude/gemini/pi/codex don't carry provenance (no per-artifact source in hand) — sync records those on its next run. `--no-cache` paths record nothing: the manifest describes the cache. +- `ArtifactType` (`crates/path-cli/src/sync.rs`) is the general enum naming artifact sources — the six agent harnesses plus `Git` (7 variants). Git artifacts are *recorded* in the manifest by `p import git` (id `-`, `path` = the repo directory) but never *discovered* — there is no machine-wide registry of repos — so sync reports them and leaves them alone. Github and pathbase are deliberately not artifact types: they are remote services, not local artifact sources, and their imports stay out of the manifest. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`cmd_share.rs`) names the six agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index 4549d249..83077a8f 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -15,7 +15,7 @@ use std::path::PathBuf; use toolpath::v1::Graph; use crate::cmd_cache::{make_id, write_cached}; -use crate::sync::ArtifactType; +use crate::sync::{ArtifactStub, ArtifactType, codex_artifact_id, stat_stamp}; #[derive(Subcommand, Debug)] pub enum ImportSource { @@ -202,6 +202,12 @@ pub fn run(args: ImportArgs, pretty: bool) -> Result<()> { pub(crate) struct DerivedDoc { pub(crate) cache_id: String, pub(crate) doc: Graph, + /// Identity + source stamp for the sync manifest, captured *before* + /// the source was read (so a write racing the derive re-syncs next + /// run). `None` for sources the manifest doesn't track (github, + /// pathbase) and for bulk `--all` derives that no longer know + /// per-artifact sources. + pub(crate) provenance: Option, } fn emit(docs: &[DerivedDoc], force: bool, no_cache: bool, pretty: bool) -> Result<()> { @@ -219,6 +225,12 @@ fn emit(docs: &[DerivedDoc], force: bool, no_cache: bool, pretty: bool) -> Resul } else { let path = write_cached(&d.cache_id, &d.doc, force)?; println!("{}", path.display()); + #[cfg(not(target_os = "emscripten"))] + if let Some(stub) = &d.provenance + && let Err(e) = crate::sync::record_stub(stub, &d.cache_id) + { + eprintln!("warning: sync manifest not updated: {e}"); + } let summary = doc_summary(&d.doc); eprintln!("Imported {} → {}", summary, d.cache_id); } @@ -324,8 +336,18 @@ fn derive_git( let canonical = std::fs::canonicalize(&repo_path).unwrap_or(repo_path.clone()); let repo_tag = short_path_hash(&canonical.to_string_lossy()); let inner = doc_inner_id(&doc); - let cache_id = make_id("git", &format!("{repo_tag}-{inner}")); - Ok(vec![DerivedDoc { cache_id, doc }]) + let cache_id = make_id(ArtifactType::Git.name(), &format!("{repo_tag}-{inner}")); + Ok(vec![DerivedDoc { + cache_id, + doc, + provenance: Some(ArtifactStub { + artifact_type: ArtifactType::Git, + id: format!("{repo_tag}-{inner}"), + path: Some(canonical.to_string_lossy().into_owned()), + modified: None, + size: None, + }), + }]) } } @@ -387,7 +409,11 @@ fn derive_github( let path = toolpath_github::derive_pull_request(&owner, &repo_name, pr_number, &config)?; let doc = Graph::from_path(path); let cache_id = make_id("github", &format!("{owner}_{repo_name}-{pr_number}")); - Ok(vec![DerivedDoc { cache_id, doc }]) + Ok(vec![DerivedDoc { + cache_id, + doc, + provenance: None, + }]) } } @@ -477,15 +503,15 @@ fn derive_claude_with_manager( } }; - let mut paths: Vec = Vec::with_capacity(pairs.len()); + let mut docs = Vec::with_capacity(pairs.len()); for (project_path, session_id) in &pairs { - let convo = manager - .read_conversation(project_path, session_id) - .map_err(|e| anyhow::anyhow!("{}", e))?; - let cfg = make_config(project_path); - paths.push(toolpath_claude::derive::derive_path(&convo, &cfg)); + docs.push(derive_claude_session_with( + manager, + project_path, + session_id, + )?); } - wrap_paths(ArtifactType::Claude, paths) + Ok(docs) } /// Derive a single Claude conversation given an explicit project + session. @@ -503,6 +529,11 @@ pub(crate) fn derive_claude_session_with( project: &str, session: &str, ) -> Result { + let (modified, size) = manager + .resolver() + .conversation_file(project, session) + .map(|p| stat_stamp(&p)) + .unwrap_or((None, None)); let cfg = toolpath_claude::derive::DeriveConfig { project_path: Some(project.to_string()), include_thinking: false, @@ -515,6 +546,13 @@ pub(crate) fn derive_claude_session_with( Ok(DerivedDoc { cache_id, doc: Graph::from_path(path), + provenance: Some(ArtifactStub { + artifact_type: ArtifactType::Claude, + id: session.to_string(), + path: Some(project.to_string()), + modified, + size, + }), }) } @@ -526,6 +564,7 @@ fn wrap_paths(t: ArtifactType, paths: Vec) -> Result = Vec::with_capacity(pairs.len()); + let mut docs = Vec::with_capacity(pairs.len()); for (project_path, session_uuid) in &pairs { - let convo = manager - .read_conversation(project_path, session_uuid) - .map_err(|e| anyhow::anyhow!("{}", e))?; - let cfg = make_config(project_path); - paths.push(toolpath_gemini::derive::derive_path(&convo, &cfg)); + docs.push(derive_gemini_session_with( + manager, + project_path, + session_uuid, + include_thinking, + )?); } - wrap_paths(ArtifactType::Gemini, paths) + Ok(docs) } /// Derive a single Gemini conversation given an explicit project + session. @@ -749,6 +789,22 @@ pub(crate) fn derive_gemini_session_with( session: &str, include_thinking: bool, ) -> Result { + let entry = manager + .resolver() + .list_session_entries(project) + .ok() + .and_then(|entries| { + entries + .into_iter() + .find(|e| e.id == session || e.session_uuid.as_deref() == Some(session)) + }); + let (artifact_id, (modified, size)) = match &entry { + Some(e) => ( + e.session_uuid.clone().unwrap_or_else(|| e.id.clone()), + stat_stamp(&e.path), + ), + None => (session.to_string(), (None, None)), + }; let cfg = toolpath_gemini::derive::DeriveConfig { project_path: Some(project.to_string()), include_thinking, @@ -761,6 +817,13 @@ pub(crate) fn derive_gemini_session_with( Ok(DerivedDoc { cache_id, doc: Graph::from_path(path), + provenance: Some(ArtifactStub { + artifact_type: ArtifactType::Gemini, + id: artifact_id, + path: Some(project.to_string()), + modified, + size, + }), }) } @@ -917,14 +980,11 @@ fn derive_codex(session: Option, all: bool) -> Result> { } }; - let mut paths: Vec = Vec::with_capacity(session_ids.len()); + let mut docs = Vec::with_capacity(session_ids.len()); for sid in &session_ids { - let s = manager - .read_session(sid) - .map_err(|e| anyhow::anyhow!("{}", e))?; - paths.push(toolpath_codex::derive::derive_path(&s, &config)); + docs.push(derive_codex_session_with(&manager, sid)?); } - wrap_paths(ArtifactType::Codex, paths) + Ok(docs) } /// Derive a single Codex session given an explicit session id. @@ -937,6 +997,14 @@ pub(crate) fn derive_codex_session_with( manager: &toolpath_codex::CodexConvo, session: &str, ) -> Result { + let file = manager.resolver().find_rollout_file(session).ok(); + let (modified, size) = file.as_deref().map(stat_stamp).unwrap_or((None, None)); + let artifact_id = file + .as_deref() + .and_then(|f| f.file_stem()) + .and_then(|stem| stem.to_str()) + .map(|stem| codex_artifact_id(stem).to_string()) + .unwrap_or_else(|| session.to_string()); let config = toolpath_codex::derive::DeriveConfig { project_path: None }; let s = manager .read_session(session) @@ -946,6 +1014,13 @@ pub(crate) fn derive_codex_session_with( Ok(DerivedDoc { cache_id, doc: Graph::from_path(path), + provenance: Some(ArtifactStub { + artifact_type: ArtifactType::Codex, + id: artifact_id, + path: None, + modified, + size, + }), }) } @@ -1148,16 +1223,7 @@ fn derive_opencode( no_snapshot_diffs, ..Default::default() }; - let derive_one = |sid: &str| -> Result { - let s = manager - .read_session(sid) - .map_err(|e| anyhow::anyhow!("{}", e))?; - Ok(toolpath_opencode::derive::derive_path_with_resolver( - &s, - &config, - manager.resolver(), - )) - }; + let derive_one = |sid: &str| derive_opencode_session_with(&manager, sid, no_snapshot_diffs); let session_ids: Vec = match (session, all) { (Some(s), _) => vec![s], @@ -1173,7 +1239,7 @@ fn derive_opencode( for m in &metas { out.push(derive_one(&m.id)?); } - return wrap_paths(ArtifactType::Opencode, out); + return Ok(out); } (None, false) => match pick_opencode(&manager, project.as_deref())? { Some(picks) => picks, @@ -1194,11 +1260,11 @@ fn derive_opencode( }, }; - let mut paths: Vec = Vec::with_capacity(session_ids.len()); + let mut docs = Vec::with_capacity(session_ids.len()); for sid in &session_ids { - paths.push(derive_one(sid)?); + docs.push(derive_one(sid)?); } - wrap_paths(ArtifactType::Opencode, paths) + Ok(docs) } } @@ -1222,6 +1288,12 @@ pub(crate) fn derive_opencode_session_with( session: &str, no_snapshot_diffs: bool, ) -> Result { + let modified = manager + .io() + .list_sessions(None) + .ok() + .and_then(|sessions| sessions.into_iter().find(|s| s.id == session)) + .and_then(|s| s.last_activity()); let config = toolpath_opencode::derive::DeriveConfig { no_snapshot_diffs, ..Default::default() @@ -1235,6 +1307,13 @@ pub(crate) fn derive_opencode_session_with( Ok(DerivedDoc { cache_id, doc: Graph::from_path(path), + provenance: Some(ArtifactStub { + artifact_type: ArtifactType::Opencode, + id: session.to_string(), + path: None, + modified, + size: None, + }), }) } @@ -1309,13 +1388,7 @@ fn derive_cursor( #[cfg(not(target_os = "emscripten"))] { let manager = toolpath_cursor::CursorConvo::new(); - let derive_one = |sid: &str| -> Result { - let s = manager - .read_session(sid) - .map_err(|e| anyhow::anyhow!("{}", e))?; - let cfg = toolpath_cursor::DeriveConfig::default(); - Ok(toolpath_cursor::derive_path(&s, &cfg)) - }; + let derive_one = |sid: &str| derive_cursor_session_with(&manager, sid); let workspace_filter = project .as_deref() @@ -1346,7 +1419,7 @@ fn derive_cursor( for m in &filtered { out.push(derive_one(&m.id)?); } - return wrap_paths(ArtifactType::Cursor, out); + return Ok(out); } (None, false) => match pick_cursor(&manager, workspace_filter.as_deref())? { Some(picks) => picks, @@ -1365,16 +1438,16 @@ fn derive_cursor( .unwrap_or_else(chrono::DateTime::::default) }) .ok_or_else(|| anyhow::anyhow!("No Cursor composers found"))?; - return wrap_paths(ArtifactType::Cursor, vec![derive_one(&pick.id)?]); + return Ok(vec![derive_one(&pick.id)?]); } }, }; - let mut paths: Vec = Vec::with_capacity(session_ids.len()); + let mut docs = Vec::with_capacity(session_ids.len()); for sid in &session_ids { - paths.push(derive_one(sid)?); + docs.push(derive_one(sid)?); } - wrap_paths(ArtifactType::Cursor, paths) + Ok(docs) } } @@ -1390,6 +1463,16 @@ pub(crate) fn derive_cursor_session_with( manager: &toolpath_cursor::CursorConvo, session: &str, ) -> Result { + let modified = manager + .io() + .read_composer_headers() + .ok() + .and_then(|h| { + h.all_composers + .into_iter() + .find(|c| c.composer_id == session) + }) + .and_then(|c| c.last_updated_at_utc()); let s = manager .read_session(session) .map_err(|e| anyhow::anyhow!("{}", e))?; @@ -1399,6 +1482,13 @@ pub(crate) fn derive_cursor_session_with( Ok(DerivedDoc { cache_id, doc: Graph::from_path(path), + provenance: Some(ArtifactStub { + artifact_type: ArtifactType::Cursor, + id: session.to_string(), + path: None, + modified, + size: None, + }), }) } @@ -1505,7 +1595,11 @@ fn derive_pi_with_manager( } let doc = toolpath_pi::derive::derive_graph(&sessions, None, &config); let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); - return Ok(vec![DerivedDoc { cache_id, doc }]); + return Ok(vec![DerivedDoc { + cache_id, + doc, + provenance: None, + }]); } (Some(p), None, false) => { #[cfg(not(target_os = "emscripten"))] @@ -1521,7 +1615,11 @@ fn derive_pi_with_manager( })?; let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); - return Ok(vec![DerivedDoc { cache_id, doc }]); + return Ok(vec![DerivedDoc { + cache_id, + doc, + provenance: None, + }]); } } #[cfg(target_os = "emscripten")] @@ -1532,7 +1630,11 @@ fn derive_pi_with_manager( .ok_or_else(|| anyhow::anyhow!("No Pi sessions found for project: {}", p))?; let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); - return Ok(vec![DerivedDoc { cache_id, doc }]); + return Ok(vec![DerivedDoc { + cache_id, + doc, + provenance: None, + }]); } } (None, _, _) => { @@ -1555,12 +1657,7 @@ fn derive_pi_with_manager( let mut docs: Vec = Vec::with_capacity(pairs.len()); for (project_path, session_id) in &pairs { - let session = manager - .read_session(project_path, session_id) - .map_err(|e| anyhow::anyhow!("{}", e))?; - let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); - let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); - docs.push(DerivedDoc { cache_id, doc }); + docs.push(derive_pi_session_with(manager, project_path, session_id)?); } Ok(docs) } @@ -1586,13 +1683,36 @@ pub(crate) fn derive_pi_session_with( project: &str, session: &str, ) -> Result { + let file = toolpath_pi::reader::list_session_files(manager.resolver(), project) + .ok() + .and_then(|files| { + files.into_iter().find(|f| { + toolpath_pi::reader::peek_header(f).is_ok_and(|h| h.id == session) + || f.file_stem() + .and_then(|stem| stem.to_str()) + .and_then(|stem| stem.split_once('_')) + .is_some_and(|(_, rest)| rest == session) + }) + }); + let (modified, size) = file.as_deref().map(stat_stamp).unwrap_or((None, None)); + let artifact_id = session.to_string(); let config = toolpath_pi::DeriveConfig::default(); let session = manager .read_session(project, session) .map_err(|e| anyhow::anyhow!("{}", e))?; let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); - Ok(DerivedDoc { cache_id, doc }) + Ok(DerivedDoc { + cache_id, + doc, + provenance: Some(ArtifactStub { + artifact_type: ArtifactType::Pi, + id: artifact_id, + path: Some(project.to_string()), + modified, + size, + }), + }) } #[cfg(not(target_os = "emscripten"))] @@ -1768,7 +1888,11 @@ pub(crate) fn pathbase_fetch_to_doc(target: &str, url_flag: Option<&str>) -> Res let cache_id = make_id("pathbase", &format!("{owner}-{repo}-{id}")); let doc = Graph::from_json(&body) .map_err(|e| anyhow::anyhow!("server returned a non-toolpath document: {e}"))?; - Ok(DerivedDoc { cache_id, doc }) + Ok(DerivedDoc { + cache_id, + doc, + provenance: None, + }) } fn derive_pathbase(target: String, url_flag: Option) -> Result> { diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index 5f79fe08..6eb9cb24 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -72,6 +72,7 @@ impl ArtifactType { ArtifactType::Opencode => Some(Harness::Opencode), ArtifactType::Cursor => Some(Harness::Cursor), ArtifactType::Pi => Some(Harness::Pi), + ArtifactType::Git => None, } } } @@ -943,6 +944,11 @@ fn share_explicit( // overwrite so cache and upload agree (use `--no-cache` to skip // the cache write entirely). let path = crate::cmd_cache::write_cached(&derived.cache_id, &derived.doc, true)?; + if let Some(stub) = &derived.provenance + && let Err(e) = crate::sync::record_stub(stub, &derived.cache_id) + { + eprintln!("warning: sync manifest not updated: {e}"); + } eprintln!( "Cached {} session → {} ({})", harness.name(), @@ -1036,6 +1042,9 @@ fn derive_session( ArtifactType::Codex => crate::cmd_import::derive_codex_session(session), ArtifactType::Opencode => crate::cmd_import::derive_opencode_session(session, false), ArtifactType::Cursor => crate::cmd_import::derive_cursor_session(session), + ArtifactType::Git => { + anyhow::bail!("share only handles agent sessions; git artifacts go through `p import`") + } } } diff --git a/crates/path-cli/src/sync.rs b/crates/path-cli/src/sync.rs index 67018f58..7d1d5ec9 100644 --- a/crates/path-cli/src/sync.rs +++ b/crates/path-cli/src/sync.rs @@ -15,9 +15,11 @@ /// The kind of artifact an operation ranges over. One enum, used /// everywhere a command names artifact sources (`p cache sync` types, /// `share`/`resume` `--harness`, import cache-id prefixes); `name()` -/// doubles as the manifest key and cache-id prefix. Today every -/// variant is an agent-session provider; other artifact kinds (git, -/// github, …) join here when sync learns to ingest them. +/// doubles as the manifest key and cache-id prefix. Git artifacts are +/// recorded in the manifest when imported but are not *discoverable* — +/// there is no machine-wide registry of repos to enumerate — so sync +/// never re-derives them. Github and pathbase are absent on purpose: +/// they are remote services, not local artifact sources. #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, clap::ValueEnum)] #[value(rename_all = "lower")] pub enum ArtifactType { @@ -27,17 +29,19 @@ pub enum ArtifactType { Opencode, Cursor, Pi, + Git, } impl ArtifactType { /// Every artifact type, in presentation order. - pub(crate) const ALL: [ArtifactType; 6] = [ + pub(crate) const ALL: [ArtifactType; 7] = [ ArtifactType::Claude, ArtifactType::Gemini, ArtifactType::Codex, ArtifactType::Opencode, ArtifactType::Cursor, ArtifactType::Pi, + ArtifactType::Git, ]; pub(crate) fn name(&self) -> &'static str { @@ -48,6 +52,7 @@ impl ArtifactType { ArtifactType::Opencode => "opencode", ArtifactType::Cursor => "cursor", ArtifactType::Pi => "pi", + ArtifactType::Git => "git", } } @@ -61,6 +66,7 @@ impl ArtifactType { ArtifactType::Opencode => "opencode", ArtifactType::Cursor => "cursor ", ArtifactType::Pi => "pi ", + ArtifactType::Git => "git ", } } @@ -72,7 +78,7 @@ impl ArtifactType { pub(crate) fn path_keyed(&self) -> bool { matches!( self, - ArtifactType::Claude | ArtifactType::Gemini | ArtifactType::Pi + ArtifactType::Claude | ArtifactType::Gemini | ArtifactType::Pi | ArtifactType::Git ) } @@ -84,11 +90,56 @@ impl ArtifactType { "opencode" => Some(ArtifactType::Opencode), "cursor" => Some(ArtifactType::Cursor), "pi" => Some(ArtifactType::Pi), + "git" => Some(ArtifactType::Git), _ => None, } } } +/// An artifact's identity plus the stat-level fingerprint of its +/// source. Sync enumerates these for change detection (producing one +/// never parses session bodies), and `p import`/`share` fill one as +/// the provenance of each derived document so the write can be +/// recorded in the manifest. +#[derive(Debug, Clone)] +pub(crate) struct ArtifactStub { + pub(crate) artifact_type: ArtifactType, + pub(crate) id: String, + /// Filesystem path the artifact is keyed under, for path-keyed + /// providers (the project directory; the repo for git). + pub(crate) path: Option, + /// Source mtime (file providers) or updated-at (DB providers). + pub(crate) modified: Option>, + /// Source file size; `None` for DB-backed providers. + pub(crate) size: Option, +} + +/// (mtime, size) of a file, both `None` when the stat fails. +pub(crate) fn stat_stamp( + path: &std::path::Path, +) -> (Option>, Option) { + match std::fs::metadata(path) { + Ok(md) => ( + md.modified() + .ok() + .map(chrono::DateTime::::from), + Some(md.len()), + ), + Err(_) => (None, None), + } +} + +/// The trailing UUID of a codex rollout filename stem +/// (`rollout--`), or the whole stem when it doesn't end +/// in one. Codex's `read_session` resolves either form. +pub(crate) fn codex_artifact_id(stem: &str) -> &str { + stem.len() + .checked_sub(36) + .and_then(|at| stem.get(at..)) + .filter(|tail| tail.bytes().filter(|&b| b == b'-').count() == 4) + .unwrap_or(stem) +} + #[cfg(not(target_os = "emscripten"))] pub(crate) use engine::*; @@ -98,9 +149,9 @@ mod engine { use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; - use std::path::{Path, PathBuf}; + use std::path::PathBuf; - use super::ArtifactType; + use super::{ArtifactStub, ArtifactType, codex_artifact_id, stat_stamp}; use crate::cmd_cache::write_cached; use crate::cmd_import::DerivedDoc; use crate::cmd_share::{ @@ -111,22 +162,6 @@ mod engine { const MANIFEST_FILE: &str = "sync.json"; - /// A cheaply-enumerated artifact: identity plus the stat-level - /// fingerprint used for change detection. Producing one never - /// parses session bodies. - #[derive(Debug, Clone)] - pub(crate) struct ArtifactStub { - pub(crate) artifact_type: ArtifactType, - pub(crate) id: String, - /// Filesystem path the artifact is keyed under, for path-keyed - /// providers (the project directory). - pub(crate) path: Option, - /// Source mtime (file providers) or updated-at (DB providers). - pub(crate) modified: Option>, - /// Source file size; `None` for DB-backed providers. - pub(crate) size: Option, - } - /// What the manifest remembers about one synced artifact. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub(crate) struct SyncRecord { @@ -293,6 +328,9 @@ mod engine { imp::derive_opencode_session_with(mgr(&bundle.opencode)?, &stub.id, false) } ArtifactType::Cursor => imp::derive_cursor_session_with(mgr(&bundle.cursor)?, &stub.id), + ArtifactType::Git => Err(anyhow!( + "git artifacts are recorded by `p import`, not re-derived by sync" + )), } } @@ -303,17 +341,6 @@ mod engine { // ── stat-level enumeration ───────────────────────────────────────── - /// (mtime, size) of a file, both `None` when the stat fails. - fn stat_stamp(path: &Path) -> (Option>, Option) { - match std::fs::metadata(path) { - Ok(md) => ( - md.modified().ok().map(DateTime::::from), - Some(md.len()), - ), - Err(_) => (None, None), - } - } - /// Enumerate one type's artifacts with stat-level fingerprints. /// Providers that aren't installed produce no stubs; other listing /// errors warn and skip so one broken provider can't block the rest. @@ -350,6 +377,9 @@ mod engine { stubs_pi(mgr, &mut out); } } + // Recorded via `p import`, never discovered: there is no + // machine-wide registry of repos to walk. + ArtifactType::Git => {} } out } @@ -440,13 +470,7 @@ mod engine { let Some(stem) = file.file_stem().and_then(|s| s.to_str()) else { continue; }; - let id = stem - .len() - .checked_sub(36) - .and_then(|at| stem.get(at..)) - .filter(|tail| tail.bytes().filter(|&b| b == b'-').count() == 4) - .unwrap_or(stem) - .to_string(); + let id = codex_artifact_id(stem).to_string(); let (modified, size) = stat_stamp(&file); out.push(ArtifactStub { artifact_type: ArtifactType::Codex, @@ -576,6 +600,26 @@ mod engine { s } + /// Record an externally-derived cache write (`p import`, `share`) in + /// the manifest, so sync doesn't re-derive what was just written. + pub(crate) fn record_stub(stub: &ArtifactStub, cache_id: &str) -> Result<()> { + let mut manifest = load_manifest()?; + manifest + .entry(stub.artifact_type.name().to_string()) + .or_default() + .insert( + stub.id.clone(), + SyncRecord { + path: stub.path.clone(), + cache_id: cache_id.to_string(), + modified: stub.modified, + size: stub.size, + synced_at: Utc::now(), + }, + ); + save_manifest(&manifest) + } + // ── manifest IO ──────────────────────────────────────────────────── fn manifest_path() -> Result { @@ -625,6 +669,7 @@ mod engine { mod tests { use super::*; use crate::config::{CONFIG_DIR_ENV, TEST_ENV_LOCK}; + use std::path::Path; /// Run `f` with `$TOOLPATH_CONFIG_DIR` pinned to `/.toolpath`; /// `f` receives the tempdir root for building provider fixtures. @@ -874,6 +919,40 @@ mod engine { }); } + #[test] + fn recorded_import_is_unchanged_to_the_next_sync() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + + // What `p import` does: derive with provenance, write the + // cache, record the stub. + let derived = crate::cmd_import::derive_claude_session_with( + bundle.claude.as_ref().unwrap(), + "/test/project", + "sess-aaa", + ) + .unwrap(); + let stub = derived.provenance.as_ref().unwrap(); + assert_eq!(stub.id, "sess-aaa"); + assert!(stub.modified.is_some() && stub.size.is_some()); + crate::cmd_cache::write_cached(&derived.cache_id, &derived.doc, true).unwrap(); + record_stub(stub, &derived.cache_id).unwrap(); + + // The import's stamp must match sync's own enumeration. + let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap()[0]; + assert_eq!( + ( + outcome.new, + outcome.updated, + outcome.unchanged, + outcome.failed + ), + (0, 0, 1, 0) + ); + }); + } + #[test] fn derive_stub_errors_when_provider_missing() { let bundle = HarnessBundle::default(); @@ -960,6 +1039,7 @@ mod type_tests { assert!(!ArtifactType::Codex.path_keyed()); assert!(!ArtifactType::Opencode.path_keyed()); assert!(!ArtifactType::Cursor.path_keyed()); + assert!(ArtifactType::Git.path_keyed()); } #[test] diff --git a/crates/path-cli/tests/integration.rs b/crates/path-cli/tests/integration.rs index b7b20160..e364ac2f 100644 --- a/crates/path-cli/tests/integration.rs +++ b/crates/path-cli/tests/integration.rs @@ -749,6 +749,118 @@ fn cache_sync_default_run_with_no_sessions_reports_nothing() { assert!(!cfg.path().join("sync.json").exists()); } +#[test] +fn import_records_manifest_so_sync_skips() { + let (home, _session_file) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args([ + "p", + "import", + "claude", + "--session", + "session-abc", + "--project", + ]) + .arg(&project) + .assert() + .success(); + + let manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(cfg.path().join("sync.json")).unwrap()) + .unwrap(); + let record = &manifest["claude"]["session-abc"]; + assert!(record["modified"].is_string(), "import must stamp mtime"); + assert!(record["size"].is_u64(), "import must stamp size"); + + // Sync sees the import's record and derives nothing. + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "cache", "sync", "claude"]) + .assert() + .success() + .stderr(predicate::str::contains("0 new, 0 updated, 1 unchanged")); +} + +#[test] +fn git_import_lands_in_manifest_and_sync_leaves_it_alone() { + let (dir, branch) = git_fixture(); + let cfg = tempfile::tempdir().unwrap(); + + cmd() + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "import", "git", "--branch"]) + .arg(&branch) + .arg("--repo") + .arg(dir.path()) + .assert() + .success(); + + let manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(cfg.path().join("sync.json")).unwrap()) + .unwrap(); + let git_records = manifest["git"].as_object().unwrap(); + assert_eq!(git_records.len(), 1); + let record = git_records.values().next().unwrap(); + assert!( + record["path"].is_string(), + "git records carry the repo path" + ); + + // Git artifacts are recorded, not discovered: sync reports zeros + // and must not fail or re-derive. + let home = tempfile::tempdir().unwrap(); + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "cache", "sync", "git"]) + .assert() + .success() + .stderr(predicate::str::contains("0 new, 0 updated, 0 unchanged")); +} + +#[test] +fn share_records_manifest_so_sync_skips() { + let (port, server, _temp, project, home) = share_anon_fixture(); + let cfg = tempfile::tempdir().unwrap(); + + cmd() + .env("HOME", &home) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args([ + "share", + "--harness", + "claude", + "--session", + "session-abc", + "--project", + ]) + .arg(&project) + .args(["--anon", "--url"]) + .arg(format!("http://127.0.0.1:{port}")) + .assert() + .success(); + server.join().unwrap(); + + let manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(cfg.path().join("sync.json")).unwrap()) + .unwrap(); + assert!(manifest["claude"]["session-abc"]["modified"].is_string()); + + cmd() + .env("HOME", &home) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "cache", "sync", "claude"]) + .assert() + .success() + .stderr(predicate::str::contains("0 new, 0 updated, 1 unchanged")); +} + #[test] fn cache_sync_rejects_unknown_type() { let cfg = tempfile::tempdir().unwrap(); From a5d9786aa9e83840d05832b7d00bfef1cc0a1ab4 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 9 Jul 2026 15:17:56 -0400 Subject: [PATCH 11/20] =?UTF-8?q?refactor(cli):=20every=20import=20flow=20?= =?UTF-8?q?loops=20per=20session=20=E2=80=94=20all=20writes=20recorded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bulk --all arms for claude/gemini/pi/codex and every most-recent fallback now enumerate ids and loop the derive_*_session_with helpers instead of calling the providers' bulk derive_project, so every cache write carries provenance and lands in the sync manifest. There was nothing to the bulk path performance-wise — read_all_* reads the same files one at a time — and wrap_paths plus the dead per-fn configs go with it. Breaking: p import pi --all now emits one Path document per session, consistent with every other provider (previously a single combined Graph via derive_graph). --- CHANGELOG.md | 6 + CLAUDE.md | 4 +- crates/path-cli/src/cmd_import.rs | 191 +++++++++++---------------- crates/path-cli/tests/integration.rs | 42 ++++++ 4 files changed, 128 insertions(+), 115 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d252bb7..8dd47ac9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,12 @@ users no longer have to `p import` each session by hand. but never re-derived by sync, since repos aren't discoverable. Github and pathbase stay out of the manifest — remote services, not artifact sources. + - Every import flow loops the per-session derive helpers — explicit + `--session`, picker multi-select, `--all`, and the most-recent + fallbacks — so everything entering the cache is recorded. + **Breaking**: `p import pi --all` now emits one Path document per + session, consistent with every other provider (it previously + produced a single combined Graph). - **`toolpath-gemini`** (0.6.1): new `PathResolver::list_session_entries` returns each session's listing id, inner `sessionId`, and backing file/dir path, and `peek_session_id` is now bounded — it scans the diff --git a/CLAUDE.md b/CLAUDE.md index 9977264e..4c272b18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -208,7 +208,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-cursor`: 78 unit + 8 integration round-trip + 1 real-DB sanity + 1 doc test (state.vscdb SQLite reader, bubble store + composer header parsing, content-addressed blob lookup, projector with full TOOL_TABLE coverage, JSONL transcript ingest in `examples/dump_fixture.rs`) - `toolpath-pi`: 133 unit + 26 integration + 5 doc tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `path-cli`: 338 unit + 104 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. +- `path-cli`: 338 unit + 105 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. - `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary) Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- p validate --input "$f"; done` @@ -278,5 +278,5 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. -- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs all six agent harnesses. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: head segment via `list_conversations`' bounded first-lines peek; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactStub` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_stub` so the next sync sees those artifacts as unchanged instead of re-deriving them. Bulk `--all` derives for claude/gemini/pi/codex don't carry provenance (no per-artifact source in hand) — sync records those on its next run. `--no-cache` paths record nothing: the manifest describes the cache. +- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs all six agent harnesses. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: head segment via `list_conversations`' bounded first-lines peek; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactStub` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_stub` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. - `ArtifactType` (`crates/path-cli/src/sync.rs`) is the general enum naming artifact sources — the six agent harnesses plus `Git` (7 variants). Git artifacts are *recorded* in the manifest by `p import git` (id `-`, `path` = the repo directory) but never *discovered* — there is no machine-wide registry of repos — so sync reports them and leaves them alone. Github and pathbase are deliberately not artifact types: they are remote services, not local artifact sources, and their imports stay out of the manifest. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`cmd_share.rs`) names the six agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index 83077a8f..e16e1890 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -160,7 +160,7 @@ pub enum ImportSource { #[arg(short, long)] session: Option, - /// Process all sessions in the project (emits a Graph) + /// Process all sessions in the project #[arg(long)] all: bool, @@ -432,11 +432,6 @@ fn derive_claude_with_manager( session: Option, all: bool, ) -> Result> { - let make_config = |p: &str| toolpath_claude::derive::DeriveConfig { - project_path: Some(p.to_string()), - include_thinking: false, - }; - // Interactive picker fires only when no explicit `--session` (and not // `--all`); the same flow handles single- and multi-select. If fzf isn't // available, we fall back to most-recent for explicit-project, or print @@ -444,14 +439,14 @@ fn derive_claude_with_manager( let pairs: Vec<(String, String)> = match (project, session, all) { (Some(p), Some(s), _) => vec![(p, s)], (Some(p), None, true) => { - let convos = manager - .read_all_conversations(&p) + let heads = manager + .list_conversations(&p) .map_err(|e| anyhow::anyhow!("{}", e))?; - let cfg = make_config(&p); - return wrap_paths( - ArtifactType::Claude, - toolpath_claude::derive::derive_project(&convos, &cfg), - ); + let mut docs = Vec::with_capacity(heads.len()); + for head in &heads { + docs.push(derive_claude_session_with(manager, &p, head)?); + } + return Ok(docs); } (Some(p), None, false) => { #[cfg(not(target_os = "emscripten"))] @@ -465,11 +460,11 @@ fn derive_claude_with_manager( .ok_or_else(|| { anyhow::anyhow!("No conversations found for project: {}", p) })?; - let cfg = make_config(&p); - return wrap_paths( - ArtifactType::Claude, - vec![toolpath_claude::derive::derive_path(&convo, &cfg)], - ); + return Ok(vec![derive_claude_session_with( + manager, + &p, + &convo.session_id, + )?]); } } #[cfg(target_os = "emscripten")] @@ -478,11 +473,11 @@ fn derive_claude_with_manager( .most_recent_conversation(&p) .map_err(|e| anyhow::anyhow!("{}", e))? .ok_or_else(|| anyhow::anyhow!("No conversations found for project: {}", p))?; - let cfg = make_config(&p); - return wrap_paths( - ArtifactType::Claude, - vec![toolpath_claude::derive::derive_path(&convo, &cfg)], - ); + return Ok(vec![derive_claude_session_with( + manager, + &p, + &convo.session_id, + )?]); } } (None, _, _) => { @@ -556,20 +551,6 @@ pub(crate) fn derive_claude_session_with( }) } -fn wrap_paths(t: ArtifactType, paths: Vec) -> Result> { - Ok(paths - .into_iter() - .map(|p| { - let cache_id = make_id(t.name(), &p.path.id); - DerivedDoc { - cache_id, - doc: Graph::from_path(p), - provenance: None, - } - }) - .collect()) -} - #[cfg(not(target_os = "emscripten"))] fn pick_claude_in_project( manager: &toolpath_claude::ClaudeConvo, @@ -689,22 +670,22 @@ fn derive_gemini_with_manager( all: bool, include_thinking: bool, ) -> Result> { - let make_config = |p: &str| toolpath_gemini::derive::DeriveConfig { - project_path: Some(p.to_string()), - include_thinking, - }; - let pairs: Vec<(String, String)> = match (project, session, all) { (Some(p), Some(s), _) => vec![(p, s)], (Some(p), None, true) => { - let convos = manager - .read_all_conversations(&p) + let ids = manager + .list_conversations(&p) .map_err(|e| anyhow::anyhow!("{}", e))?; - let cfg = make_config(&p); - return wrap_paths( - ArtifactType::Gemini, - toolpath_gemini::derive::derive_project(&convos, &cfg), - ); + let mut docs = Vec::with_capacity(ids.len()); + for id in &ids { + docs.push(derive_gemini_session_with( + manager, + &p, + id, + include_thinking, + )?); + } + return Ok(docs); } (Some(p), None, false) => { #[cfg(not(target_os = "emscripten"))] @@ -718,11 +699,12 @@ fn derive_gemini_with_manager( .ok_or_else(|| { anyhow::anyhow!("No conversations found for project: {}", p) })?; - let cfg = make_config(&p); - return wrap_paths( - ArtifactType::Gemini, - vec![toolpath_gemini::derive::derive_path(&convo, &cfg)], - ); + return Ok(vec![derive_gemini_session_with( + manager, + &p, + &convo.session_uuid, + include_thinking, + )?]); } } #[cfg(target_os = "emscripten")] @@ -731,11 +713,12 @@ fn derive_gemini_with_manager( .most_recent_conversation(&p) .map_err(|e| anyhow::anyhow!("{}", e))? .ok_or_else(|| anyhow::anyhow!("No conversations found for project: {}", p))?; - let cfg = make_config(&p); - return wrap_paths( - ArtifactType::Gemini, - vec![toolpath_gemini::derive::derive_path(&convo, &cfg)], - ); + return Ok(vec![derive_gemini_session_with( + manager, + &p, + &convo.session_uuid, + include_thinking, + )?]); } } (None, _, _) => { @@ -929,21 +912,28 @@ fn pick_gemini_global( fn derive_codex(session: Option, all: bool) -> Result> { let manager = toolpath_codex::CodexConvo::new(); - let config = toolpath_codex::derive::DeriveConfig { project_path: None }; let session_ids: Vec = match (session, all) { (Some(s), _) => vec![s], (None, true) => { - let sessions = manager - .read_all_sessions() + let files = manager + .io() + .list_rollout_files() .map_err(|e| anyhow::anyhow!("{}", e))?; - if sessions.is_empty() { + if files.is_empty() { anyhow::bail!("No Codex sessions found in ~/.codex/sessions"); } - return wrap_paths( - ArtifactType::Codex, - toolpath_codex::derive::derive_project(&sessions, &config), - ); + let mut docs = Vec::with_capacity(files.len()); + for file in &files { + let Some(stem) = file.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + docs.push(derive_codex_session_with( + &manager, + codex_artifact_id(stem), + )?); + } + return Ok(docs); } (None, false) => { #[cfg(not(target_os = "emscripten"))] @@ -957,10 +947,7 @@ fn derive_codex(session: Option, all: bool) -> Result> { .ok_or_else(|| { anyhow::anyhow!("No Codex sessions found in ~/.codex/sessions") })?; - return wrap_paths( - ArtifactType::Codex, - vec![toolpath_codex::derive::derive_path(&s, &config)], - ); + return Ok(vec![derive_codex_session_with(&manager, &s.id)?]); } } } @@ -972,10 +959,7 @@ fn derive_codex(session: Option, all: bool) -> Result> { .ok_or_else(|| { anyhow::anyhow!("No Codex sessions found in ~/.codex/sessions") })?; - return wrap_paths( - ArtifactType::Codex, - vec![toolpath_codex::derive::derive_path(&s, &config)], - ); + return Ok(vec![derive_codex_session_with(&manager, &s.id)?]); } } }; @@ -1219,10 +1203,6 @@ fn derive_opencode( #[cfg(not(target_os = "emscripten"))] { let manager = toolpath_opencode::OpencodeConvo::new(); - let config = toolpath_opencode::derive::DeriveConfig { - no_snapshot_diffs, - ..Default::default() - }; let derive_one = |sid: &str| derive_opencode_session_with(&manager, sid, no_snapshot_diffs); let session_ids: Vec = match (session, all) { @@ -1248,14 +1228,7 @@ fn derive_opencode( .most_recent_session() .map_err(|e| anyhow::anyhow!("{}", e))? .ok_or_else(|| anyhow::anyhow!("No opencode sessions found"))?; - return wrap_paths( - ArtifactType::Opencode, - vec![toolpath_opencode::derive::derive_path_with_resolver( - &s, - &config, - manager.resolver(), - )], - ); + return Ok(vec![derive_one(&s.id)?]); } }, }; @@ -1582,24 +1555,20 @@ fn derive_pi_with_manager( session: Option, all: bool, ) -> Result> { - let config = toolpath_pi::DeriveConfig::default(); - let pairs: Vec<(String, String)> = match (project, session, all) { (Some(p), Some(s), _) => vec![(p, s)], (Some(p), None, true) => { - let sessions = manager - .read_all_sessions(&p) + let metas = manager + .list_sessions(&p) .map_err(|e| anyhow::anyhow!("{}", e))?; - if sessions.is_empty() { + if metas.is_empty() { anyhow::bail!("No Pi sessions found for project: {}", p); } - let doc = toolpath_pi::derive::derive_graph(&sessions, None, &config); - let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); - return Ok(vec![DerivedDoc { - cache_id, - doc, - provenance: None, - }]); + let mut docs = Vec::with_capacity(metas.len()); + for m in &metas { + docs.push(derive_pi_session_with(manager, &p, &m.id)?); + } + return Ok(docs); } (Some(p), None, false) => { #[cfg(not(target_os = "emscripten"))] @@ -1613,13 +1582,11 @@ fn derive_pi_with_manager( .ok_or_else(|| { anyhow::anyhow!("No Pi sessions found for project: {}", p) })?; - let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); - let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); - return Ok(vec![DerivedDoc { - cache_id, - doc, - provenance: None, - }]); + return Ok(vec![derive_pi_session_with( + manager, + &p, + &session.header.id, + )?]); } } #[cfg(target_os = "emscripten")] @@ -1628,13 +1595,11 @@ fn derive_pi_with_manager( .most_recent_session(&p) .map_err(|e| anyhow::anyhow!("{}", e))? .ok_or_else(|| anyhow::anyhow!("No Pi sessions found for project: {}", p))?; - let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); - let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); - return Ok(vec![DerivedDoc { - cache_id, - doc, - provenance: None, - }]); + return Ok(vec![derive_pi_session_with( + manager, + &p, + &session.header.id, + )?]); } } (None, _, _) => { diff --git a/crates/path-cli/tests/integration.rs b/crates/path-cli/tests/integration.rs index e364ac2f..2efb57a5 100644 --- a/crates/path-cli/tests/integration.rs +++ b/crates/path-cli/tests/integration.rs @@ -787,6 +787,48 @@ fn import_records_manifest_so_sync_skips() { .stderr(predicate::str::contains("0 new, 0 updated, 1 unchanged")); } +#[test] +fn bulk_import_records_every_session() { + let (home, session_file) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + // A second session alongside the fixture's, so --all has a batch. + std::fs::write( + session_file.parent().unwrap().join("deadbeef-second.jsonl"), + format!( + r#"{{"type":"user","uuid":"u-9","timestamp":"2024-02-01T00:00:00Z","cwd":"{cwd}","message":{{"role":"user","content":"second"}}}} +"#, + cwd = project.display() + ), + ) + .unwrap(); + + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "import", "claude", "--all", "--project"]) + .arg(&project) + .assert() + .success(); + + let manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(cfg.path().join("sync.json")).unwrap()) + .unwrap(); + assert_eq!( + manifest["claude"].as_object().unwrap().len(), + 2, + "--all must record every session it writes" + ); + + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "cache", "sync", "claude"]) + .assert() + .success() + .stderr(predicate::str::contains("0 new, 0 updated, 2 unchanged")); +} + #[test] fn git_import_lands_in_manifest_and_sync_leaves_it_alone() { let (dir, branch) = git_fixture(); From a048ed24bafdf3e7efa8e8c59d262560c3456ec2 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Fri, 10 Jul 2026 18:17:31 -0400 Subject: [PATCH 12/20] feat(cli): path query syncs its scope before reading A query now freshens exactly the slice of the cache it will read: --source claude syncs only claude, --id prefixes narrow likewise, a bare cache-wide query syncs every artifact type, and --input-only queries never touch the cache. Reporting is quiet unless something was actually ingested (synced claude: 3 new, 1 updated); a sync failure warns and degrades to querying the cache as-is; --no-sync opts out. Real data: a first-ever query over an empty cache ingests 32 sessions and answers in one command; the steady-state sync adds nothing measurable. Existing query integration tests pin $HOME to a shared empty sandbox so the implicit sync runs against nothing instead of the developer's real sessions. --- CHANGELOG.md | 9 +++ CLAUDE.md | 8 +- crates/path-cli/src/cmd_query.rs | 128 +++++++++++++++++++++++++++++++ crates/path-cli/tests/query.rs | 98 ++++++++++++++++++++++- 4 files changed, 239 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dd47ac9..61c42d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,15 @@ users no longer have to `p import` each session by hand. **Breaking**: `p import pi --all` now emits one Path document per session, consistent with every other provider (it previously produced a single combined Graph). + - `path query` now syncs before it reads, scoped to the query's own + flags: `--source claude` syncs only claude, `--id` prefixes narrow + likewise, a bare cache-wide query syncs every type, and + `--input`-only queries never touch the cache. Output is quiet + unless something was actually ingested (`synced claude: 3 new, 1 + updated`), a sync failure degrades to querying the cache as-is, + and `--no-sync` opts out. This is the piece that makes the cache + an implementation detail: a new user can run `path query` with no + setup and get their sessions. - **`toolpath-gemini`** (0.6.1): new `PathResolver::list_session_entries` returns each session's listing id, inner `sessionId`, and backing file/dir path, and `peek_session_id` is now bounded — it scans the diff --git a/CLAUDE.md b/CLAUDE.md index 4c272b18..9c880875 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,7 +135,9 @@ cargo run -p path-cli -- p cache sync claude codex # only these artifact types # Inspect / analyze cargo run -p path-cli -- p render dot --input doc.json cargo run -p path-cli -- p render md --input doc.json --detail full -# Query the whole local cache with a jaq (jq) filter over wrapped steps +# Query the whole local cache with a jaq (jq) filter over wrapped steps. +# Queries auto-sync their scope first (--source claude syncs only claude; +# --input-only queries never touch the cache); --no-sync opts out. cargo run -p path-cli -- query 'map(select(.dead_end)) | map(.step.id)' cargo run -p path-cli -- query --source claude 'map(select(any(.change[].structural.token_usage; .input_tokens > 50000)))' cargo run -p path-cli -- query --input doc.json 'map(select(.step.actor | startswith("agent:")))' @@ -208,7 +210,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-cursor`: 78 unit + 8 integration round-trip + 1 real-DB sanity + 1 doc test (state.vscdb SQLite reader, bubble store + composer header parsing, content-addressed blob lookup, projector with full TOOL_TABLE coverage, JSONL transcript ingest in `examples/dump_fixture.rs`) - `toolpath-pi`: 133 unit + 26 integration + 5 doc tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `path-cli`: 338 unit + 105 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. +- `path-cli`: 343 unit + 108 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. - `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary) Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- p validate --input "$f"; done` @@ -278,5 +280,5 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. -- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs all six agent harnesses. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: head segment via `list_conversations`' bounded first-lines peek; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactStub` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_stub` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. +- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs all six agent harnesses. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: head segment via `list_conversations`' bounded first-lines peek; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactStub` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_stub` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. - `ArtifactType` (`crates/path-cli/src/sync.rs`) is the general enum naming artifact sources — the six agent harnesses plus `Git` (7 variants). Git artifacts are *recorded* in the manifest by `p import git` (id `-`, `path` = the repo directory) but never *discovered* — there is no machine-wide registry of repos — so sync reports them and leaves them alone. Github and pathbase are deliberately not artifact types: they are remote services, not local artifact sources, and their imports stay out of the manifest. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`cmd_share.rs`) names the six agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/crates/path-cli/src/cmd_query.rs b/crates/path-cli/src/cmd_query.rs index 81c0e179..ae809fe2 100644 --- a/crates/path-cli/src/cmd_query.rs +++ b/crates/path-cli/src/cmd_query.rs @@ -57,6 +57,10 @@ pub struct QueryArgs { /// reading text/diff content. Composes with `-c`. #[arg(short = 'r', long)] raw: bool, + + /// Skip the automatic cache sync that runs before the query. + #[arg(long)] + no_sync: bool, } const WRAPPER_HELP: &str = "\ @@ -89,6 +93,11 @@ Examples: path query -r '.[0].change[].structural.text' # read a turn's text, unescaped"; pub fn run(args: QueryArgs, pretty: bool) -> Result<()> { + #[cfg(not(target_os = "emscripten"))] + if !args.no_sync { + sync_query_scope(&args); + } + let scope = Scope { source: args.source, ids: args.ids, @@ -103,3 +112,122 @@ pub fn run(args: QueryArgs, pretty: bool) -> Result<()> { crate::query::run(&scope, &args.filter, compact, args.raw) } + +/// Freshen the slice of the cache this query will read, before reading +/// it. Quiet unless something was actually ingested; a sync failure +/// degrades to querying the cache as-is. +#[cfg(not(target_os = "emscripten"))] +fn sync_query_scope(args: &QueryArgs) { + let types = sync_types_for(args.source.as_deref(), &args.ids, &args.input); + if types.is_empty() { + return; + } + let bundle = crate::cmd_share::HarnessBundle::from_environment(); + match crate::sync::sync_bundle(&bundle, &types) { + Ok(outcomes) => { + for (t, o) in outcomes { + if o.new + o.updated + o.failed > 0 { + let failed = if o.failed > 0 { + format!(", {} failed", o.failed) + } else { + String::new() + }; + eprintln!( + "synced {}: {} new, {} updated{failed}", + t.name(), + o.new, + o.updated + ); + } + } + } + Err(e) => eprintln!("warning: cache sync skipped: {e}"), + } +} + +/// Which artifact types the query's scope flags reach. `--input`-only +/// invocations never touch the cache; `--source` narrows to one type +/// (non-syncable sources like `pathbase` map to nothing); `--id`s +/// narrow to their prefixes; a bare cache-wide query syncs everything. +#[cfg(not(target_os = "emscripten"))] +fn sync_types_for( + source: Option<&str>, + ids: &[String], + inputs: &[String], +) -> Vec { + use crate::sync::ArtifactType; + let scans_cache = source.is_some() || !ids.is_empty() || inputs.is_empty(); + if !scans_cache { + return Vec::new(); + } + if let Some(source) = source { + return ArtifactType::parse(source).into_iter().collect(); + } + if !ids.is_empty() { + return ArtifactType::ALL + .into_iter() + .filter(|t| { + ids.iter().any(|id| { + id.strip_prefix(t.name()) + .is_some_and(|rest| rest.starts_with('-')) + }) + }) + .collect(); + } + ArtifactType::ALL.to_vec() +} + +#[cfg(all(test, not(target_os = "emscripten")))] +mod tests { + use super::sync_types_for; + use crate::sync::ArtifactType; + + fn s(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn input_only_queries_sync_nothing() { + assert!(sync_types_for(None, &[], &s(&["doc.json"])).is_empty()); + } + + #[test] + fn source_flag_narrows_to_one_type() { + assert_eq!( + sync_types_for(Some("claude"), &[], &[]), + vec![ArtifactType::Claude] + ); + assert!( + sync_types_for(Some("pathbase"), &[], &[]).is_empty(), + "non-syncable sources sync nothing" + ); + } + + #[test] + fn ids_narrow_to_their_prefixes() { + let types = sync_types_for( + None, + &s(&["claude-abc", "codex-def", "pathbase-x-y-z"]), + &[], + ); + assert_eq!(types, vec![ArtifactType::Claude, ArtifactType::Codex]); + // `cursor-…` must not match on the `c` of another type or vice versa. + assert_eq!( + sync_types_for(None, &s(&["cursor-abc"]), &[]), + vec![ArtifactType::Cursor] + ); + } + + #[test] + fn bare_cache_query_syncs_everything() { + assert_eq!(sync_types_for(None, &[], &[]), ArtifactType::ALL.to_vec()); + } + + #[test] + fn source_beats_ids_and_inputs_do_not_disable_cache_scan() { + assert_eq!( + sync_types_for(Some("git"), &s(&["claude-abc"]), &s(&["doc.json"])), + vec![ArtifactType::Git] + ); + } +} diff --git a/crates/path-cli/tests/query.rs b/crates/path-cli/tests/query.rs index 68525567..8bd0f48f 100644 --- a/crates/path-cli/tests/query.rs +++ b/crates/path-cli/tests/query.rs @@ -10,7 +10,14 @@ use predicates::prelude::*; use std::path::Path; fn cmd() -> Command { - Command::cargo_bin("path").unwrap() + // `path query` auto-syncs the cache from the installed harnesses; + // pin $HOME to a shared empty sandbox so tests exercise that path + // without ingesting the developer's real sessions. + static HOME: std::sync::OnceLock = std::sync::OnceLock::new(); + let home = HOME.get_or_init(|| tempfile::tempdir().unwrap()); + let mut c = Command::cargo_bin("path").unwrap(); + c.env("HOME", home.path()).env_remove("XDG_DATA_HOME"); + c } /// Write `json` into `/documents/.json`, creating the dir. @@ -504,3 +511,92 @@ fn kind_unknown_errors() { .failure() .stderr(predicate::str::contains("Bundled kinds")); } + +// ── auto-sync on invocation ────────────────────────────────────────── + +/// A `$HOME` with one real Claude session for exercising query's +/// implicit sync (distinct from the shared empty sandbox in `cmd()`). +fn claude_home() -> tempfile::TempDir { + let home = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + std::fs::create_dir_all(&project).unwrap(); + let slug = project + .to_string_lossy() + .replace([std::path::MAIN_SEPARATOR, '_', '.'], "-"); + let dir = home.path().join(".claude/projects").join(slug); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("feedbeef-query.jsonl"), + format!( + r#"{{"type":"user","uuid":"u-1","timestamp":"2024-01-01T00:00:00Z","cwd":"{cwd}","message":{{"role":"user","content":"hi"}}}} +{{"type":"assistant","uuid":"a-1","timestamp":"2024-01-01T00:00:01Z","message":{{"role":"assistant","content":"hello"}}}} +"#, + cwd = project.display() + ), + ) + .unwrap(); + home +} + +fn fixture_query<'a>( + home: &tempfile::TempDir, + cfg: &Path, + args: impl IntoIterator, +) -> assert_cmd::assert::Assert { + Command::cargo_bin("path") + .unwrap() + .env("HOME", home.path()) + .env_remove("XDG_DATA_HOME") + .env("TOOLPATH_CONFIG_DIR", cfg) + .arg("query") + .args(args) + .assert() +} + +#[test] +fn query_syncs_its_scope_before_reading() { + let home = claude_home(); + let cfg = tempfile::tempdir().unwrap(); + // Empty cache; the query ingests the session and reads it in one go. + fixture_query(&home, cfg.path(), ["--source", "claude", "length > 0"]) + .success() + .stdout(predicate::str::contains("true")) + .stderr(predicate::str::contains("synced claude: 1 new")); + assert!(cfg.path().join("sync.json").exists()); + + // Second run: nothing changed, so the sync is silent. + fixture_query(&home, cfg.path(), ["--source", "claude", "length > 0"]) + .success() + .stdout(predicate::str::contains("true")) + .stderr(predicate::str::contains("synced").not()); +} + +#[test] +fn query_no_sync_reads_the_cache_as_is() { + let home = claude_home(); + let cfg = tempfile::tempdir().unwrap(); + fixture_query(&home, cfg.path(), ["--no-sync", "length"]) + .success() + .stdout(predicate::str::contains("0")); + assert!(!cfg.path().join("sync.json").exists()); +} + +#[test] +fn input_only_query_never_touches_the_cache() { + let home = claude_home(); + let cfg = tempfile::tempdir().unwrap(); + let doc = cfg.path().join("doc.json"); + std::fs::write(&doc, CLAUDE_DOC).unwrap(); + Command::cargo_bin("path") + .unwrap() + .env("HOME", home.path()) + .env_remove("XDG_DATA_HOME") + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["query", "--input"]) + .arg(&doc) + .arg("length > 0") + .assert() + .success() + .stdout(predicate::str::contains("true")); + assert!(!cfg.path().join("sync.json").exists()); +} From be6735302e59eb5b3b84bd467a5ee4f3d6e97872 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Mon, 13 Jul 2026 12:04:14 -0400 Subject: [PATCH 13/20] feat(cli): manifest as artifact index; --parent-dir/-d scoped sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SyncRecord.cache_id becomes optional: a record without one is known but not materialized. p cache rm downgrades records instead of orphaning them — fixing rm'd docs previously staying gone until their source changed — and sync verifies the doc file exists before skipping, so out-of-band deletions self-heal the same way. --parent-dir / -d on p cache sync and path query restricts ingestion (and the query's reads) to artifacts under a directory. The stat gate always runs first: unchanged+cached artifacts skip before any scope check; only artifacts already facing a derive get the constraint. Path-keyed providers prune whole projects before enumerating; opencode/cursor check the directory their cheap headers carry; codex — whose cwd lives inside the rollout — gets a one-line peek only when new/changed, memoized into the record's path so it happens at most once per artifact. Out-of-scope work tallies separately and never touches a materialized record's stamp, so staleness stays visible to the next in-scope sync. Claude's dir slugs are lossy ('/', '_', '.' all became '-'), so its scope matching happens in slug space, and its session derives stop passing the slug-derived project string into DeriveConfig — path.base now comes from the session's own recorded cwd. --- CHANGELOG.md | 17 ++ CLAUDE.md | 6 +- crates/path-cli/src/cmd_cache.rs | 14 +- crates/path-cli/src/cmd_import.rs | 5 +- crates/path-cli/src/cmd_query.rs | 8 +- crates/path-cli/src/query/mod.rs | 34 ++- crates/path-cli/src/sync.rs | 380 ++++++++++++++++++++++++--- crates/path-cli/tests/integration.rs | 24 ++ crates/path-cli/tests/query.rs | 23 ++ 9 files changed, 455 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61c42d36..87e05cac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,23 @@ users no longer have to `p import` each session by hand. and `--no-sync` opts out. This is the piece that makes the cache an implementation detail: a new user can run `path query` with no setup and get their sessions. + - The manifest is now an artifact *index*, not just a cache inventory: + `cache_id` on a record is optional, and a record without one means + "known, not materialized". `p cache rm` downgrades records instead + of orphaning them (the next sync re-materializes the doc — this + also fixes rm'd docs previously staying gone until the source + changed), and sync verifies doc existence before skipping, so + out-of-band deletions self-heal too. + - `--parent-dir ` / `-d` on `p cache sync` and `path query` + restricts ingestion (and the query's reads) to artifacts under a + directory. Stat gate first, always: unchanged+cached artifacts skip + before any scope check; only artifacts that would cost a derive get + the constraint, with a one-line peek for codex (whose cwd lives + inside the rollout) memoized into the manifest so it happens at + most once per artifact. Claude project matching happens in slug + space (its dir slugs are lossy), and claude derives now source + `path.base` from the session's recorded cwd instead of the lossy + slug string. - **`toolpath-gemini`** (0.6.1): new `PathResolver::list_session_entries` returns each session's listing id, inner `sessionId`, and backing file/dir path, and `peek_session_id` is now bounded — it scans the diff --git a/CLAUDE.md b/CLAUDE.md index 9c880875..a14cc8f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,6 +131,7 @@ cargo run -p path-cli -- p cache ls cargo run -p path-cli -- p cache rm cargo run -p path-cli -- p cache sync # ingest new/changed sessions from every harness cargo run -p path-cli -- p cache sync claude codex # only these artifact types +cargo run -p path-cli -- p cache sync -d ~/work/proj # only artifacts under this directory # Inspect / analyze cargo run -p path-cli -- p render dot --input doc.json @@ -138,6 +139,7 @@ cargo run -p path-cli -- p render md --input doc.json --detail full # Query the whole local cache with a jaq (jq) filter over wrapped steps. # Queries auto-sync their scope first (--source claude syncs only claude; # --input-only queries never touch the cache); --no-sync opts out. +# --parent-dir/-d scopes both the sync and the read to that subtree. cargo run -p path-cli -- query 'map(select(.dead_end)) | map(.step.id)' cargo run -p path-cli -- query --source claude 'map(select(any(.change[].structural.token_usage; .input_tokens > 50000)))' cargo run -p path-cli -- query --input doc.json 'map(select(.step.actor | startswith("agent:")))' @@ -210,7 +212,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-cursor`: 78 unit + 8 integration round-trip + 1 real-DB sanity + 1 doc test (state.vscdb SQLite reader, bubble store + composer header parsing, content-addressed blob lookup, projector with full TOOL_TABLE coverage, JSONL transcript ingest in `examples/dump_fixture.rs`) - `toolpath-pi`: 133 unit + 26 integration + 5 doc tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `path-cli`: 343 unit + 108 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. +- `path-cli`: 347 unit + 110 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. - `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary) Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- p validate --input "$f"; done` @@ -280,5 +282,5 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. -- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs all six agent harnesses. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: head segment via `list_conversations`' bounded first-lines peek; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactStub` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_stub` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. +- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs all six agent harnesses. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: head segment via `list_conversations`' bounded first-lines peek; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record's `cache_id` is *optional*: a record without one is "known, not materialized" — created when a `--parent-dir` constraint excluded a peeked artifact, or when `p cache rm` evicts a doc (rm downgrades the record; the next in-scope sync re-materializes it, and sync also verifies the doc file actually exists before skipping, so even out-of-band deletions self-heal). `--parent-dir ` / `-d` on both `p cache sync` and `path query` restricts ingestion to artifacts under that directory (subtree): path-keyed providers prune whole projects before enumerating (claude compares in *slug space* — its dir slugs are lossy, `/`/`_`/`.` all became `-`), cwd-keyed ones check the directory their cheap headers carry, and codex — whose cwd lives inside the rollout — gets a one-line peek only when new/changed, memoized into the record's `path`. The stat gate always runs first: unchanged+cached artifacts skip before any scope check. Out-of-scope work is tallied separately (`N out of scope`) and never touches a materialized record's stamp. Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's own recorded cwd rather than the lossy slug. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactStub` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_stub` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. - `ArtifactType` (`crates/path-cli/src/sync.rs`) is the general enum naming artifact sources — the six agent harnesses plus `Git` (7 variants). Git artifacts are *recorded* in the manifest by `p import git` (id `-`, `path` = the repo directory) but never *discovered* — there is no machine-wide registry of repos — so sync reports them and leaves them alone. Github and pathbase are deliberately not artifact types: they are remote services, not local artifact sources, and their imports stay out of the manifest. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`cmd_share.rs`) names the six agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/crates/path-cli/src/cmd_cache.rs b/crates/path-cli/src/cmd_cache.rs index 5b584280..f2789b8b 100644 --- a/crates/path-cli/src/cmd_cache.rs +++ b/crates/path-cli/src/cmd_cache.rs @@ -30,6 +30,12 @@ pub enum CacheOp { /// Artifact types to sync (default: every agent harness) #[arg(value_enum)] types: Vec, + + /// Only ingest artifacts living under this directory (subtree + /// match). Out-of-scope artifacts are noted in the manifest but + /// not derived. + #[arg(long, short = 'd')] + parent_dir: Option, }, } @@ -38,7 +44,7 @@ pub fn run(op: CacheOp) -> Result<()> { CacheOp::Ls => run_ls(), CacheOp::Rm { id } => run_rm(&id), #[cfg(not(target_os = "emscripten"))] - CacheOp::Sync { types } => crate::sync::run(types), + CacheOp::Sync { types, parent_dir } => crate::sync::run(types, parent_dir), } } @@ -56,6 +62,12 @@ fn run_ls() -> Result<()> { fn run_rm(id: &str) -> Result<()> { remove_cached(id)?; + // The artifact is still real — downgrade its manifest record to + // "known, not cached" so the next sync can re-materialize it. + #[cfg(not(target_os = "emscripten"))] + if let Err(e) = crate::sync::evict_cache_id(id) { + eprintln!("warning: sync manifest not updated: {e}"); + } eprintln!("Removed {id}"); Ok(()) } diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index e16e1890..a44c5a22 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -529,8 +529,11 @@ pub(crate) fn derive_claude_session_with( .conversation_file(project, session) .map(|p| stat_stamp(&p)) .unwrap_or((None, None)); + // The caller's project string often comes from claude's lossy dir + // slugs ('/', '_', '.' all collapsed); leaving it out of the derive + // lets path.base come from the session's own recorded cwd instead. let cfg = toolpath_claude::derive::DeriveConfig { - project_path: Some(project.to_string()), + project_path: None, include_thinking: false, }; let convo = manager diff --git a/crates/path-cli/src/cmd_query.rs b/crates/path-cli/src/cmd_query.rs index ae809fe2..909f2b49 100644 --- a/crates/path-cli/src/cmd_query.rs +++ b/crates/path-cli/src/cmd_query.rs @@ -42,6 +42,11 @@ pub struct QueryArgs { #[arg(long)] project: Option, + /// Keep only paths whose base lives under this directory (subtree + /// match), and scope the implicit sync to it likewise. + #[arg(long, short = 'd')] + parent_dir: Option, + /// Keep only paths whose meta.kind matches this selector /// (semver prefix, e.g. `agent-coding-session` or `…/v1.0`). #[arg(long)] @@ -103,6 +108,7 @@ pub fn run(args: QueryArgs, pretty: bool) -> Result<()> { ids: args.ids, inputs: args.input, project: args.project, + parent_dir: args.parent_dir, kind: args.kind, }; @@ -123,7 +129,7 @@ fn sync_query_scope(args: &QueryArgs) { return; } let bundle = crate::cmd_share::HarnessBundle::from_environment(); - match crate::sync::sync_bundle(&bundle, &types) { + match crate::sync::sync_bundle(&bundle, &types, args.parent_dir.as_deref()) { Ok(outcomes) => { for (t, o) in outcomes { if o.new + o.updated + o.failed > 0 { diff --git a/crates/path-cli/src/query/mod.rs b/crates/path-cli/src/query/mod.rs index 6d5d2cfc..ecc002fb 100644 --- a/crates/path-cli/src/query/mod.rs +++ b/crates/path-cli/src/query/mod.rs @@ -30,6 +30,9 @@ pub struct Scope { pub inputs: Vec, /// `--project`: keep only paths whose `base` resolves to this directory. pub project: Option, + /// `--parent-dir`: keep only paths whose `base` lives under this + /// directory (subtree match). + pub parent_dir: Option, /// `--kind`: keep only paths whose `meta.kind` matches this selector. pub kind: Option, } @@ -94,6 +97,7 @@ impl DocSource { fn stream_files(scope: &Scope, emit: &mut dyn FnMut(Val) -> Result<()>) -> Result<()> { let kind_sel = scope.kind.as_deref().map(kinds::parse_kind_selector); let project = scope.project.as_deref().map(canonicalize_or_self); + let parent_dir = scope.parent_dir.as_deref().map(canonicalize_or_self); for src in select_files(scope)? { let graph = match read_source(&src) { @@ -115,6 +119,7 @@ fn stream_files(scope: &Scope, emit: &mut dyn FnMut(Val) -> Result<()>) -> Resul &graph, kind_sel.as_ref(), project.as_deref(), + parent_dir.as_deref(), &mut steps, ); drop(graph); @@ -231,6 +236,7 @@ fn wrap_graph( graph: &Graph, kind_sel: Option<&KindSelector>, project: Option<&FsPath>, + parent_dir: Option<&FsPath>, out: &mut Vec, ) { for entry in &graph.paths { @@ -249,6 +255,11 @@ fn wrap_graph( { continue; } + if let Some(dir) = parent_dir + && !path_matches_parent_dir(path, dir) + { + continue; + } wrap_path(src, path, out); } @@ -307,13 +318,17 @@ fn path_context(path: &Path) -> serde_json::Value { /// Whether a path's `base` resolves to `project` (a canonicalized directory). /// Only `file://` bases can match; VCS bases (`github:…`) never do. fn path_matches_project(path: &Path, project: &FsPath) -> bool { - let Some(base) = &path.path.base else { - return false; - }; - let Some(fs) = base.uri.strip_prefix("file://") else { - return false; - }; - canonicalize_or_self(FsPath::new(fs)) == project + base_fs_path(path).is_some_and(|p| p == project) +} + +fn path_matches_parent_dir(path: &Path, parent_dir: &FsPath) -> bool { + base_fs_path(path).is_some_and(|p| p.starts_with(parent_dir)) +} + +fn base_fs_path(path: &Path) -> Option { + let base = path.path.base.as_ref()?; + let fs = base.uri.strip_prefix("file://")?; + Some(canonicalize_or_self(FsPath::new(fs))) } fn canonicalize_or_self(p: &FsPath) -> PathBuf { @@ -406,12 +421,12 @@ mod tests { let graph = Graph::from_path(forked_path()); let mut out = Vec::new(); let sel = kinds::parse_kind_selector("agent-coding-session/v1.1.0"); - wrap_graph(&doc_src("g"), &graph, Some(&sel), None, &mut out); + wrap_graph(&doc_src("g"), &graph, Some(&sel), None, None, &mut out); assert_eq!(out.len(), 4, "matching kind keeps all steps"); out.clear(); let miss = kinds::parse_kind_selector("agent-coding-session/v2"); - wrap_graph(&doc_src("g"), &graph, Some(&miss), None, &mut out); + wrap_graph(&doc_src("g"), &graph, Some(&miss), None, None, &mut out); assert!(out.is_empty(), "non-matching kind drops the whole path"); } @@ -447,6 +462,7 @@ mod tests { ids: vec![], inputs: vec!["/tmp/some.json".to_string(), "-".to_string()], project: None, + parent_dir: None, kind: None, }; let files = select_files(&scope).unwrap(); diff --git a/crates/path-cli/src/sync.rs b/crates/path-cli/src/sync.rs index 7d1d5ec9..e20af8b1 100644 --- a/crates/path-cli/src/sync.rs +++ b/crates/path-cli/src/sync.rs @@ -70,7 +70,7 @@ impl ArtifactType { } } - /// True when the underlying provider keys artifacts by a filesystem + /// True when the parent_dirlying provider keys artifacts by a filesystem /// path (the project directory). claude/gemini/pi: true. /// codex/opencode/cursor: false (sessions store cwd per-row, not as /// a directory key — cursor stores it as @@ -105,7 +105,7 @@ impl ArtifactType { pub(crate) struct ArtifactStub { pub(crate) artifact_type: ArtifactType, pub(crate) id: String, - /// Filesystem path the artifact is keyed under, for path-keyed + /// Filesystem path the artifact is keyed parent_dir, for path-keyed /// providers (the project directory; the repo for git). pub(crate) path: Option, /// Source mtime (file providers) or updated-at (DB providers). @@ -149,7 +149,7 @@ mod engine { use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; - use std::path::PathBuf; + use std::path::{Path, PathBuf}; use super::{ArtifactStub, ArtifactType, codex_artifact_id, stat_stamp}; use crate::cmd_cache::write_cached; @@ -162,15 +162,21 @@ mod engine { const MANIFEST_FILE: &str = "sync.json"; - /// What the manifest remembers about one synced artifact. + /// What the manifest remembers about one known artifact. A record + /// with a `cache_id` is materialized in the cache; one without is + /// merely known — seen during an out-of-scope sync, or evicted by + /// `p cache rm`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub(crate) struct SyncRecord { - /// Filesystem path the artifact is keyed under, for path-keyed - /// providers (claude/gemini/pi: the project directory). + /// Filesystem path the artifact is keyed parent_dir: the project + /// directory for path-keyed providers, the recorded cwd / + /// workspace for the others (when known). #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) path: Option, - /// Cache entry the derived document was written to. - pub(crate) cache_id: String, + /// Cache entry the derived document was written to; `None` + /// when the artifact is known but not cached. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) cache_id: Option, /// Fingerprint: source mtime (file providers) or updated-at /// (DB providers) at sync time. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -193,19 +199,21 @@ mod engine { pub(crate) updated: usize, pub(crate) unchanged: usize, pub(crate) failed: usize, + /// Artifacts needing work that a `--parent-dir` constraint excluded. + pub(crate) out_of_scope: usize, } impl SyncOutcome { fn total(&self) -> usize { - self.new + self.updated + self.unchanged + self.failed + self.new + self.updated + self.unchanged + self.failed + self.out_of_scope } } - pub(crate) fn run(types: Vec) -> Result<()> { + pub(crate) fn run(types: Vec, parent_dir: Option) -> Result<()> { let explicit = !types.is_empty(); let types = resolve_types(&types); let bundle = HarnessBundle::from_environment(); - let outcomes = sync_bundle(&bundle, &types)?; + let outcomes = sync_bundle(&bundle, &types, parent_dir.as_deref())?; eprint!("{}", render_summary(&outcomes, explicit)); Ok(()) } @@ -230,16 +238,17 @@ mod engine { pub(crate) fn sync_bundle( bundle: &HarnessBundle, types: &[ArtifactType], + parent_dir: Option<&Path>, ) -> Result> { let mut manifest = load_manifest()?; let mut out = Vec::with_capacity(types.len()); for &artifact_type in types { - let stubs = enumerate_stubs(bundle, artifact_type); + let stubs = enumerate_stubs(bundle, artifact_type, parent_dir); let mut records = manifest .get(artifact_type.name()) .cloned() .unwrap_or_default(); - let outcome = sync_stubs(bundle, &stubs, &mut records)?; + let outcome = sync_stubs(bundle, &stubs, &mut records, parent_dir)?; if !records.is_empty() { manifest.insert(artifact_type.name().to_string(), records); save_manifest(&manifest)?; @@ -256,18 +265,58 @@ mod engine { bundle: &HarnessBundle, stubs: &[ArtifactStub], records: &mut BTreeMap, + parent_dir: Option<&Path>, ) -> Result { let mut outcome = SyncOutcome::default(); for stub in stubs { let existing = records.get(&stub.id); let is_new = existing.is_none(); + // Stat gate first, always: a materialized, unchanged artifact + // needs nothing — no read, no scope check. if let Some(rec) = existing && rec.modified == stub.modified && rec.size == stub.size + && let Some(cache_id) = &rec.cache_id + && crate::cmd_cache::cache_path(cache_id).is_ok_and(|p| p.exists()) { outcome.unchanged += 1; continue; } + // Scope gate: only artifacts that would cost a derive get the + // constraint check (with a bounded peek for codex, memoized in + // the record so it happens at most once per artifact). + if let Some(parent_dir) = parent_dir { + let dir = stub + .path + .clone() + .or_else(|| existing.and_then(|r| r.path.clone())) + .or_else(|| peek_stub_dir(bundle, stub)); + let in_scope = dir.as_deref().is_some_and(|d| match stub.artifact_type { + // Claude paths came from lossy dir slugs; compare in + // slug space like the enumeration pruning does. + ArtifactType::Claude => claude_project_in_scope(d, parent_dir), + _ => dir_in_scope(d, parent_dir), + }); + if !in_scope { + outcome.out_of_scope += 1; + // Remember what we learned — but never touch the stamp + // of a materialized record, or its staleness would be + // masked from the next in-scope sync. + if existing.is_none_or(|r| r.cache_id.is_none()) { + records.insert( + stub.id.clone(), + SyncRecord { + path: dir, + cache_id: None, + modified: stub.modified, + size: stub.size, + synced_at: Utc::now(), + }, + ); + } + continue; + } + } match derive_stub(bundle, stub) { Ok(derived) => { // force: sync owns refresh semantics — a re-sync or a @@ -278,7 +327,7 @@ mod engine { stub.id.clone(), SyncRecord { path: stub.path.clone(), - cache_id: derived.cache_id, + cache_id: Some(derived.cache_id), // The stamp was taken before the derive read the // source, so a write racing the derive re-syncs // next run instead of going unnoticed. @@ -339,22 +388,77 @@ mod engine { .ok_or_else(|| anyhow!("provider not available")) } + fn canonicalize_or_self(p: &Path) -> PathBuf { + std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()) + } + + /// Subtree check for a real filesystem path. Canonicalizes both + /// sides, but also accepts the raw parent so a not-yet-resolvable + /// constraint (or an unresolvable dir) still matches literally. + fn dir_in_scope(dir: &str, parent_dir: &Path) -> bool { + let d = canonicalize_or_self(Path::new(dir)); + d.starts_with(canonicalize_or_self(parent_dir)) || d.starts_with(parent_dir) + } + + /// Claude's project-dir slugs are lossy — '/', '_', and '.' all + /// became '-', and un-sanitizing only restores '/'. Comparing real + /// paths therefore misfilters any project containing '.' or '_'; + /// compare in slug space instead, where '/' boundaries are '-'. + fn claude_project_in_scope(project: &str, parent_dir: &Path) -> bool { + fn slug(s: &str) -> String { + s.replace(['/', '_', '.'], "-") + } + let p = slug(project); + [ + slug(&parent_dir.to_string_lossy()), + slug(&canonicalize_or_self(parent_dir).to_string_lossy()), + ] + .iter() + .any(|parent| p == *parent || p.starts_with(&format!("{parent}-"))) + } + + /// Where a stub's artifact lives, for providers whose cheap listing + /// doesn't carry it. Codex is the only case: the rollout's first + /// line is `session_meta` with the session cwd — one bounded read, + /// and the result is memoized into the manifest record afterwards. + fn peek_stub_dir(bundle: &HarnessBundle, stub: &ArtifactStub) -> Option { + if stub.artifact_type != ArtifactType::Codex { + return None; + } + let file = bundle + .codex + .as_ref()? + .resolver() + .find_rollout_file(&stub.id) + .ok()?; + use std::io::{BufRead, BufReader}; + let f = std::fs::File::open(file).ok()?; + let mut line = String::new(); + BufReader::new(f).read_line(&mut line).ok()?; + let v: serde_json::Value = serde_json::from_str(&line).ok()?; + Some(v.get("payload")?.get("cwd")?.as_str()?.to_string()) + } + // ── stat-level enumeration ───────────────────────────────────────── /// Enumerate one type's artifacts with stat-level fingerprints. /// Providers that aren't installed produce no stubs; other listing /// errors warn and skip so one broken provider can't block the rest. - fn enumerate_stubs(bundle: &HarnessBundle, t: ArtifactType) -> Vec { + fn enumerate_stubs( + bundle: &HarnessBundle, + t: ArtifactType, + parent_dir: Option<&Path>, + ) -> Vec { let mut out = Vec::new(); match t { ArtifactType::Claude => { if let Some(mgr) = &bundle.claude { - stubs_claude(mgr, &mut out); + stubs_claude(mgr, parent_dir, &mut out); } } ArtifactType::Gemini => { if let Some(mgr) = &bundle.gemini { - stubs_gemini(mgr, &mut out); + stubs_gemini(mgr, parent_dir, &mut out); } } ArtifactType::Codex => { @@ -374,7 +478,7 @@ mod engine { } ArtifactType::Pi => { if let Some(mgr) = &bundle.pi { - stubs_pi(mgr, &mut out); + stubs_pi(mgr, parent_dir, &mut out); } } // Recorded via `p import`, never discovered: there is no @@ -387,7 +491,11 @@ mod engine { /// Chain heads via `list_conversations` (bounded first-lines peek /// per file, no full parse); fingerprint stats the head segment — /// appends land there, and a rotation surfaces as a new head id. - fn stubs_claude(mgr: &toolpath_claude::ClaudeConvo, out: &mut Vec) { + fn stubs_claude( + mgr: &toolpath_claude::ClaudeConvo, + parent_dir: Option<&Path>, + out: &mut Vec, + ) { let projects = match mgr.list_projects() { Ok(ps) => ps, Err(e) if is_not_found_claude(&e) => return, @@ -397,6 +505,11 @@ mod engine { } }; for project in projects { + if let Some(parent_dir) = parent_dir + && !claude_project_in_scope(&project, parent_dir) + { + continue; + } let heads = match mgr.list_conversations(&project) { Ok(h) => h, Err(e) => { @@ -424,7 +537,11 @@ mod engine { /// Session entries via a bounded identity peek (`toolpath-gemini` /// reads at most the first 4 KiB of a main file); the fingerprint /// stats the main file (or the orphan sub-agent directory). - fn stubs_gemini(mgr: &toolpath_gemini::GeminiConvo, out: &mut Vec) { + fn stubs_gemini( + mgr: &toolpath_gemini::GeminiConvo, + parent_dir: Option<&Path>, + out: &mut Vec, + ) { let projects = match mgr.list_projects() { Ok(ps) => ps, Err(e) if is_not_found_gemini(&e) => return, @@ -434,6 +551,11 @@ mod engine { } }; for project in projects { + if let Some(parent_dir) = parent_dir + && !dir_in_scope(&project, parent_dir) + { + continue; + } let entries = match mgr.resolver().list_session_entries(&project) { Ok(entries) => entries, Err(e) => { @@ -497,8 +619,8 @@ mod engine { out.push(ArtifactStub { artifact_type: ArtifactType::Opencode, modified: s.last_activity(), + path: Some(s.directory.to_string_lossy().into_owned()), id: s.id, - path: None, size: None, }); } @@ -521,8 +643,11 @@ mod engine { out.push(ArtifactStub { artifact_type: ArtifactType::Cursor, modified: l.head.last_updated_at_utc(), + path: l + .head + .workspace_path() + .map(|p| p.to_string_lossy().into_owned()), id: l.head.composer_id, - path: None, size: None, }); } @@ -531,7 +656,11 @@ mod engine { /// Session files stat-only; the id comes from a one-line header /// peek, falling back to the filename stem's `_` /// shape — the same resolution `read_session` accepts. - fn stubs_pi(mgr: &toolpath_pi::PiConvo, out: &mut Vec) { + fn stubs_pi( + mgr: &toolpath_pi::PiConvo, + parent_dir: Option<&Path>, + out: &mut Vec, + ) { let projects = match mgr.list_projects() { Ok(ps) => ps, Err(e) if is_not_found_pi(&e) => return, @@ -541,6 +670,11 @@ mod engine { } }; for project in projects { + if let Some(parent_dir) = parent_dir + && !dir_in_scope(&project, parent_dir) + { + continue; + } let files = match toolpath_pi::reader::list_session_files(mgr.resolver(), &project) { Ok(f) => f, Err(e) => { @@ -592,6 +726,9 @@ mod engine { if o.failed > 0 { s.push_str(&format!(", {} failed", o.failed)); } + if o.out_of_scope > 0 { + s.push_str(&format!(", {} out of scope", o.out_of_scope)); + } s.push('\n'); } if s.is_empty() { @@ -611,7 +748,7 @@ mod engine { stub.id.clone(), SyncRecord { path: stub.path.clone(), - cache_id: cache_id.to_string(), + cache_id: Some(cache_id.to_string()), modified: stub.modified, size: stub.size, synced_at: Utc::now(), @@ -620,6 +757,27 @@ mod engine { save_manifest(&manifest) } + /// `p cache rm` eviction: the doc is gone, so any record pointing + /// at it downgrades to known-but-uncached (the artifact itself is + /// still real; the next in-scope sync re-materializes it). + pub(crate) fn evict_cache_id(cache_id: &str) -> Result<()> { + let mut manifest = load_manifest()?; + let mut changed = false; + for records in manifest.values_mut() { + for rec in records.values_mut() { + if rec.cache_id.as_deref() == Some(cache_id) { + rec.cache_id = None; + changed = true; + } + } + } + if changed { + save_manifest(&manifest) + } else { + Ok(()) + } + } + // ── manifest IO ──────────────────────────────────────────────────── fn manifest_path() -> Result { @@ -742,7 +900,7 @@ mod engine { "sess-1".to_string(), SyncRecord { path: Some("/test/project".to_string()), - cache_id: "claude-p1".to_string(), + cache_id: Some("claude-p1".to_string()), modified: Some("2024-01-02T00:00:01.123456789Z".parse().unwrap()), size: Some(4096), synced_at: "2026-07-09T00:00:00Z".parse().unwrap(), @@ -783,7 +941,7 @@ mod engine { with_cfg(|home| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); - let stubs = enumerate_stubs(&bundle, ArtifactType::Claude); + let stubs = enumerate_stubs(&bundle, ArtifactType::Claude, None); assert_eq!(stubs.len(), 1); assert_eq!(stubs[0].id, "sess-aaa"); assert_eq!(stubs[0].path.as_deref(), Some("/test/project")); @@ -799,7 +957,7 @@ mod engine { write_claude_session(home, "-test-project", "sess-bbb", "Fix a bug"); let bundle = claude_bundle(home); - let outcomes = sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap(); + let outcomes = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); assert_eq!(outcomes.len(), 1); let (_, first) = outcomes[0]; assert_eq!( @@ -814,15 +972,16 @@ mod engine { assert_eq!(rec.path.as_deref(), Some("/test/project")); assert!(rec.modified.is_some()); assert!(rec.size.is_some()); + let cache_id = rec + .cache_id + .as_deref() + .expect("synced record is materialized"); assert!( - crate::cmd_cache::cache_path(&rec.cache_id) - .unwrap() - .exists(), - "cache doc must exist for {}", - rec.cache_id + crate::cmd_cache::cache_path(cache_id).unwrap().exists(), + "cache doc must exist for {cache_id}" ); - let (_, second) = sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap()[0]; + let (_, second) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; assert_eq!( (second.new, second.updated, second.unchanged, second.failed), (0, 0, 2, 0) @@ -835,11 +994,12 @@ mod engine { with_cfg(|home| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); - sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap(); + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] .cache_id - .clone(); + .clone() + .expect("synced record is materialized"); let steps_before = cached_step_count(&cache_id); // Session continues: a later user turn lands in the file, @@ -852,7 +1012,7 @@ mod engine { body.push('\n'); std::fs::write(&file, body).unwrap(); - let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap()[0]; + let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; assert_eq!( ( outcome.new, @@ -875,7 +1035,7 @@ mod engine { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); - let outcomes = sync_bundle(&bundle, &[ArtifactType::Codex]).unwrap(); + let outcomes = sync_bundle(&bundle, &[ArtifactType::Codex], None).unwrap(); assert_eq!(outcomes[0].1, SyncOutcome::default()); assert!( load_manifest().unwrap().is_empty(), @@ -889,13 +1049,13 @@ mod engine { with_cfg(|home| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); - sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap(); + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); // Losing the manifest (or a prior manual `p import`) leaves a // cache entry sync doesn't know about; re-syncing must // overwrite it, not die on the exists-check. std::fs::remove_file(manifest_path().unwrap()).unwrap(); - let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap()[0]; + let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; assert_eq!((outcome.new, outcome.failed), (1, 0)); }); } @@ -905,11 +1065,11 @@ mod engine { with_cfg(|home| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); - let mut stubs = enumerate_stubs(&bundle, ArtifactType::Claude); + let mut stubs = enumerate_stubs(&bundle, ArtifactType::Claude, None); stubs.push(make_stub(ArtifactType::Claude, "does-not-exist")); let mut records = BTreeMap::new(); - let outcome = sync_stubs(&bundle, &stubs, &mut records).unwrap(); + let outcome = sync_stubs(&bundle, &stubs, &mut records, None).unwrap(); assert_eq!((outcome.new, outcome.failed), (1, 1)); assert!(records.contains_key("sess-aaa")); assert!( @@ -940,7 +1100,7 @@ mod engine { record_stub(stub, &derived.cache_id).unwrap(); // The import's stamp must match sync's own enumeration. - let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude]).unwrap()[0]; + let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; assert_eq!( ( outcome.new, @@ -953,6 +1113,140 @@ mod engine { }); } + #[test] + fn parent_dir_scopes_path_keyed_enumeration() { + with_cfg(|home| { + write_claude_session(home, "-scope-alpha", "aaaa1111-x", "In alpha"); + write_claude_session(home, "-scope-beta", "bbbb2222-x", "In beta"); + let bundle = claude_bundle(home); + + let (_, scoped) = sync_bundle( + &bundle, + &[ArtifactType::Claude], + Some(Path::new("/scope/alpha")), + ) + .unwrap()[0]; + assert_eq!((scoped.new, scoped.out_of_scope), (1, 0)); + let manifest = load_manifest().unwrap(); + assert!( + !manifest["claude"].contains_key("bbbb2222-x"), + "pruned projects must not be enumerated or recorded" + ); + + // Unscoped sync picks up the rest. + let (_, full) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; + assert_eq!((full.new, full.unchanged), (1, 1)); + }); + } + + fn codex_bundle(home: &Path, cwd: &str) -> HarnessBundle { + let codex_dir = home.join(".codex"); + let dir = codex_dir.join("sessions/2026/05/07"); + std::fs::create_dir_all(&dir).unwrap(); + let meta = format!( + r#"{{"timestamp":"2026-05-07T00:00:00Z","type":"session_meta","payload":{{"id":"00000000-0000-0000-0000-0000000000aa","timestamp":"2026-05-07T00:00:00Z","cwd":"{cwd}","originator":"codex-tui","cli_version":"test","source":"cli","model_provider":"openai"}}}}"# + ); + let user = r#"{"timestamp":"2026-05-07T00:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}}"#; + std::fs::write( + dir.join("rollout-2026-05-07T00-00-00-00000000-0000-0000-0000-0000000000aa.jsonl"), + format!("{meta}\n{user}\n"), + ) + .unwrap(); + let resolver = toolpath_codex::PathResolver::new().with_codex_dir(&codex_dir); + HarnessBundle { + codex: Some(toolpath_codex::CodexConvo::with_resolver(resolver)), + ..Default::default() + } + } + + #[test] + fn out_of_scope_codex_peek_is_memoized_then_scope_match_derives() { + with_cfg(|home| { + let bundle = codex_bundle(home, "/work/proj"); + + // cwd lives outside the constraint: one bounded peek, a + // known-but-uncached record, no derive. + let (_, out) = sync_bundle( + &bundle, + &[ArtifactType::Codex], + Some(Path::new("/elsewhere")), + ) + .unwrap()[0]; + assert_eq!((out.new, out.out_of_scope), (0, 1)); + let rec = load_manifest().unwrap()["codex"]["00000000-0000-0000-0000-0000000000aa"] + .clone(); + assert_eq!( + rec.path.as_deref(), + Some("/work/proj"), + "peeked cwd memoized" + ); + assert!(rec.cache_id.is_none(), "known, not materialized"); + + // Matching constraint: the memoized record answers the scope + // question and the artifact derives. + let (_, hit) = sync_bundle( + &bundle, + &[ArtifactType::Codex], + Some(Path::new("/work/proj")), + ) + .unwrap()[0]; + assert_eq!((hit.new, hit.updated, hit.out_of_scope), (0, 1, 0)); + let rec = load_manifest().unwrap()["codex"]["00000000-0000-0000-0000-0000000000aa"] + .clone(); + assert!(rec.cache_id.is_some(), "materialized now"); + }); + } + + #[test] + fn evicted_cache_entry_rematerializes_on_next_sync() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); + let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] + .cache_id + .clone() + .unwrap(); + + // `p cache rm`: doc removed, record downgraded to known. + crate::cmd_cache::remove_cached(&cache_id).unwrap(); + evict_cache_id(&cache_id).unwrap(); + assert!( + load_manifest().unwrap()["claude"]["sess-aaa"] + .cache_id + .is_none() + ); + + let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; + assert_eq!((outcome.new, outcome.updated), (0, 1)); + assert!( + crate::cmd_cache::cache_path(&cache_id).unwrap().exists(), + "evicted artifact re-materializes" + ); + }); + } + + #[test] + fn manually_deleted_doc_is_restored_even_with_stale_record() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); + let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] + .cache_id + .clone() + .unwrap(); + + // Doc deleted behind the CLI's back: the record still claims + // materialization, but sync verifies the doc exists. + let doc = crate::cmd_cache::cache_path(&cache_id).unwrap(); + std::fs::remove_file(&doc).unwrap(); + let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; + assert_eq!((outcome.new, outcome.updated), (0, 1)); + assert!(doc.exists()); + }); + } + #[test] fn derive_stub_errors_when_provider_missing() { let bundle = HarnessBundle::default(); @@ -986,6 +1280,7 @@ mod engine { updated: 1, unchanged: 3, failed: 0, + out_of_scope: 0, }, ), (ArtifactType::Cursor, SyncOutcome::default()), @@ -1007,6 +1302,7 @@ mod engine { updated: 0, unchanged: 1, failed: 2, + out_of_scope: 0, }, )]; let s = render_summary(&outcomes, false); diff --git a/crates/path-cli/tests/integration.rs b/crates/path-cli/tests/integration.rs index 2efb57a5..aa2e6651 100644 --- a/crates/path-cli/tests/integration.rs +++ b/crates/path-cli/tests/integration.rs @@ -903,6 +903,30 @@ fn share_records_manifest_so_sync_skips() { .stderr(predicate::str::contains("0 new, 0 updated, 1 unchanged")); } +#[test] +fn cache_sync_parent_dir_limits_ingestion() { + let (home, _session_file) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "cache", "sync", "claude", "-d", "/nowhere"]) + .assert() + .success() + .stderr(predicate::str::contains("0 new, 0 updated, 0 unchanged")); + + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "cache", "sync", "claude", "-d"]) + .arg(&project) + .assert() + .success() + .stderr(predicate::str::contains("1 new, 0 updated, 0 unchanged")); +} + #[test] fn cache_sync_rejects_unknown_type() { let cfg = tempfile::tempdir().unwrap(); diff --git a/crates/path-cli/tests/query.rs b/crates/path-cli/tests/query.rs index 8bd0f48f..30559967 100644 --- a/crates/path-cli/tests/query.rs +++ b/crates/path-cli/tests/query.rs @@ -600,3 +600,26 @@ fn input_only_query_never_touches_the_cache() { .stdout(predicate::str::contains("true")); assert!(!cfg.path().join("sync.json").exists()); } + +#[test] +fn query_parent_dir_scopes_sync_and_read() { + let home = claude_home(); + let cfg = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + + // Constraint matches the session's project: ingested and read. + fixture_query( + &home, + cfg.path(), + ["--parent-dir", project.to_str().unwrap(), "length > 0"], + ) + .success() + .stdout(predicate::str::contains("true")) + .stderr(predicate::str::contains("synced claude: 1 new")); + + // Constraint elsewhere: nothing read, nothing new ingested. + fixture_query(&home, cfg.path(), ["--parent-dir", "/nowhere", "length"]) + .success() + .stdout(predicate::str::contains("0")) + .stderr(predicate::str::contains("synced").not()); +} From 9f71123838de600d2b2e47ed6dd584c257df9375 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Mon, 13 Jul 2026 12:39:09 -0400 Subject: [PATCH 14/20] feat(cli): wire Copilot into sync after rebasing onto ben/query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased ben/cache-sync onto the rewritten ben/query (now on main with the Copilot provider). Copilot becomes a full participant: an ArtifactType::Copilot + Harness::Copilot pair, stat-only enumeration over session-state//events.jsonl (dir name is the id), a bounded first-line peek for the session.start context cwd — memoized like codex's — per-session import loops with provenance, the share aggregator and resume arms restored onto the Harness-typed surfaces, and query auto-sync via --source copilot. --- CHANGELOG.md | 5 ++ CLAUDE.md | 6 +- crates/path-cli/src/cmd_import.rs | 64 +++++++-------- crates/path-cli/src/cmd_resume.rs | 6 ++ crates/path-cli/src/cmd_share.rs | 40 ++++++--- crates/path-cli/src/sync.rs | 130 +++++++++++++++++++++++++++--- crates/path-cli/tests/resume.rs | 2 +- 7 files changed, 195 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87e05cac..335bc72b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,11 @@ users no longer have to `p import` each session by hand. space (its dir slugs are lossy), and claude derives now source `path.base` from the session's recorded cwd instead of the lossy slug string. + - Copilot participates in sync like every other harness: an + `ArtifactType::Copilot` (stat-only enumeration over + `session-state//events.jsonl`, cwd peeked from `session.start` + and memoized for `--parent-dir`), per-session import loops with + provenance, and `path query --source copilot` auto-sync. - **`toolpath-gemini`** (0.6.1): new `PathResolver::list_session_entries` returns each session's listing id, inner `sessionId`, and backing file/dir path, and `peek_session_id` is now bounded — it scans the diff --git a/CLAUDE.md b/CLAUDE.md index a14cc8f9..ad2bd8d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,7 +212,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-cursor`: 78 unit + 8 integration round-trip + 1 real-DB sanity + 1 doc test (state.vscdb SQLite reader, bubble store + composer header parsing, content-addressed blob lookup, projector with full TOOL_TABLE coverage, JSONL transcript ingest in `examples/dump_fixture.rs`) - `toolpath-pi`: 133 unit + 26 integration + 5 doc tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `path-cli`: 347 unit + 110 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. +- `path-cli`: 350 unit + 117 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. - `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary) Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- p validate --input "$f"; done` @@ -282,5 +282,5 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. -- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs all six agent harnesses. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: head segment via `list_conversations`' bounded first-lines peek; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record's `cache_id` is *optional*: a record without one is "known, not materialized" — created when a `--parent-dir` constraint excluded a peeked artifact, or when `p cache rm` evicts a doc (rm downgrades the record; the next in-scope sync re-materializes it, and sync also verifies the doc file actually exists before skipping, so even out-of-band deletions self-heal). `--parent-dir ` / `-d` on both `p cache sync` and `path query` restricts ingestion to artifacts under that directory (subtree): path-keyed providers prune whole projects before enumerating (claude compares in *slug space* — its dir slugs are lossy, `/`/`_`/`.` all became `-`), cwd-keyed ones check the directory their cheap headers carry, and codex — whose cwd lives inside the rollout — gets a one-line peek only when new/changed, memoized into the record's `path`. The stat gate always runs first: unchanged+cached artifacts skip before any scope check. Out-of-scope work is tallied separately (`N out of scope`) and never touches a materialized record's stamp. Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's own recorded cwd rather than the lossy slug. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactStub` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_stub` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. -- `ArtifactType` (`crates/path-cli/src/sync.rs`) is the general enum naming artifact sources — the six agent harnesses plus `Git` (7 variants). Git artifacts are *recorded* in the manifest by `p import git` (id `-`, `path` = the repo directory) but never *discovered* — there is no machine-wide registry of repos — so sync reports them and leaves them alone. Github and pathbase are deliberately not artifact types: they are remote services, not local artifact sources, and their imports stay out of the manifest. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`cmd_share.rs`) names the six agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. +- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs every artifact type. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: head segment via `list_conversations`' bounded first-lines peek; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek; copilot: `session-state//events.jsonl`, pure read-dir + stat) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record's `cache_id` is *optional*: a record without one is "known, not materialized" — created when a `--parent-dir` constraint excluded a peeked artifact, or when `p cache rm` evicts a doc (rm downgrades the record; the next in-scope sync re-materializes it, and sync also verifies the doc file actually exists before skipping, so even out-of-band deletions self-heal). `--parent-dir ` / `-d` on both `p cache sync` and `path query` restricts ingestion to artifacts under that directory (subtree): path-keyed providers prune whole projects before enumerating (claude compares in *slug space* — its dir slugs are lossy, `/`/`_`/`.` all became `-`), cwd-keyed ones check the directory their cheap headers carry, and codex/copilot — whose cwd lives inside the session file — get a one-line peek only when new/changed, memoized into the record's `path`. The stat gate always runs first: unchanged+cached artifacts skip before any scope check. Out-of-scope work is tallied separately (`N out of scope`) and never touches a materialized record's stamp. Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's own recorded cwd rather than the lossy slug. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactStub` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_stub` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. +- `ArtifactType` (`crates/path-cli/src/sync.rs`) is the general enum naming artifact sources — the seven agent harnesses (incl. copilot) plus `Git` (8 variants). Git artifacts are *recorded* in the manifest by `p import git` (id `-`, `path` = the repo directory) but never *discovered* — there is no machine-wide registry of repos — so sync reports them and leaves them alone. Github and pathbase are deliberately not artifact types: they are remote services, not local artifact sources, and their imports stay out of the manifest. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`cmd_share.rs`) names the six agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index a44c5a22..f337952b 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -1057,20 +1057,17 @@ fn pick_codex(manager: &toolpath_codex::CodexConvo) -> Result fn derive_copilot(session: Option, all: bool) -> Result> { let manager = toolpath_copilot::CopilotConvo::new(); - let config = toolpath_copilot::derive::DeriveConfig { project_path: None }; let session_ids: Vec = match (session, all) { (Some(s), _) => vec![s], (None, true) => { - let sessions = manager - .read_all_sessions() + let metas = manager + .list_sessions() .map_err(|e| anyhow::anyhow!("{}", e))?; - if sessions.is_empty() { + if metas.is_empty() { anyhow::bail!("No Copilot sessions found in ~/.copilot/session-state"); } - return wrap_paths_copilot(toolpath_copilot::derive::derive_project( - &sessions, &config, - )); + metas.into_iter().map(|m| m.id).collect() } (None, false) => { #[cfg(not(target_os = "emscripten"))] @@ -1086,9 +1083,7 @@ fn derive_copilot(session: Option, all: bool) -> Result> "No Copilot sessions found in ~/.copilot/session-state" ) })?; - return wrap_paths_copilot(vec![toolpath_copilot::derive::derive_path( - &s, &config, - )]); + return Ok(vec![derive_copilot_session_with(&manager, &s.id)?]); } } } @@ -1100,51 +1095,52 @@ fn derive_copilot(session: Option, all: bool) -> Result> .ok_or_else(|| { anyhow::anyhow!("No Copilot sessions found in ~/.copilot/session-state") })?; - return wrap_paths_copilot(vec![toolpath_copilot::derive::derive_path( - &s, &config, - )]); + return Ok(vec![derive_copilot_session_with(&manager, &s.id)?]); } } }; - let mut paths: Vec = Vec::with_capacity(session_ids.len()); + let mut docs = Vec::with_capacity(session_ids.len()); for sid in &session_ids { - let s = manager - .read_session(sid) - .map_err(|e| anyhow::anyhow!("{}", e))?; - paths.push(toolpath_copilot::derive::derive_path(&s, &config)); + docs.push(derive_copilot_session_with(&manager, sid)?); } - wrap_paths_copilot(paths) + Ok(docs) } /// Derive a single Copilot session given an explicit session id. pub(crate) fn derive_copilot_session(session: &str) -> Result { - let manager = toolpath_copilot::CopilotConvo::new(); + derive_copilot_session_with(&toolpath_copilot::CopilotConvo::new(), session) +} + +/// [`derive_copilot_session`] against a caller-supplied manager. +pub(crate) fn derive_copilot_session_with( + manager: &toolpath_copilot::CopilotConvo, + session: &str, +) -> Result { + let (modified, size) = manager + .resolver() + .events_file(session) + .map(|p| stat_stamp(&p)) + .unwrap_or((None, None)); let config = toolpath_copilot::derive::DeriveConfig { project_path: None }; let s = manager .read_session(session) .map_err(|e| anyhow::anyhow!("{}", e))?; let path = toolpath_copilot::derive::derive_path(&s, &config); - let cache_id = make_id("copilot", &path.path.id); + let cache_id = make_id(ArtifactType::Copilot.name(), &path.path.id); Ok(DerivedDoc { cache_id, doc: Graph::from_path(path), + provenance: Some(ArtifactStub { + artifact_type: ArtifactType::Copilot, + id: session.to_string(), + path: None, + modified, + size, + }), }) } -fn wrap_paths_copilot(paths: Vec) -> Result> { - Ok(paths - .into_iter() - .map(|p| { - let cache_id = make_id("copilot", &p.path.id); - DerivedDoc { - cache_id, - doc: Graph::from_path(p), - } - }) - .collect()) -} - #[cfg(not(target_os = "emscripten"))] fn pick_copilot(manager: &toolpath_copilot::CopilotConvo) -> Result>> { if !fuzzy::available() { diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index d522e9c2..02272b6d 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -130,6 +130,7 @@ pub(crate) fn infer_source_harness(path: &TPath) -> Option { "claude-code" => return Some(Harness::Claude), "gemini-cli" => return Some(Harness::Gemini), "codex" => return Some(Harness::Codex), + "copilot" => return Some(Harness::Copilot), "opencode" => return Some(Harness::Opencode), "cursor" => return Some(Harness::Cursor), "pi" => return Some(Harness::Pi), @@ -147,6 +148,9 @@ pub(crate) fn infer_source_harness(path: &TPath) -> Option { if actor.starts_with("agent:codex") { return Some(Harness::Codex); } + if actor.starts_with("agent:copilot") { + return Some(Harness::Copilot); + } if actor.starts_with("agent:opencode") { return Some(Harness::Opencode); } @@ -402,6 +406,7 @@ pub(crate) fn argv_for(harness: Harness, session_id: &str) -> Vec { Harness::Claude => vec!["-r".into(), session_id.into()], Harness::Gemini => vec!["--resume".into(), session_id.into()], Harness::Codex => vec!["resume".into(), session_id.into()], + Harness::Copilot => vec!["--resume".into(), session_id.into()], Harness::Opencode => vec!["--session".into(), session_id.into()], // Cursor.app has no "open composer by id" flag — we exec the // workspace path so Cursor opens on that folder; the projected @@ -459,6 +464,7 @@ pub(crate) fn project_into_harness( Harness::Claude => crate::cmd_export::project_claude(path, cwd), Harness::Gemini => crate::cmd_export::project_gemini(path, cwd), Harness::Codex => crate::cmd_export::project_codex(path, cwd), + Harness::Copilot => crate::cmd_export::project_copilot(path, cwd), Harness::Opencode => crate::cmd_export::project_opencode(path, cwd), Harness::Cursor => crate::cmd_export::project_cursor(path, cwd), Harness::Pi => crate::cmd_export::project_pi(path, cwd), diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index 6eb9cb24..9b76f810 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -27,17 +27,19 @@ pub enum Harness { Opencode, Cursor, Pi, + Copilot, } impl Harness { /// Every harness, in presentation order. - pub(crate) const ALL: [Harness; 6] = [ + pub(crate) const ALL: [Harness; 7] = [ Harness::Claude, Harness::Gemini, Harness::Codex, Harness::Opencode, Harness::Cursor, Harness::Pi, + Harness::Copilot, ]; /// The artifact type this harness's sessions ingest as. @@ -49,6 +51,7 @@ impl Harness { Harness::Opencode => ArtifactType::Opencode, Harness::Cursor => ArtifactType::Cursor, Harness::Pi => ArtifactType::Pi, + Harness::Copilot => ArtifactType::Copilot, } } @@ -72,6 +75,7 @@ impl ArtifactType { ArtifactType::Opencode => Some(Harness::Opencode), ArtifactType::Cursor => Some(Harness::Cursor), ArtifactType::Pi => Some(Harness::Pi), + ArtifactType::Copilot => Some(Harness::Copilot), ArtifactType::Git => None, } } @@ -209,6 +213,11 @@ pub(crate) fn gather_artifacts( { collect_codex(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); } + if want(ArtifactType::Copilot) + && let Some(mgr) = &bundle.copilot + { + collect_copilot(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); + } if want(ArtifactType::Opencode) && let Some(mgr) = &bundle.opencode { @@ -440,7 +449,7 @@ fn collect_copilot( mgr: &toolpath_copilot::CopilotConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { let metas = match mgr.list_sessions() { Ok(m) if !m.is_empty() => m, @@ -464,16 +473,16 @@ fn collect_copilot( .as_deref() .map(|p| paths_match(p, canonical_cwd)) .unwrap_or(false); - out.push(SessionRow { - harness: Harness::Copilot, - project: None, + out.push(ArtifactRow { + artifact_type: ArtifactType::Copilot, + path: None, cwd: m.cwd, session_id: m.id, title: m .first_user_message .unwrap_or_else(|| "(no prompt)".to_string()), last_activity: m.last_activity, - message_count: m.line_count, + message_count: Some(m.line_count), matches_cwd, }); } @@ -596,6 +605,13 @@ pub(crate) fn is_not_found_codex(err: &toolpath_codex::ConvoError) -> bool { || matches!(err, ConvoError::CodexDirectoryNotFound(_)) } +pub(crate) fn is_not_found_copilot(err: &toolpath_copilot::ConvoError) -> bool { + use toolpath_copilot::ConvoError; + matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) + || matches!(err, ConvoError::NoHomeDirectory) + || matches!(err, ConvoError::CopilotDirectoryNotFound(_)) +} + pub(crate) fn is_not_found_opencode(err: &toolpath_opencode::ConvoError) -> bool { use toolpath_opencode::ConvoError; matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) @@ -1036,6 +1052,7 @@ fn derive_session( ArtifactType::Gemini => { crate::cmd_import::derive_gemini_session(project.expect("path_keyed"), session, false) } + ArtifactType::Copilot => crate::cmd_import::derive_copilot_session(session), ArtifactType::Pi => { crate::cmd_import::derive_pi_session(project.expect("path_keyed"), session, None) } @@ -1206,9 +1223,9 @@ mod tests { let temp = TempDir::new().unwrap(); write_copilot_session(&temp.path().join(".copilot"), "sess-aa", "/work/proj"); let bundle = copilot_only_bundle(temp.path()); - let rows = gather_sessions(&bundle, Path::new("/work/proj"), None, None); + let rows = gather_artifacts(&bundle, Path::new("/work/proj"), None, None); assert_eq!(rows.len(), 1); - assert_eq!(rows[0].harness, Harness::Copilot); + assert_eq!(rows[0].artifact_type, ArtifactType::Copilot); assert_eq!(rows[0].cwd.as_deref(), Some("/work/proj")); assert!(rows[0].matches_cwd); } @@ -1219,7 +1236,12 @@ mod tests { write_copilot_session(&temp.path().join(".copilot"), "sess-aa", "/work/proj"); let bundle = copilot_only_bundle(temp.path()); // Filtering to a different harness drops the copilot row. - let rows = gather_sessions(&bundle, Path::new("/work/proj"), Some(Harness::Codex), None); + let rows = gather_artifacts( + &bundle, + Path::new("/work/proj"), + Some(ArtifactType::Codex), + None, + ); assert!(rows.is_empty()); } diff --git a/crates/path-cli/src/sync.rs b/crates/path-cli/src/sync.rs index e20af8b1..d3ca14b0 100644 --- a/crates/path-cli/src/sync.rs +++ b/crates/path-cli/src/sync.rs @@ -29,18 +29,20 @@ pub enum ArtifactType { Opencode, Cursor, Pi, + Copilot, Git, } impl ArtifactType { /// Every artifact type, in presentation order. - pub(crate) const ALL: [ArtifactType; 7] = [ + pub(crate) const ALL: [ArtifactType; 8] = [ ArtifactType::Claude, ArtifactType::Gemini, ArtifactType::Codex, ArtifactType::Opencode, ArtifactType::Cursor, ArtifactType::Pi, + ArtifactType::Copilot, ArtifactType::Git, ]; @@ -52,6 +54,7 @@ impl ArtifactType { ArtifactType::Opencode => "opencode", ArtifactType::Cursor => "cursor", ArtifactType::Pi => "pi", + ArtifactType::Copilot => "copilot", ArtifactType::Git => "git", } } @@ -66,6 +69,7 @@ impl ArtifactType { ArtifactType::Opencode => "opencode", ArtifactType::Cursor => "cursor ", ArtifactType::Pi => "pi ", + ArtifactType::Copilot => "copilot ", ArtifactType::Git => "git ", } } @@ -90,6 +94,7 @@ impl ArtifactType { "opencode" => Some(ArtifactType::Opencode), "cursor" => Some(ArtifactType::Cursor), "pi" => Some(ArtifactType::Pi), + "copilot" => Some(ArtifactType::Copilot), "git" => Some(ArtifactType::Git), _ => None, } @@ -377,6 +382,9 @@ mod engine { imp::derive_opencode_session_with(mgr(&bundle.opencode)?, &stub.id, false) } ArtifactType::Cursor => imp::derive_cursor_session_with(mgr(&bundle.cursor)?, &stub.id), + ArtifactType::Copilot => { + imp::derive_copilot_session_with(mgr(&bundle.copilot)?, &stub.id) + } ArtifactType::Git => Err(anyhow!( "git artifacts are recorded by `p import`, not re-derived by sync" )), @@ -422,21 +430,39 @@ mod engine { /// line is `session_meta` with the session cwd — one bounded read, /// and the result is memoized into the manifest record afterwards. fn peek_stub_dir(bundle: &HarnessBundle, stub: &ArtifactStub) -> Option { - if stub.artifact_type != ArtifactType::Codex { - return None; - } - let file = bundle - .codex - .as_ref()? - .resolver() - .find_rollout_file(&stub.id) - .ok()?; + let file = match stub.artifact_type { + ArtifactType::Codex => bundle + .codex + .as_ref()? + .resolver() + .find_rollout_file(&stub.id) + .ok()?, + ArtifactType::Copilot => bundle + .copilot + .as_ref()? + .resolver() + .events_file(&stub.id) + .ok()?, + _ => return None, + }; use std::io::{BufRead, BufReader}; let f = std::fs::File::open(file).ok()?; let mut line = String::new(); BufReader::new(f).read_line(&mut line).ok()?; let v: serde_json::Value = serde_json::from_str(&line).ok()?; - Some(v.get("payload")?.get("cwd")?.as_str()?.to_string()) + match stub.artifact_type { + // Codex: `session_meta` payload carries cwd directly. + ArtifactType::Codex => Some(v.get("payload")?.get("cwd")?.as_str()?.to_string()), + // Copilot: `session.start` carries it under `context`, with + // some key-name variance across CLI versions. + ArtifactType::Copilot => ["data", "payload"].iter().find_map(|env| { + let ctx = v.get(env)?.get("context")?; + ["cwd", "workingDirectory", "working_dir"] + .iter() + .find_map(|k| Some(ctx.get(k)?.as_str()?.to_string())) + }), + _ => None, + } } // ── stat-level enumeration ───────────────────────────────────────── @@ -481,6 +507,11 @@ mod engine { stubs_pi(mgr, parent_dir, &mut out); } } + ArtifactType::Copilot => { + if let Some(mgr) = &bundle.copilot { + stubs_copilot(mgr, &mut out); + } + } // Recorded via `p import`, never discovered: there is no // machine-wide registry of repos to walk. ArtifactType::Git => {} @@ -576,6 +607,40 @@ mod engine { } } + /// Session-state directories, stat-only: each session is a + /// `/events.jsonl` under `session-state/` (or its legacy + /// sibling); the directory name is the id and the events file is + /// the fingerprint target. + fn stubs_copilot(mgr: &toolpath_copilot::CopilotConvo, out: &mut Vec) { + let mut seen = std::collections::HashSet::new(); + let dirs = [ + mgr.resolver().session_state_dir(), + mgr.resolver().legacy_session_state_dir(), + ]; + for dir in dirs.into_iter().flatten() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let Some(id) = entry.file_name().to_str().map(String::from) else { + continue; + }; + let events = entry.path().join("events.jsonl"); + if !events.exists() || !seen.insert(id.clone()) { + continue; + } + let (modified, size) = stat_stamp(&events); + out.push(ArtifactStub { + artifact_type: ArtifactType::Copilot, + id, + path: None, + modified, + size, + }); + } + } + } + /// Rollout files, stat-only. The artifact id is the trailing UUID of /// the filename stem (`rollout--`); `read_session` /// accepts either the UUID or the full stem, so the fallback is safe. @@ -1197,6 +1262,49 @@ mod engine { }); } + fn copilot_bundle(home: &Path, id: &str, cwd: &str) -> HarnessBundle { + let copilot_dir = home.join(".copilot"); + let dir = copilot_dir.join("session-state").join(id); + std::fs::create_dir_all(&dir).unwrap(); + let start = format!( + r#"{{"type":"session.start","timestamp":"2026-07-01T00:00:00Z","data":{{"copilotVersion":"1.0.67","context":{{"cwd":"{cwd}"}}}}}}"# + ); + let user = r#"{"type":"user.message","timestamp":"2026-07-01T00:00:01Z","data":{"content":"hi"}}"#; + std::fs::write(dir.join("events.jsonl"), format!("{start}\n{user}\n")).unwrap(); + let resolver = toolpath_copilot::PathResolver::new().with_copilot_dir(&copilot_dir); + HarnessBundle { + copilot: Some(toolpath_copilot::CopilotConvo::with_resolver(resolver)), + ..Default::default() + } + } + + #[test] + fn copilot_syncs_and_scopes_via_memoized_peek() { + with_cfg(|home| { + let bundle = copilot_bundle(home, "sess-cp", "/work/proj"); + + // Out-of-scope first: one peek, a known record with the cwd. + let (_, out) = sync_bundle( + &bundle, + &[ArtifactType::Copilot], + Some(Path::new("/elsewhere")), + ) + .unwrap()[0]; + assert_eq!((out.new, out.out_of_scope), (0, 1)); + let rec = load_manifest().unwrap()["copilot"]["sess-cp"].clone(); + assert_eq!(rec.path.as_deref(), Some("/work/proj")); + assert!(rec.cache_id.is_none()); + + // In scope: derives; then a plain re-sync is a no-op. + let (_, hit) = + sync_bundle(&bundle, &[ArtifactType::Copilot], Some(Path::new("/work"))) + .unwrap()[0]; + assert_eq!((hit.updated, hit.out_of_scope), (1, 0)); + let (_, again) = sync_bundle(&bundle, &[ArtifactType::Copilot], None).unwrap()[0]; + assert_eq!(again.unchanged, 1); + }); + } + #[test] fn evicted_cache_entry_rematerializes_on_next_sync() { with_cfg(|home| { diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index faed228a..ce0139b2 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -118,7 +118,7 @@ fn file_input_explicit_copilot_projects_and_records_exec() { let recorder = RecordingExec::default(); run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Copilot), + args_explicit(doc_file, cwd.path(), Harness::Copilot), &recorder, ) .unwrap(); From 69137d37afb38e888d8326626d343fc32fdc1ab7 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Mon, 13 Jul 2026 12:41:10 -0400 Subject: [PATCH 15/20] feat(cli): share uploads from cache when the source is unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync::fresh_cache_id answers "is this artifact's cached doc current?" — a fresh stat matches the manifest stamp and the doc file exists — using the same stub enumeration sync itself trusts. share_explicit checks it before deriving: on a hit it uploads the cached JSON as-is (a derive would reproduce the same bytes) and says so on stderr; a grown session or an evicted doc steps around the fast path and derives as before. --no-cache skips the check entirely. --- CHANGELOG.md | 4 ++ CLAUDE.md | 4 +- crates/path-cli/src/cmd_share.rs | 24 ++++++++++++ crates/path-cli/src/sync.rs | 53 +++++++++++++++++++++++++++ crates/path-cli/tests/integration.rs | 55 ++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 335bc72b..11da7ea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,10 @@ users no longer have to `p import` each session by hand. `session-state//events.jsonl`, cwd peeked from `session.start` and memoized for `--parent-dir`), per-session import loops with provenance, and `path query --source copilot` auto-sync. + - `share` uploads straight from the cache when the picked session is + unchanged since its last sync (manifest stamp matches a fresh stat + and the doc exists) — re-deriving would reproduce the same bytes. + A grown session steps around the fast path and derives as before. - **`toolpath-gemini`** (0.6.1): new `PathResolver::list_session_entries` returns each session's listing id, inner `sessionId`, and backing file/dir path, and `peek_session_id` is now bounded — it scans the diff --git a/CLAUDE.md b/CLAUDE.md index ad2bd8d8..3e5aaa04 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,7 +212,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-cursor`: 78 unit + 8 integration round-trip + 1 real-DB sanity + 1 doc test (state.vscdb SQLite reader, bubble store + composer header parsing, content-addressed blob lookup, projector with full TOOL_TABLE coverage, JSONL transcript ingest in `examples/dump_fixture.rs`) - `toolpath-pi`: 133 unit + 26 integration + 5 doc tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `path-cli`: 350 unit + 117 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. +- `path-cli`: 351 unit + 118 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. - `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary) Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- p validate --input "$f"; done` @@ -279,7 +279,7 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - Format references for the agent on-disk formats we derive from live at `docs/agents/formats/`. The Claude Code format (`~/.claude/projects/…` JSONL) gets the deepest treatment — twelve focused docs at `docs/agents/formats/claude-code/` covering envelope, entry types, tools, session chains, compaction, writing-compatible JSONL, a linear walkthrough, and a version-keyed changelog. Sibling single-file references: `codex.md`, `gemini.md`, `opencode.md`. Keep them in sync with their derive crates when fields or behaviors change. - Interactive session selection: `path p import ` (claude / gemini / pi / codex / opencode) auto-launches a fuzzy picker when stdin and stderr are TTYs and no `--session` was given. Backend: external `fzf` if on `$PATH`, otherwise the embedded skim picker (default-feature `embedded-picker`, defined in `crates/path-cli/src/skim_picker.rs`). Multi-select (TAB) produces a `Graph` document; single-select produces a `Path`. The picker uses `path show --…` as its `--preview` command. When neither backend can run (no TTY, or `--no-default-features` AND no `fzf`), it falls back to most-recent (with `--project`) or prints the manual recipe (without). `path p list --format tsv` is the documented machine-readable surface — column 1 is the project (for claude/gemini/pi) or session id (for codex/opencode), and the trailing column carries `first_user_message` so consumers can fuzzy-match by topic. - Conversation metadata title field: `toolpath-claude::ConversationMetadata`, `toolpath-gemini::ConversationMetadata`, and `toolpath-pi::SessionMeta` all expose `first_user_message: Option` — the first non-empty user-prompt text. Populated cheaply during the metadata pass (single-pass for Claude/Gemini; one extra short read for Pi). Used by the picker UI but useful for any "list sessions by topic" surface. -- `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. +- `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. When the manifest shows the picked session unchanged since its last sync (`sync::fresh_cache_id`: stamps match, doc present), share uploads the cached doc directly instead of re-deriving. - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. - Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs every artifact type. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: head segment via `list_conversations`' bounded first-lines peek; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek; copilot: `session-state//events.jsonl`, pure read-dir + stat) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record's `cache_id` is *optional*: a record without one is "known, not materialized" — created when a `--parent-dir` constraint excluded a peeked artifact, or when `p cache rm` evicts a doc (rm downgrades the record; the next in-scope sync re-materializes it, and sync also verifies the doc file actually exists before skipping, so even out-of-band deletions self-heal). `--parent-dir ` / `-d` on both `p cache sync` and `path query` restricts ingestion to artifacts under that directory (subtree): path-keyed providers prune whole projects before enumerating (claude compares in *slug space* — its dir slugs are lossy, `/`/`_`/`.` all became `-`), cwd-keyed ones check the directory their cheap headers carry, and codex/copilot — whose cwd lives inside the session file — get a one-line peek only when new/changed, memoized into the record's `path`. The stat gate always runs first: unchanged+cached artifacts skip before any scope check. Out-of-scope work is tallied separately (`N out of scope`) and never touches a materialized record's stamp. Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's own recorded cwd rather than the lossy slug. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactStub` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_stub` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index 9b76f810..98cb9b54 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -948,6 +948,30 @@ fn share_explicit( (false, _) => None, }; + // Fast path: when the manifest shows this exact source state is + // already in the cache, upload the cached doc instead of re-deriving + // — a derive would reproduce it byte-for-byte anyway. + if !args.no_cache + && let Some(cache_id) = + crate::sync::fresh_cache_id(&HarnessBundle::from_environment(), harness, session) + { + let doc_path = crate::cmd_cache::cache_path(&cache_id)?; + let body = std::fs::read_to_string(&doc_path)?; + eprintln!( + "Cache is current for {} session {cache_id}; uploading without re-deriving", + harness.name() + ); + let summary = format!("{} session {}", harness.name(), cache_id); + let upload = crate::cmd_export::PathbaseUploadArgs { + url: args.url.clone(), + anon: args.anon, + repo: args.repo.clone(), + name: args.name.clone(), + public: args.public, + }; + return crate::cmd_export::run_pathbase_inner(auth, base_url, upload, &body, &summary); + } + let derived = derive_session(harness, project.as_deref(), session)?; let summary = format!("{} session {}", harness.name(), derived.cache_id); diff --git a/crates/path-cli/src/sync.rs b/crates/path-cli/src/sync.rs index d3ca14b0..b862584e 100644 --- a/crates/path-cli/src/sync.rs +++ b/crates/path-cli/src/sync.rs @@ -822,6 +822,27 @@ mod engine { save_manifest(&manifest) } + /// The cache entry for an artifact, when the manifest says it is + /// materialized and a fresh stat shows its source unchanged since — + /// i.e. re-deriving would reproduce the cached doc byte-for-byte. + /// Used by `share` to upload straight from the cache. + pub(crate) fn fresh_cache_id( + bundle: &HarnessBundle, + artifact_type: ArtifactType, + id: &str, + ) -> Option { + let stub = enumerate_stubs(bundle, artifact_type, None) + .into_iter() + .find(|s| s.id == id)?; + let manifest = load_manifest().ok()?; + let rec = manifest.get(artifact_type.name())?.get(id)?; + let cache_id = rec.cache_id.clone()?; + (rec.modified == stub.modified + && rec.size == stub.size + && crate::cmd_cache::cache_path(&cache_id).is_ok_and(|p| p.exists())) + .then_some(cache_id) + } + /// `p cache rm` eviction: the doc is gone, so any record pointing /// at it downgrades to known-but-uncached (the artifact itself is /// still real; the next in-scope sync re-materializes it). @@ -1355,6 +1376,38 @@ mod engine { }); } + #[test] + fn fresh_cache_id_tracks_source_and_eviction() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + + // Nothing synced yet: no fresh copy. + assert!(fresh_cache_id(&bundle, ArtifactType::Claude, "sess-aaa").is_none()); + + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); + let cache_id = fresh_cache_id(&bundle, ArtifactType::Claude, "sess-aaa") + .expect("synced artifact is fresh"); + + // Source grows: stale until re-synced. + let file = home.join(".claude/projects/-test-project/sess-aaa.jsonl"); + let mut body = std::fs::read_to_string(&file).unwrap(); + body.push_str( + r#"{"type":"user","uuid":"u-2","timestamp":"2024-01-02T00:05:00Z","cwd":"/test/project","message":{"role":"user","content":"more"}}"#, + ); + body.push('\n'); + std::fs::write(&file, body).unwrap(); + assert!(fresh_cache_id(&bundle, ArtifactType::Claude, "sess-aaa").is_none()); + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); + assert!(fresh_cache_id(&bundle, ArtifactType::Claude, "sess-aaa").is_some()); + + // Evicted: known but not materialized, so not fresh. + crate::cmd_cache::remove_cached(&cache_id).unwrap(); + evict_cache_id(&cache_id).unwrap(); + assert!(fresh_cache_id(&bundle, ArtifactType::Claude, "sess-aaa").is_none()); + }); + } + #[test] fn derive_stub_errors_when_provider_missing() { let bundle = HarnessBundle::default(); diff --git a/crates/path-cli/tests/integration.rs b/crates/path-cli/tests/integration.rs index aa2e6651..516586a5 100644 --- a/crates/path-cli/tests/integration.rs +++ b/crates/path-cli/tests/integration.rs @@ -927,6 +927,61 @@ fn cache_sync_parent_dir_limits_ingestion() { .stderr(predicate::str::contains("1 new, 0 updated, 0 unchanged")); } +#[test] +fn share_uploads_cached_doc_when_source_unchanged() { + let (home, session_file) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + let share = |port: u16| { + let mut c = cmd(); + c.env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args([ + "share", + "--harness", + "claude", + "--session", + "session-abc", + "--project", + ]) + .arg(&project) + .args(["--anon", "--url"]) + .arg(format!("http://127.0.0.1:{port}")); + c + }; + + // First share derives + records. + let (port, server) = one_shot_anon_server(); + share(port) + .assert() + .success() + .stderr(predicate::str::contains("uploading without re-deriving").not()); + server.join().unwrap(); + + // Unchanged source: the second share must not re-derive. + let (port, server) = one_shot_anon_server(); + share(port) + .assert() + .success() + .stderr(predicate::str::contains("uploading without re-deriving")); + server.join().unwrap(); + + // The session grows: the fast path steps aside and share re-derives. + let mut body = std::fs::read_to_string(&session_file).unwrap(); + body.push_str( + r#"{"type":"user","uuid":"u-2","timestamp":"2024-01-01T00:05:00Z","cwd":"/x","message":{"role":"user","content":"more"}}"#, + ); + body.push('\n'); + std::fs::write(&session_file, body).unwrap(); + let (port, server) = one_shot_anon_server(); + share(port) + .assert() + .success() + .stderr(predicate::str::contains("uploading without re-deriving").not()) + .stderr(predicate::str::contains("Cached claude session")); + server.join().unwrap(); +} + #[test] fn cache_sync_rejects_unknown_type() { let cfg = tempfile::tempdir().unwrap(); From 5324d934b5304b6ff7c45d554883eaa21cb8d3ac Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Mon, 13 Jul 2026 13:33:55 -0400 Subject: [PATCH 16/20] =?UTF-8?q?fix(cli):=20apply=20review=20round=20?= =?UTF-8?q?=E2=80=94=20maximal=20ingest,=20strip=20at=20egress,=209=20more?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cache is the archive and now always holds the fullest derivation: claude and gemini ingest thinking unconditionally (breaking: p import gemini loses --include-thinking), while the privacy decision moves to the egress surfaces — share and p export pathbase strip thinking from uploads by default with --include-thinking to opt in there. Local projection keeps everything, so resume fidelity improves. This also dissolves the review's top finding: sync can no longer clobber a flag-derived doc because per-doc derive variance no longer exists. The other nine review findings: - p import --all warns-and-skips unreadable sessions again (all five per-session providers) instead of aborting the batch - importing an artifact the implicit query-sync already cached is a no-op with a friendly message, gated on real (non-None) stamps so git re-imports still honor error-on-hit - pi project scoping compares in its dir-encoded space, so hyphenated real paths match --parent-dir (mirror of claude's slug-space fix) - the copilot cwd peek scans a bounded number of lines and accepts top-level cwd keys, matching toolpath-copilot's own tolerance - a derive no longer clobbers the memoized peeked cwd in the record - claude sessions with no recorded cwd fall back to the caller's project for path.base instead of deriving baseless - gemini's peek runs the serde fallback even when the prefix scan declined on a small file (sub-agent dedup stays correct) - the toolpath-pi workspace pin returns to 0.6.1 (rebase damage) - sed-mangled doc comments and the stale six-harness count fixed --- CHANGELOG.md | 18 +++ CLAUDE.md | 6 +- Cargo.toml | 2 +- crates/path-cli/src/cmd_export.rs | 82 +++++++++++++- crates/path-cli/src/cmd_import.rs | 100 ++++++++++------- crates/path-cli/src/cmd_share.rs | 20 +++- crates/path-cli/src/sync.rs | 158 +++++++++++++++++++++++---- crates/path-cli/tests/integration.rs | 91 +++++++++++++++ crates/toolpath-gemini/src/paths.rs | 19 +++- 9 files changed, 423 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11da7ea4..ff280cce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,24 @@ users no longer have to `p import` each session by hand. unchanged since its last sync (manifest stamp matches a fresh stat and the doc exists) — re-deriving would reproduce the same bytes. A grown session steps around the fast path and derives as before. + - **Maximal ingest, strip at egress**: the cache always holds the + fullest derivation — thinking blocks are ingested for claude and + gemini unconditionally (**breaking**: `p import gemini` loses its + `--include-thinking` flag), and the privacy decision moves to where + it belongs: `share` and `p export pathbase` strip thinking from + uploads by default, with `--include-thinking` there to opt in. The + local cache and `resume` fidelity keep everything. + - Review fixes: `p import --all` warns-and-skips unreadable + sessions again instead of aborting the batch; importing an artifact + the implicit query-sync already cached is a friendly no-op instead + of an exists-error; pi project scoping compares in its dir-encoded + space so hyphenated paths match `--parent-dir`; the copilot cwd + peek tolerates `session.start` anywhere in the first lines and + top-level cwd keys; a derive no longer clobbers the memoized peeked + cwd; claude sessions with no recorded cwd fall back to the caller's + `--project` for `path.base`; a small-file gemini peek that declines + in the prefix now still runs the full-parse fallback; and a + rebase-dropped `toolpath-pi` workspace pin is back at 0.6.1. - **`toolpath-gemini`** (0.6.1): new `PathResolver::list_session_entries` returns each session's listing id, inner `sessionId`, and backing file/dir path, and `peek_session_id` is now bounded — it scans the diff --git a/CLAUDE.md b/CLAUDE.md index 3e5aaa04..93eed94e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,7 +212,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-cursor`: 78 unit + 8 integration round-trip + 1 real-DB sanity + 1 doc test (state.vscdb SQLite reader, bubble store + composer header parsing, content-addressed blob lookup, projector with full TOOL_TABLE coverage, JSONL transcript ingest in `examples/dump_fixture.rs`) - `toolpath-pi`: 133 unit + 26 integration + 5 doc tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `path-cli`: 351 unit + 118 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. +- `path-cli`: 354 unit + 121 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. - `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary) Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- p validate --input "$f"; done` @@ -279,8 +279,8 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - Format references for the agent on-disk formats we derive from live at `docs/agents/formats/`. The Claude Code format (`~/.claude/projects/…` JSONL) gets the deepest treatment — twelve focused docs at `docs/agents/formats/claude-code/` covering envelope, entry types, tools, session chains, compaction, writing-compatible JSONL, a linear walkthrough, and a version-keyed changelog. Sibling single-file references: `codex.md`, `gemini.md`, `opencode.md`. Keep them in sync with their derive crates when fields or behaviors change. - Interactive session selection: `path p import ` (claude / gemini / pi / codex / opencode) auto-launches a fuzzy picker when stdin and stderr are TTYs and no `--session` was given. Backend: external `fzf` if on `$PATH`, otherwise the embedded skim picker (default-feature `embedded-picker`, defined in `crates/path-cli/src/skim_picker.rs`). Multi-select (TAB) produces a `Graph` document; single-select produces a `Path`. The picker uses `path show --…` as its `--preview` command. When neither backend can run (no TTY, or `--no-default-features` AND no `fzf`), it falls back to most-recent (with `--project`) or prints the manual recipe (without). `path p list --format tsv` is the documented machine-readable surface — column 1 is the project (for claude/gemini/pi) or session id (for codex/opencode), and the trailing column carries `first_user_message` so consumers can fuzzy-match by topic. - Conversation metadata title field: `toolpath-claude::ConversationMetadata`, `toolpath-gemini::ConversationMetadata`, and `toolpath-pi::SessionMeta` all expose `first_user_message: Option` — the first non-empty user-prompt text. Populated cheaply during the metadata pass (single-pass for Claude/Gemini; one extra short read for Pi). Used by the picker UI but useful for any "list sessions by topic" surface. -- `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. When the manifest shows the picked session unchanged since its last sync (`sync::fresh_cache_id`: stamps match, doc present), share uploads the cached doc directly instead of re-deriving. +- `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. When the manifest shows the picked session unchanged since its last sync (`sync::fresh_cache_id`: stamps match, doc present), share uploads the cached doc directly instead of re-deriving. The cache ingests maximally (thinking always included); `share` and `p export pathbase` strip thinking from uploads by default (`--include-thinking` opts in) — local projection (`resume`, `p export `) keeps everything. - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. - Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs every artifact type. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: head segment via `list_conversations`' bounded first-lines peek; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek; copilot: `session-state//events.jsonl`, pure read-dir + stat) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record's `cache_id` is *optional*: a record without one is "known, not materialized" — created when a `--parent-dir` constraint excluded a peeked artifact, or when `p cache rm` evicts a doc (rm downgrades the record; the next in-scope sync re-materializes it, and sync also verifies the doc file actually exists before skipping, so even out-of-band deletions self-heal). `--parent-dir ` / `-d` on both `p cache sync` and `path query` restricts ingestion to artifacts under that directory (subtree): path-keyed providers prune whole projects before enumerating (claude compares in *slug space* — its dir slugs are lossy, `/`/`_`/`.` all became `-`), cwd-keyed ones check the directory their cheap headers carry, and codex/copilot — whose cwd lives inside the session file — get a one-line peek only when new/changed, memoized into the record's `path`. The stat gate always runs first: unchanged+cached artifacts skip before any scope check. Out-of-scope work is tallied separately (`N out of scope`) and never touches a materialized record's stamp. Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's own recorded cwd rather than the lossy slug. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactStub` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_stub` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. -- `ArtifactType` (`crates/path-cli/src/sync.rs`) is the general enum naming artifact sources — the seven agent harnesses (incl. copilot) plus `Git` (8 variants). Git artifacts are *recorded* in the manifest by `p import git` (id `-`, `path` = the repo directory) but never *discovered* — there is no machine-wide registry of repos — so sync reports them and leaves them alone. Github and pathbase are deliberately not artifact types: they are remote services, not local artifact sources, and their imports stay out of the manifest. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`cmd_share.rs`) names the six agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. +- `ArtifactType` (`crates/path-cli/src/sync.rs`) is the general enum naming artifact sources — the seven agent harnesses (incl. copilot) plus `Git` (8 variants). Git artifacts are *recorded* in the manifest by `p import git` (id `-`, `path` = the repo directory) but never *discovered* — there is no machine-wide registry of repos — so sync reports them and leaves them alone. Github and pathbase are deliberately not artifact types: they are remote services, not local artifact sources, and their imports stay out of the manifest. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`cmd_share.rs`) names the seven agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/Cargo.toml b/Cargo.toml index 8f9cfb9e..12107ac1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ toolpath-cursor = { version = "0.2.0", path = "crates/toolpath-cursor" } toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" } toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" } toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" } -toolpath-pi = { version = "0.6.0", path = "crates/toolpath-pi" } +toolpath-pi = { version = "0.6.1", path = "crates/toolpath-pi" } path-cli = { version = "0.16.0", path = "crates/path-cli" } pathbase-client = { version = "0.2.0", path = "crates/pathbase-client" } diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index dfc82d2b..951e8916 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -192,6 +192,10 @@ pub enum ExportTarget { /// Mark the uploaded graph public (default: unlisted, addressable only by UUID) #[arg(long)] public: bool, + + /// Keep thinking blocks in the uploaded document (stripped by default) + #[arg(long)] + include_thinking: bool, }, } @@ -259,6 +263,7 @@ pub fn run(target: ExportTarget) -> Result<()> { repo, name, public, + include_thinking, } => run_pathbase(PathbaseExportArgs { input, url, @@ -266,6 +271,7 @@ pub fn run(target: ExportTarget) -> Result<()> { repo, name, public, + include_thinking, }), } } @@ -278,6 +284,7 @@ struct PathbaseExportArgs { repo: Option, name: Option, public: bool, + include_thinking: bool, } /// Pathbase upload knobs that don't depend on where the body came from. @@ -1852,6 +1859,27 @@ fn write_cursor_to_stdout(session: &toolpath_cursor::CursorSession) -> Result<() // ── Pathbase ────────────────────────────────────────────────────────── +/// Remove thinking text from every step's structural extras — the +/// egress inverse of the maximal ingest (the cache always derives with +/// thinking included; documents leaving the machine drop it unless the +/// caller opts in). +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn strip_thinking(graph: &mut toolpath::v1::Graph) { + use toolpath::v1::PathOrRef; + for entry in &mut graph.paths { + let PathOrRef::Path(path) = entry else { + continue; + }; + for step in &mut path.steps { + for change in step.change.values_mut() { + if let Some(structural) = &mut change.structural { + structural.extra.remove("thinking"); + } + } + } + } +} + fn run_pathbase(args: PathbaseExportArgs) -> Result<()> { #[cfg(target_os = "emscripten")] { @@ -1864,8 +1892,11 @@ fn run_pathbase(args: PathbaseExportArgs) -> Result<()> { use crate::cmd_pathbase::preflight_auth; let file = cache_ref(&args.input)?; - let body = std::fs::read_to_string(&file) - .with_context(|| format!("Failed to read {}", file.display()))?; + let mut graph = crate::io::read_document_auto(&file)?; + if !args.include_thinking { + strip_thinking(&mut graph); + } + let body = graph.to_json()?; let upload = PathbaseUploadArgs { url: args.url, anon: args.anon, @@ -2031,6 +2062,52 @@ mod tests { use std::collections::HashMap; use toolpath::v1::{ArtifactChange, PathIdentity, Step, StepIdentity, StructuralChange}; + #[test] + fn strip_thinking_removes_only_thinking_extras() { + let mut extra = HashMap::new(); + extra.insert("text".to_string(), serde_json::json!("hello")); + extra.insert( + "thinking".to_string(), + serde_json::json!("secret reasoning"), + ); + let step = Step { + step: StepIdentity { + id: "s1".into(), + parents: vec![], + actor: "agent:claude-code".into(), + timestamp: "2026-01-01T00:00:00Z".into(), + }, + change: HashMap::from([( + "conversation://x".to_string(), + ArtifactChange { + raw: None, + structural: Some(StructuralChange { + change_type: "conversation.append".into(), + extra, + }), + }, + )]), + meta: None, + }; + let path = toolpath::v1::Path { + path: PathIdentity { + id: "p1".into(), + base: None, + head: "s1".into(), + graph_ref: None, + }, + steps: vec![step], + meta: None, + }; + let mut graph = toolpath::v1::Graph::from_path(path); + + strip_thinking(&mut graph); + + let json = graph.to_json().unwrap(); + assert!(!json.contains("secret reasoning")); + assert!(json.contains("hello"), "non-thinking extras survive"); + } + fn make_path_doc() -> toolpath::v1::Graph { let artifact_key = "agent://claude/test-session"; @@ -2859,6 +2936,7 @@ mod tests { std::env::set_var(crate::config::CONFIG_DIR_ENV, temp.path()); } let err = run_pathbase(PathbaseExportArgs { + include_thinking: false, input: input_path.to_string_lossy().to_string(), url: Some("http://127.0.0.1:1".to_string()), anon: false, diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index f337952b..47c1cb20 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -90,10 +90,6 @@ pub enum ImportSource { /// Process all sessions in the project #[arg(long)] all: bool, - - /// Include thinking blocks in conversation.append text - #[arg(long)] - include_thinking: bool, }, /// Import from Codex CLI rollout files Codex { @@ -223,6 +219,21 @@ fn emit(docs: &[DerivedDoc], force: bool, no_cache: bool, pretty: bool) -> Resul }; println!("{}", json); } else { + // The implicit sync in `path query` fills the cache under + // these same ids; re-importing an artifact whose record is + // still fresh is a no-op, not an exists-error. + #[cfg(not(target_os = "emscripten"))] + if !force + && let Some(stub) = &d.provenance + && crate::sync::record_is_current(stub, &d.cache_id) + { + println!("{}", crate::cmd_cache::cache_path(&d.cache_id)?.display()); + eprintln!( + "{} is already up to date (pass --force to re-derive)", + d.cache_id + ); + continue; + } let path = write_cached(&d.cache_id, &d.doc, force)?; println!("{}", path.display()); #[cfg(not(target_os = "emscripten"))] @@ -271,8 +282,7 @@ fn derive(source: ImportSource) -> Result> { project, session, all, - include_thinking, - } => derive_gemini(project, session, all, include_thinking), + } => derive_gemini(project, session, all), ImportSource::Codex { session, all } => derive_codex(session, all), ImportSource::Copilot { session, all } => derive_copilot(session, all), ImportSource::Opencode { @@ -444,7 +454,10 @@ fn derive_claude_with_manager( .map_err(|e| anyhow::anyhow!("{}", e))?; let mut docs = Vec::with_capacity(heads.len()); for head in &heads { - docs.push(derive_claude_session_with(manager, &p, head)?); + match derive_claude_session_with(manager, &p, head) { + Ok(doc) => docs.push(doc), + Err(e) => eprintln!("Warning: skipping session {head}: {e}"), + } } return Ok(docs); } @@ -532,14 +545,25 @@ pub(crate) fn derive_claude_session_with( // The caller's project string often comes from claude's lossy dir // slugs ('/', '_', '.' all collapsed); leaving it out of the derive // lets path.base come from the session's own recorded cwd instead. + // The cache is the archive: always derive maximally (thinking + // included) — egress surfaces strip on their way out. let cfg = toolpath_claude::derive::DeriveConfig { project_path: None, - include_thinking: false, + include_thinking: true, }; let convo = manager .read_conversation(project, session) .map_err(|e| anyhow::anyhow!("{}", e))?; - let path = toolpath_claude::derive::derive_path(&convo, &cfg); + let mut path = toolpath_claude::derive::derive_path(&convo, &cfg); + // Sessions whose entries carry no cwd would otherwise derive with no + // base at all — fall back to the caller's project for those. + if path.path.base.is_none() && project.starts_with('/') { + path.path.base = Some(toolpath::v1::Base { + uri: format!("file://{project}"), + ref_str: None, + branch: None, + }); + } let cache_id = make_id(ArtifactType::Claude.name(), &path.path.id); Ok(DerivedDoc { cache_id, @@ -660,10 +684,9 @@ fn derive_gemini( project: Option, session: Option, all: bool, - include_thinking: bool, ) -> Result> { let manager = toolpath_gemini::GeminiConvo::new(); - derive_gemini_with_manager(&manager, project, session, all, include_thinking) + derive_gemini_with_manager(&manager, project, session, all) } fn derive_gemini_with_manager( @@ -671,7 +694,6 @@ fn derive_gemini_with_manager( project: Option, session: Option, all: bool, - include_thinking: bool, ) -> Result> { let pairs: Vec<(String, String)> = match (project, session, all) { (Some(p), Some(s), _) => vec![(p, s)], @@ -681,12 +703,10 @@ fn derive_gemini_with_manager( .map_err(|e| anyhow::anyhow!("{}", e))?; let mut docs = Vec::with_capacity(ids.len()); for id in &ids { - docs.push(derive_gemini_session_with( - manager, - &p, - id, - include_thinking, - )?); + match derive_gemini_session_with(manager, &p, id) { + Ok(doc) => docs.push(doc), + Err(e) => eprintln!("Warning: skipping session {id}: {e}"), + } } return Ok(docs); } @@ -706,7 +726,6 @@ fn derive_gemini_with_manager( manager, &p, &convo.session_uuid, - include_thinking, )?]); } } @@ -720,7 +739,6 @@ fn derive_gemini_with_manager( manager, &p, &convo.session_uuid, - include_thinking, )?]); } } @@ -748,24 +766,14 @@ fn derive_gemini_with_manager( manager, project_path, session_uuid, - include_thinking, )?); } Ok(docs) } /// Derive a single Gemini conversation given an explicit project + session. -pub(crate) fn derive_gemini_session( - project: &str, - session: &str, - include_thinking: bool, -) -> Result { - derive_gemini_session_with( - &toolpath_gemini::GeminiConvo::new(), - project, - session, - include_thinking, - ) +pub(crate) fn derive_gemini_session(project: &str, session: &str) -> Result { + derive_gemini_session_with(&toolpath_gemini::GeminiConvo::new(), project, session) } /// [`derive_gemini_session`] against a caller-supplied manager. @@ -773,7 +781,6 @@ pub(crate) fn derive_gemini_session_with( manager: &toolpath_gemini::GeminiConvo, project: &str, session: &str, - include_thinking: bool, ) -> Result { let entry = manager .resolver() @@ -791,9 +798,11 @@ pub(crate) fn derive_gemini_session_with( ), None => (session.to_string(), (None, None)), }; + // Maximal ingest: thinking is always derived into the cache; egress + // surfaces strip it on the way out. let cfg = toolpath_gemini::derive::DeriveConfig { project_path: Some(project.to_string()), - include_thinking, + include_thinking: true, }; let convo = manager .read_conversation(project, session) @@ -931,10 +940,11 @@ fn derive_codex(session: Option, all: bool) -> Result> { let Some(stem) = file.file_stem().and_then(|s| s.to_str()) else { continue; }; - docs.push(derive_codex_session_with( - &manager, - codex_artifact_id(stem), - )?); + let id = codex_artifact_id(stem); + match derive_codex_session_with(&manager, id) { + Ok(doc) => docs.push(doc), + Err(e) => eprintln!("Warning: skipping session {id}: {e}"), + } } return Ok(docs); } @@ -1067,7 +1077,14 @@ fn derive_copilot(session: Option, all: bool) -> Result> if metas.is_empty() { anyhow::bail!("No Copilot sessions found in ~/.copilot/session-state"); } - metas.into_iter().map(|m| m.id).collect() + let mut docs = Vec::with_capacity(metas.len()); + for m in &metas { + match derive_copilot_session_with(&manager, &m.id) { + Ok(doc) => docs.push(doc), + Err(e) => eprintln!("Warning: skipping session {}: {e}", m.id), + } + } + return Ok(docs); } (None, false) => { #[cfg(not(target_os = "emscripten"))] @@ -1565,7 +1582,10 @@ fn derive_pi_with_manager( } let mut docs = Vec::with_capacity(metas.len()); for m in &metas { - docs.push(derive_pi_session_with(manager, &p, &m.id)?); + match derive_pi_session_with(manager, &p, &m.id) { + Ok(doc) => docs.push(doc), + Err(e) => eprintln!("Warning: skipping session {}: {e}", m.id), + } } return Ok(docs); } diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index 98cb9b54..8ca2e93e 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -123,6 +123,11 @@ pub struct ShareArgs { /// Skip writing the cache; derive in-memory only #[arg(long)] pub no_cache: bool, + + /// Keep thinking blocks in the uploaded document (stripped by + /// default; the local cache always keeps them) + #[arg(long)] + pub include_thinking: bool, } /// One artifact surfaced by a provider — today always an agent session. @@ -726,6 +731,7 @@ pub fn run(args: ShareArgs) -> Result<()> { None }, no_cache: args.no_cache, + include_thinking: args.include_thinking, }; // Show the conversation title in the confirmation line; the session id // is opaque and doesn't help the user verify they picked the right @@ -956,7 +962,11 @@ fn share_explicit( crate::sync::fresh_cache_id(&HarnessBundle::from_environment(), harness, session) { let doc_path = crate::cmd_cache::cache_path(&cache_id)?; - let body = std::fs::read_to_string(&doc_path)?; + let mut graph = crate::io::read_document_auto(&doc_path)?; + if !args.include_thinking { + crate::cmd_export::strip_thinking(&mut graph); + } + let body = graph.to_json()?; eprintln!( "Cache is current for {} session {cache_id}; uploading without re-deriving", harness.name() @@ -997,7 +1007,11 @@ fn share_explicit( ); } - let body = derived.doc.to_json()?; + let mut doc = derived.doc; + if !args.include_thinking { + crate::cmd_export::strip_thinking(&mut doc); + } + let body = doc.to_json()?; let upload = crate::cmd_export::PathbaseUploadArgs { url: args.url.clone(), anon: args.anon, @@ -1074,7 +1088,7 @@ fn derive_session( crate::cmd_import::derive_claude_session(project.expect("path_keyed"), session) } ArtifactType::Gemini => { - crate::cmd_import::derive_gemini_session(project.expect("path_keyed"), session, false) + crate::cmd_import::derive_gemini_session(project.expect("path_keyed"), session) } ArtifactType::Copilot => crate::cmd_import::derive_copilot_session(session), ArtifactType::Pi => { diff --git a/crates/path-cli/src/sync.rs b/crates/path-cli/src/sync.rs index b862584e..860f4108 100644 --- a/crates/path-cli/src/sync.rs +++ b/crates/path-cli/src/sync.rs @@ -74,7 +74,7 @@ impl ArtifactType { } } - /// True when the parent_dirlying provider keys artifacts by a filesystem + /// True when the underlying provider keys artifacts by a filesystem /// path (the project directory). claude/gemini/pi: true. /// codex/opencode/cursor: false (sessions store cwd per-row, not as /// a directory key — cursor stores it as @@ -110,7 +110,7 @@ impl ArtifactType { pub(crate) struct ArtifactStub { pub(crate) artifact_type: ArtifactType, pub(crate) id: String, - /// Filesystem path the artifact is keyed parent_dir, for path-keyed + /// Filesystem path the artifact is keyed under, for path-keyed /// providers (the project directory; the repo for git). pub(crate) path: Option, /// Source mtime (file providers) or updated-at (DB providers). @@ -173,7 +173,7 @@ mod engine { /// `p cache rm`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub(crate) struct SyncRecord { - /// Filesystem path the artifact is keyed parent_dir: the project + /// Filesystem path the artifact is keyed under: the project /// directory for path-keyed providers, the recorded cwd / /// workspace for the others (when known). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -297,9 +297,11 @@ mod engine { .or_else(|| existing.and_then(|r| r.path.clone())) .or_else(|| peek_stub_dir(bundle, stub)); let in_scope = dir.as_deref().is_some_and(|d| match stub.artifact_type { - // Claude paths came from lossy dir slugs; compare in - // slug space like the enumeration pruning does. + // Claude and pi paths came from lossy dir encodings; + // compare in their encoded spaces, like the + // enumeration pruning does. ArtifactType::Claude => claude_project_in_scope(d, parent_dir), + ArtifactType::Pi => pi_project_in_scope(d, parent_dir), _ => dir_in_scope(d, parent_dir), }); if !in_scope { @@ -322,6 +324,12 @@ mod engine { continue; } } + // A stub without a path must not erase one a previous pass + // peeked and memoized (codex/copilot cwd). + let memoized_path = stub + .path + .clone() + .or_else(|| records.get(&stub.id).and_then(|r| r.path.clone())); match derive_stub(bundle, stub) { Ok(derived) => { // force: sync owns refresh semantics — a re-sync or a @@ -331,7 +339,7 @@ mod engine { records.insert( stub.id.clone(), SyncRecord { - path: stub.path.clone(), + path: memoized_path, cache_id: Some(derived.cache_id), // The stamp was taken before the derive read the // source, so a write racing the derive re-syncs @@ -374,7 +382,7 @@ mod engine { imp::derive_claude_session_with(mgr(&bundle.claude)?, path()?, &stub.id) } ArtifactType::Gemini => { - imp::derive_gemini_session_with(mgr(&bundle.gemini)?, path()?, &stub.id, false) + imp::derive_gemini_session_with(mgr(&bundle.gemini)?, path()?, &stub.id) } ArtifactType::Pi => imp::derive_pi_session_with(mgr(&bundle.pi)?, path()?, &stub.id), ArtifactType::Codex => imp::derive_codex_session_with(mgr(&bundle.codex)?, &stub.id), @@ -408,6 +416,23 @@ mod engine { d.starts_with(canonicalize_or_self(parent_dir)) || d.starts_with(parent_dir) } + /// Pi's session-dir names encode '/' as '-', and decoding restores + /// every '-' to '/', so a decoded project string is wrong for any + /// real path containing a hyphen. Compare in the encoded space, + /// where '/' boundaries are '-'. + fn pi_project_in_scope(project: &str, parent_dir: &Path) -> bool { + fn enc(s: &str) -> String { + s.replace('/', "-") + } + let p = enc(project); + [ + enc(&parent_dir.to_string_lossy()), + enc(&canonicalize_or_self(parent_dir).to_string_lossy()), + ] + .iter() + .any(|parent| p == *parent || p.starts_with(&format!("{parent}-"))) + } + /// Claude's project-dir slugs are lossy — '/', '_', and '.' all /// became '-', and un-sanitizing only restores '/'. Comparing real /// paths therefore misfilters any project containing '.' or '_'; @@ -447,19 +472,32 @@ mod engine { }; use std::io::{BufRead, BufReader}; let f = std::fs::File::open(file).ok()?; - let mut line = String::new(); - BufReader::new(f).read_line(&mut line).ok()?; - let v: serde_json::Value = serde_json::from_str(&line).ok()?; match stub.artifact_type { - // Codex: `session_meta` payload carries cwd directly. - ArtifactType::Codex => Some(v.get("payload")?.get("cwd")?.as_str()?.to_string()), - // Copilot: `session.start` carries it under `context`, with - // some key-name variance across CLI versions. - ArtifactType::Copilot => ["data", "payload"].iter().find_map(|env| { - let ctx = v.get(env)?.get("context")?; - ["cwd", "workingDirectory", "working_dir"] - .iter() - .find_map(|k| Some(ctx.get(k)?.as_str()?.to_string())) + // Codex: the first line is `session_meta`; its payload + // carries cwd directly. + ArtifactType::Codex => { + let mut line = String::new(); + BufReader::new(f).read_line(&mut line).ok()?; + let v: serde_json::Value = serde_json::from_str(&line).ok()?; + Some(v.get("payload")?.get("cwd")?.as_str()?.to_string()) + } + // Copilot: `session.start` is usually first but the format + // tolerates it appearing later, and older CLIs store cwd at + // the top of the payload instead of under `context` — scan a + // bounded number of lines and accept both shapes, mirroring + // toolpath-copilot's own tolerance. + ArtifactType::Copilot => BufReader::new(f).lines().take(10).find_map(|line| { + let v: serde_json::Value = serde_json::from_str(&line.ok()?).ok()?; + ["data", "payload"].iter().find_map(|env| { + let payload = v.get(env)?; + [payload.get("context").unwrap_or(payload), payload] + .iter() + .find_map(|scope| { + ["cwd", "workingDirectory", "working_dir"] + .iter() + .find_map(|k| Some(scope.get(k)?.as_str()?.to_string())) + }) + }) }), _ => None, } @@ -736,7 +774,7 @@ mod engine { }; for project in projects { if let Some(parent_dir) = parent_dir - && !dir_in_scope(&project, parent_dir) + && !pi_project_in_scope(&project, parent_dir) { continue; } @@ -822,6 +860,27 @@ mod engine { save_manifest(&manifest) } + /// Whether the manifest already records exactly this artifact state + /// under exactly this cache entry, with the doc present — i.e. a + /// write would reproduce what's already there. + pub(crate) fn record_is_current(stub: &ArtifactStub, cache_id: &str) -> bool { + let Ok(manifest) = load_manifest() else { + return false; + }; + manifest + .get(stub.artifact_type.name()) + .and_then(|records| records.get(&stub.id)) + .is_some_and(|rec| { + rec.cache_id.as_deref() == Some(cache_id) + // None stamps mean freshness is unknowable (git); only + // a real, matching stamp can vouch for the cache entry. + && (rec.modified.is_some() || rec.size.is_some()) + && rec.modified == stub.modified + && rec.size == stub.size + && crate::cmd_cache::cache_path(cache_id).is_ok_and(|p| p.exists()) + }) + } + /// The cache entry for an artifact, when the manifest says it is /// materialized and a fresh stat shows its source unchanged since — /// i.e. re-deriving would reproduce the cached doc byte-for-byte. @@ -1280,6 +1339,11 @@ mod engine { let rec = load_manifest().unwrap()["codex"]["00000000-0000-0000-0000-0000000000aa"] .clone(); assert!(rec.cache_id.is_some(), "materialized now"); + assert_eq!( + rec.path.as_deref(), + Some("/work/proj"), + "deriving must not clobber the memoized peeked cwd" + ); }); } @@ -1408,6 +1472,60 @@ mod engine { }); } + #[test] + fn pi_scope_matching_survives_hyphenated_paths() { + // decode_project turns the dir-encoded '-' back into '/', so a + // real path /Users/b/my-repo arrives as /Users/b/my/repo; the + // comparator must match it against the real constraint anyway. + assert!(pi_project_in_scope( + "/Users/b/my/repo", + Path::new("/Users/b/my-repo") + )); + assert!(pi_project_in_scope( + "/Users/b/my/repo/sub", + Path::new("/Users/b/my-repo") + )); + assert!(!pi_project_in_scope( + "/Users/b/other", + Path::new("/Users/b/my-repo") + )); + } + + #[test] + fn copilot_peek_accepts_top_level_cwd() { + with_cfg(|home| { + // Older CLIs store cwd at the payload top level, no + // `context` object — the peek must still find it. + let copilot_dir = home.join(".copilot"); + let dir = copilot_dir.join("session-state").join("sess-legacy"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("events.jsonl"), + concat!( + r#"{"type":"session.start","data":{"cwd":"/work/proj"}}"#, + "\n", + r#"{"type":"user.message","data":{"content":"hi"}}"#, + "\n" + ), + ) + .unwrap(); + let resolver = toolpath_copilot::PathResolver::new().with_copilot_dir(&copilot_dir); + let bundle = HarnessBundle { + copilot: Some(toolpath_copilot::CopilotConvo::with_resolver(resolver)), + ..Default::default() + }; + let (_, out) = sync_bundle( + &bundle, + &[ArtifactType::Copilot], + Some(Path::new("/elsewhere")), + ) + .unwrap()[0]; + assert_eq!(out.out_of_scope, 1); + let rec = load_manifest().unwrap()["copilot"]["sess-legacy"].clone(); + assert_eq!(rec.path.as_deref(), Some("/work/proj")); + }); + } + #[test] fn derive_stub_errors_when_provider_missing() { let bundle = HarnessBundle::default(); diff --git a/crates/path-cli/tests/integration.rs b/crates/path-cli/tests/integration.rs index 516586a5..546b558f 100644 --- a/crates/path-cli/tests/integration.rs +++ b/crates/path-cli/tests/integration.rs @@ -982,6 +982,97 @@ fn share_uploads_cached_doc_when_source_unchanged() { server.join().unwrap(); } +#[test] +fn import_ingests_thinking_maximally() { + let (home, session_file) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + // Append an assistant turn with a thinking block. + let mut body = std::fs::read_to_string(&session_file).unwrap(); + body.push_str( + r#"{"type":"assistant","uuid":"a-2","timestamp":"2024-01-01T00:00:02Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"secret reasoning"},{"type":"text","text":"done"}]}}"#, + ); + body.push('\n'); + std::fs::write(&session_file, body).unwrap(); + + let out = cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args([ + "p", + "import", + "claude", + "--session", + "session-abc", + "--project", + ]) + .arg(&project) + .output() + .unwrap(); + assert!(out.status.success()); + let doc_path = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let doc = std::fs::read_to_string(&doc_path).unwrap(); + assert!( + doc.contains("secret reasoning"), + "the cache holds the maximal derivation, thinking included" + ); +} + +#[test] +fn import_after_query_sync_is_a_noop_not_an_error() { + let (home, _session_file) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + + // A bare query auto-syncs the session into the cache… + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["query", "--source", "claude", "length"]) + .assert() + .success(); + + // …and the documented explicit import must not die on the exists-check. + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args([ + "p", + "import", + "claude", + "--session", + "session-abc", + "--project", + ]) + .arg(&project) + .assert() + .success() + .stderr(predicate::str::contains("already up to date")); +} + +#[cfg(unix)] +#[test] +fn bulk_import_skips_unreadable_sessions() { + use std::os::unix::fs::PermissionsExt; + let (home, session_file) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + // A second session that cannot be read. + let bad = session_file.parent().unwrap().join("deadbeef-bad.jsonl"); + std::fs::write(&bad, "x").unwrap(); + std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o000)).unwrap(); + + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "import", "claude", "--all", "--project"]) + .arg(&project) + .assert() + .success() + .stderr(predicate::str::contains("Warning: skipping session")) + .stderr(predicate::str::contains("Imported")); +} + #[test] fn cache_sync_rejects_unknown_type() { let cfg = tempfile::tempdir().unwrap(); diff --git a/crates/toolpath-gemini/src/paths.rs b/crates/toolpath-gemini/src/paths.rs index cadcc43f..9ba93dbe 100644 --- a/crates/toolpath-gemini/src/paths.rs +++ b/crates/toolpath-gemini/src/paths.rs @@ -401,13 +401,12 @@ fn peek_session_id(path: &std::path::Path) -> Option { let file = fs::File::open(path).ok()?; let mut prefix = Vec::with_capacity(PEEK_BYTES); file.take(PEEK_BYTES as u64).read_to_end(&mut prefix).ok()?; - let whole_file = prefix.len() < PEEK_BYTES; if let Some(id) = prefix_session_id(&prefix) { return Some(id); } - if whole_file { - return None; - } + // The prefix scan can *decline* (identity after `messages`) as well + // as miss, so always fall back to the full parse — for files that + // fit in the prefix this re-reads a few KiB, which is noise. #[derive(Deserialize)] struct Peek { #[serde(rename = "sessionId")] @@ -898,6 +897,18 @@ mod tests { assert_eq!(peek_session_id(&path).as_deref(), Some("late-id")); } + #[test] + fn peek_session_id_small_file_with_late_identity_still_resolves() { + let (_temp, resolver) = setup(); + let chats = resolver.chats_dir("/proj").unwrap(); + fs::create_dir_all(&chats).unwrap(); + // Fits inside the prefix, but the prefix scan must decline + // (identity after `messages`) and the serde fallback must win. + let path = chats.join("session-2026-01-01T00-00-tiny.json"); + fs::write(&path, r#"{"messages":[],"sessionId":"tiny-id"}"#).unwrap(); + assert_eq!(peek_session_id(&path).as_deref(), Some("tiny-id")); + } + #[test] fn prefix_session_id_rejects_keys_after_messages() { assert_eq!( From 574f5eb3ada455e103371432a0f325673cbb7645 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Tue, 14 Jul 2026 12:24:45 -0400 Subject: [PATCH 17/20] =?UTF-8?q?fix(cli):=20review=20round=20=E2=80=94=20?= =?UTF-8?q?delegation=20thinking=20strip,=20manifest=20lock,=20targeted=20?= =?UTF-8?q?share=20stat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - strip_thinking also scrubs sub-agent turns embedded in delegations (gemini folds sub-agent chat files in, each turn with its own thinking), recursing into nested delegations - manifest writers serialize on an advisory lock (sync.json.lock); every write is a locked read-merge-save and sync checkpoints merge only the records the run wrote, so concurrent invocations union their records instead of clobbering each other - sync's unchanged gate no longer lets an all-None stamp vouch for a record (mirrors record_is_current) - share's fresh-cache fast path stats its one artifact directly (stamp_one) instead of enumerating the whole artifact type - ArtifactType::parse delegates to clap's ValueEnum instead of duplicating the name table --- CHANGELOG.md | 12 ++ CLAUDE.md | 6 +- crates/path-cli/src/cmd_export.rs | 102 ++++++++++ crates/path-cli/src/cmd_share.rs | 8 +- crates/path-cli/src/sync.rs | 307 +++++++++++++++++++++++------- 5 files changed, 358 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff280cce..ae90a58f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,18 @@ users no longer have to `p import` each session by hand. `--project` for `path.base`; a small-file gemini peek that declines in the prefix now still runs the full-parse fallback; and a rebase-dropped `toolpath-pi` workspace pin is back at 0.6.1. + - PR review round 2: thinking-stripping now also scrubs sub-agent + turns embedded in `delegations` (gemini folds sub-agent chat files + into them, each turn with its own `thinking`), recursing into + nested delegations; manifest writers are serialized by an advisory + lock (`sync.json.lock`) and every write is a locked read-merge-save + — sync checkpoints merge only the records the run wrote — so + concurrent invocations (query auto-syncs, imports) union their + records instead of clobbering each other; an all-`None` stamp can + no longer vouch for a record in sync's unchanged gate (mirrors + `record_is_current` — unknowable freshness re-derives); the `share` + fast path stats its one artifact directly instead of enumerating + the whole artifact type. - **`toolpath-gemini`** (0.6.1): new `PathResolver::list_session_entries` returns each session's listing id, inner `sessionId`, and backing file/dir path, and `peek_session_id` is now bounded — it scans the diff --git a/CLAUDE.md b/CLAUDE.md index 93eed94e..3c209c85 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,7 +212,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-cursor`: 78 unit + 8 integration round-trip + 1 real-DB sanity + 1 doc test (state.vscdb SQLite reader, bubble store + composer header parsing, content-addressed blob lookup, projector with full TOOL_TABLE coverage, JSONL transcript ingest in `examples/dump_fixture.rs`) - `toolpath-pi`: 133 unit + 26 integration + 5 doc tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `path-cli`: 354 unit + 121 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. +- `path-cli`: 356 unit + 121 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. - `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary) Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- p validate --input "$f"; done` @@ -279,8 +279,8 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - Format references for the agent on-disk formats we derive from live at `docs/agents/formats/`. The Claude Code format (`~/.claude/projects/…` JSONL) gets the deepest treatment — twelve focused docs at `docs/agents/formats/claude-code/` covering envelope, entry types, tools, session chains, compaction, writing-compatible JSONL, a linear walkthrough, and a version-keyed changelog. Sibling single-file references: `codex.md`, `gemini.md`, `opencode.md`. Keep them in sync with their derive crates when fields or behaviors change. - Interactive session selection: `path p import ` (claude / gemini / pi / codex / opencode) auto-launches a fuzzy picker when stdin and stderr are TTYs and no `--session` was given. Backend: external `fzf` if on `$PATH`, otherwise the embedded skim picker (default-feature `embedded-picker`, defined in `crates/path-cli/src/skim_picker.rs`). Multi-select (TAB) produces a `Graph` document; single-select produces a `Path`. The picker uses `path show --…` as its `--preview` command. When neither backend can run (no TTY, or `--no-default-features` AND no `fzf`), it falls back to most-recent (with `--project`) or prints the manual recipe (without). `path p list --format tsv` is the documented machine-readable surface — column 1 is the project (for claude/gemini/pi) or session id (for codex/opencode), and the trailing column carries `first_user_message` so consumers can fuzzy-match by topic. - Conversation metadata title field: `toolpath-claude::ConversationMetadata`, `toolpath-gemini::ConversationMetadata`, and `toolpath-pi::SessionMeta` all expose `first_user_message: Option` — the first non-empty user-prompt text. Populated cheaply during the metadata pass (single-pass for Claude/Gemini; one extra short read for Pi). Used by the picker UI but useful for any "list sessions by topic" surface. -- `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. When the manifest shows the picked session unchanged since its last sync (`sync::fresh_cache_id`: stamps match, doc present), share uploads the cached doc directly instead of re-deriving. The cache ingests maximally (thinking always included); `share` and `p export pathbase` strip thinking from uploads by default (`--include-thinking` opts in) — local projection (`resume`, `p export `) keeps everything. +- `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. When the manifest shows the picked session unchanged since its last sync (`sync::fresh_cache_id`: stamps match, doc present; the freshness stat targets that one artifact directly, no sibling enumeration), share uploads the cached doc directly instead of re-deriving. The cache ingests maximally (thinking always included); `share` and `p export pathbase` strip thinking from uploads by default (`--include-thinking` opts in; the strip also scrubs sub-agent turns embedded in `delegations`, recursively) — local projection (`resume`, `p export `) keeps everything. - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. -- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs every artifact type. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: head segment via `list_conversations`' bounded first-lines peek; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek; copilot: `session-state//events.jsonl`, pure read-dir + stat) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record's `cache_id` is *optional*: a record without one is "known, not materialized" — created when a `--parent-dir` constraint excluded a peeked artifact, or when `p cache rm` evicts a doc (rm downgrades the record; the next in-scope sync re-materializes it, and sync also verifies the doc file actually exists before skipping, so even out-of-band deletions self-heal). `--parent-dir ` / `-d` on both `p cache sync` and `path query` restricts ingestion to artifacts under that directory (subtree): path-keyed providers prune whole projects before enumerating (claude compares in *slug space* — its dir slugs are lossy, `/`/`_`/`.` all became `-`), cwd-keyed ones check the directory their cheap headers carry, and codex/copilot — whose cwd lives inside the session file — get a one-line peek only when new/changed, memoized into the record's `path`. The stat gate always runs first: unchanged+cached artifacts skip before any scope check. Out-of-scope work is tallied separately (`N out of scope`) and never touches a materialized record's stamp. Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's own recorded cwd rather than the lossy slug. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactStub` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_stub` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. +- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs every artifact type. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: head segment via `list_conversations`' bounded first-lines peek; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek; copilot: `session-state//events.jsonl`, pure read-dir + stat) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type; writers serialize on an advisory lock (`sync.json.lock`) and every write is a locked read-merge-save — sync checkpoints merge only the records the run wrote — so concurrent invocations (query auto-syncs, imports) union their records instead of clobbering each other. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record's `cache_id` is *optional*: a record without one is "known, not materialized" — created when a `--parent-dir` constraint excluded a peeked artifact, or when `p cache rm` evicts a doc (rm downgrades the record; the next in-scope sync re-materializes it, and sync also verifies the doc file actually exists before skipping, so even out-of-band deletions self-heal). `--parent-dir ` / `-d` on both `p cache sync` and `path query` restricts ingestion to artifacts under that directory (subtree): path-keyed providers prune whole projects before enumerating (claude compares in *slug space* — its dir slugs are lossy, `/`/`_`/`.` all became `-`), cwd-keyed ones check the directory their cheap headers carry, and codex/copilot — whose cwd lives inside the session file — get a one-line peek only when new/changed, memoized into the record's `path`. The stat gate always runs first: unchanged+cached artifacts skip before any scope check. Out-of-scope work is tallied separately (`N out of scope`) and never touches a materialized record's stamp. Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's own recorded cwd rather than the lossy slug. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactStub` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_stub` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. - `ArtifactType` (`crates/path-cli/src/sync.rs`) is the general enum naming artifact sources — the seven agent harnesses (incl. copilot) plus `Git` (8 variants). Git artifacts are *recorded* in the manifest by `p import git` (id `-`, `path` = the repo directory) but never *discovered* — there is no machine-wide registry of repos — so sync reports them and leaves them alone. Github and pathbase are deliberately not artifact types: they are remote services, not local artifact sources, and their imports stay out of the manifest. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`cmd_share.rs`) names the seven agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 951e8916..d9973441 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -1874,12 +1874,41 @@ pub(crate) fn strip_thinking(graph: &mut toolpath::v1::Graph) { for change in step.change.values_mut() { if let Some(structural) = &mut change.structural { structural.extra.remove("thinking"); + // `delegations` embeds full sub-agent turns (gemini + // folds sub-agent files in), each with its own + // `thinking` — reasoning must not ride out inside them. + if let Some(delegations) = structural.extra.get_mut("delegations") { + strip_delegation_thinking(delegations); + } } } } } } +/// Remove `thinking` from every turn of a serialized `Vec`, +/// recursing into the turns' own nested delegations. +#[cfg(not(target_os = "emscripten"))] +fn strip_delegation_thinking(delegations: &mut serde_json::Value) { + let Some(list) = delegations.as_array_mut() else { + return; + }; + for delegation in list { + let Some(turns) = delegation.get_mut("turns").and_then(|t| t.as_array_mut()) else { + continue; + }; + for turn in turns { + let Some(obj) = turn.as_object_mut() else { + continue; + }; + obj.remove("thinking"); + if let Some(nested) = obj.get_mut("delegations") { + strip_delegation_thinking(nested); + } + } + } +} + fn run_pathbase(args: PathbaseExportArgs) -> Result<()> { #[cfg(target_os = "emscripten")] { @@ -2108,6 +2137,79 @@ mod tests { assert!(json.contains("hello"), "non-thinking extras survive"); } + #[test] + fn strip_thinking_scrubs_delegation_turns_recursively() { + let mut extra = HashMap::new(); + extra.insert( + "delegations".to_string(), + serde_json::json!([{ + "agent_id": "sub-1", + "prompt": "investigate", + "turns": [{ + "id": "t1", + "role": "assistant", + "timestamp": "2026-01-01T00:00:00Z", + "text": "visible sub-agent text", + "thinking": "sub-agent reasoning", + "delegations": [{ + "agent_id": "sub-2", + "prompt": "deeper", + "turns": [{ + "id": "t2", + "role": "assistant", + "timestamp": "2026-01-01T00:00:01Z", + "text": "nested text", + "thinking": "nested reasoning" + }] + }] + }], + "result": "sub-agent result" + }]), + ); + let step = Step { + step: StepIdentity { + id: "s1".into(), + parents: vec![], + actor: "agent:gemini-cli".into(), + timestamp: "2026-01-01T00:00:00Z".into(), + }, + change: HashMap::from([( + "conversation://x".to_string(), + ArtifactChange { + raw: None, + structural: Some(StructuralChange { + change_type: "conversation.append".into(), + extra, + }), + }, + )]), + meta: None, + }; + let path = toolpath::v1::Path { + path: PathIdentity { + id: "p1".into(), + base: None, + head: "s1".into(), + graph_ref: None, + }, + steps: vec![step], + meta: None, + }; + let mut graph = toolpath::v1::Graph::from_path(path); + + strip_thinking(&mut graph); + + let json = graph.to_json().unwrap(); + assert!(!json.contains("sub-agent reasoning")); + assert!(!json.contains("nested reasoning")); + assert!(json.contains("visible sub-agent text")); + assert!(json.contains("nested text")); + assert!( + json.contains("sub-agent result"), + "delegation results survive" + ); + } + fn make_path_doc() -> toolpath::v1::Graph { let artifact_key = "agent://claude/test-session"; diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index 8ca2e93e..3be5e835 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -958,8 +958,12 @@ fn share_explicit( // already in the cache, upload the cached doc instead of re-deriving // — a derive would reproduce it byte-for-byte anyway. if !args.no_cache - && let Some(cache_id) = - crate::sync::fresh_cache_id(&HarnessBundle::from_environment(), harness, session) + && let Some(cache_id) = crate::sync::fresh_cache_id( + &HarnessBundle::from_environment(), + harness, + project.as_deref(), + session, + ) { let doc_path = crate::cmd_cache::cache_path(&cache_id)?; let mut graph = crate::io::read_document_auto(&doc_path)?; diff --git a/crates/path-cli/src/sync.rs b/crates/path-cli/src/sync.rs index 860f4108..15ae395c 100644 --- a/crates/path-cli/src/sync.rs +++ b/crates/path-cli/src/sync.rs @@ -87,17 +87,7 @@ impl ArtifactType { } pub(crate) fn parse(s: &str) -> Option { - match s { - "claude" => Some(ArtifactType::Claude), - "gemini" => Some(ArtifactType::Gemini), - "codex" => Some(ArtifactType::Codex), - "opencode" => Some(ArtifactType::Opencode), - "cursor" => Some(ArtifactType::Cursor), - "pi" => Some(ArtifactType::Pi), - "copilot" => Some(ArtifactType::Copilot), - "git" => Some(ArtifactType::Git), - _ => None, - } + ::from_str(s, false).ok() } } @@ -239,46 +229,58 @@ mod engine { /// Sync the given artifact types from `bundle` into the cache. The /// manifest is checkpointed after each type so an interrupted first - /// run doesn't forget the types it already finished. + /// run doesn't forget the types it already finished. Reads come + /// from a point-in-time snapshot; each checkpoint merges only the + /// records this run wrote, under the manifest lock, so concurrent + /// invocations (query auto-syncs, imports) union their records + /// instead of clobbering each other. pub(crate) fn sync_bundle( bundle: &HarnessBundle, types: &[ArtifactType], parent_dir: Option<&Path>, ) -> Result> { - let mut manifest = load_manifest()?; + let manifest = load_manifest()?; let mut out = Vec::with_capacity(types.len()); for &artifact_type in types { let stubs = enumerate_stubs(bundle, artifact_type, parent_dir); - let mut records = manifest + let records = manifest .get(artifact_type.name()) .cloned() .unwrap_or_default(); - let outcome = sync_stubs(bundle, &stubs, &mut records, parent_dir)?; - if !records.is_empty() { - manifest.insert(artifact_type.name().to_string(), records); - save_manifest(&manifest)?; + let (outcome, writes) = sync_stubs(bundle, &stubs, &records, parent_dir)?; + if !writes.is_empty() { + update_manifest(|m| { + m.entry(artifact_type.name().to_string()) + .or_default() + .extend(writes); + })?; } out.push((artifact_type, outcome)); } Ok(out) } - /// Sync one type's stubs against its manifest records. Derivation - /// failures are warned and tallied, not fatal; cache-write failures - /// (disk, permissions) abort. + /// Sync one type's stubs against a snapshot of its manifest records, + /// returning the records this run wrote (for a locked merge by the + /// caller). Derivation failures are warned and tallied, not fatal; + /// cache-write failures (disk, permissions) abort. fn sync_stubs( bundle: &HarnessBundle, stubs: &[ArtifactStub], - records: &mut BTreeMap, + records: &BTreeMap, parent_dir: Option<&Path>, - ) -> Result { + ) -> Result<(SyncOutcome, BTreeMap)> { let mut outcome = SyncOutcome::default(); + let mut writes: BTreeMap = BTreeMap::new(); for stub in stubs { let existing = records.get(&stub.id); let is_new = existing.is_none(); // Stat gate first, always: a materialized, unchanged artifact - // needs nothing — no read, no scope check. + // needs nothing — no read, no scope check. All-`None` stamps + // mean freshness is unknowable; only a real stamp can vouch + // (mirrors `record_is_current`). if let Some(rec) = existing + && (rec.modified.is_some() || rec.size.is_some()) && rec.modified == stub.modified && rec.size == stub.size && let Some(cache_id) = &rec.cache_id @@ -310,7 +312,7 @@ mod engine { // of a materialized record, or its staleness would be // masked from the next in-scope sync. if existing.is_none_or(|r| r.cache_id.is_none()) { - records.insert( + writes.insert( stub.id.clone(), SyncRecord { path: dir, @@ -329,14 +331,14 @@ mod engine { let memoized_path = stub .path .clone() - .or_else(|| records.get(&stub.id).and_then(|r| r.path.clone())); + .or_else(|| existing.and_then(|r| r.path.clone())); match derive_stub(bundle, stub) { Ok(derived) => { // force: sync owns refresh semantics — a re-sync or a // prior manual `p import` of the same session must not // error on the existing cache entry. write_cached(&derived.cache_id, &derived.doc, true)?; - records.insert( + writes.insert( stub.id.clone(), SyncRecord { path: memoized_path, @@ -365,7 +367,7 @@ mod engine { } } } - Ok(outcome) + Ok((outcome, writes)) } /// Derive one artifact through the same manager it was enumerated @@ -843,21 +845,21 @@ mod engine { /// Record an externally-derived cache write (`p import`, `share`) in /// the manifest, so sync doesn't re-derive what was just written. pub(crate) fn record_stub(stub: &ArtifactStub, cache_id: &str) -> Result<()> { - let mut manifest = load_manifest()?; - manifest - .entry(stub.artifact_type.name().to_string()) - .or_default() - .insert( - stub.id.clone(), - SyncRecord { - path: stub.path.clone(), - cache_id: Some(cache_id.to_string()), - modified: stub.modified, - size: stub.size, - synced_at: Utc::now(), - }, - ); - save_manifest(&manifest) + update_manifest(|manifest| { + manifest + .entry(stub.artifact_type.name().to_string()) + .or_default() + .insert( + stub.id.clone(), + SyncRecord { + path: stub.path.clone(), + cache_id: Some(cache_id.to_string()), + modified: stub.modified, + size: stub.size, + synced_at: Utc::now(), + }, + ); + }) } /// Whether the manifest already records exactly this artifact state @@ -884,43 +886,116 @@ mod engine { /// The cache entry for an artifact, when the manifest says it is /// materialized and a fresh stat shows its source unchanged since — /// i.e. re-deriving would reproduce the cached doc byte-for-byte. - /// Used by `share` to upload straight from the cache. + /// Used by `share` to upload straight from the cache. The stat + /// targets one artifact directly — no enumeration of its siblings. pub(crate) fn fresh_cache_id( bundle: &HarnessBundle, artifact_type: ArtifactType, + project: Option<&str>, id: &str, ) -> Option { - let stub = enumerate_stubs(bundle, artifact_type, None) - .into_iter() - .find(|s| s.id == id)?; let manifest = load_manifest().ok()?; let rec = manifest.get(artifact_type.name())?.get(id)?; let cache_id = rec.cache_id.clone()?; - (rec.modified == stub.modified - && rec.size == stub.size + let (modified, size) = stamp_one(bundle, artifact_type, project, id)?; + // None stamps mean freshness is unknowable; only a real, + // matching stamp can vouch for the cache entry. + ((rec.modified.is_some() || rec.size.is_some()) + && rec.modified == modified + && rec.size == size && crate::cmd_cache::cache_path(&cache_id).is_ok_and(|p| p.exists())) .then_some(cache_id) } + /// Stat-level fingerprint for a single artifact, resolved directly. + /// Uses the same stat targets as `enumerate_stubs` and the + /// `derive_*_session_with` provenance stamps, so the result compares + /// against manifest records. `project` is required for the + /// path-keyed providers (claude/gemini/pi). + fn stamp_one( + bundle: &HarnessBundle, + artifact_type: ArtifactType, + project: Option<&str>, + id: &str, + ) -> Option<(Option>, Option)> { + match artifact_type { + ArtifactType::Claude => { + let file = bundle + .claude + .as_ref()? + .resolver() + .conversation_file(project?, id) + .ok()?; + Some(stat_stamp(&file)) + } + ArtifactType::Gemini => { + let entries = bundle + .gemini + .as_ref()? + .resolver() + .list_session_entries(project?) + .ok()?; + let entry = entries + .into_iter() + .find(|e| e.id == id || e.session_uuid.as_deref() == Some(id))?; + Some(stat_stamp(&entry.path)) + } + ArtifactType::Pi => { + let mgr = bundle.pi.as_ref()?; + let files = + toolpath_pi::reader::list_session_files(mgr.resolver(), project?).ok()?; + let file = files.into_iter().find(|f| { + toolpath_pi::reader::peek_header(f).is_ok_and(|h| h.id == id) + || f.file_stem() + .and_then(|stem| stem.to_str()) + .and_then(|stem| stem.split_once('_')) + .is_some_and(|(_, rest)| rest == id) + })?; + Some(stat_stamp(&file)) + } + ArtifactType::Codex => { + let file = bundle + .codex + .as_ref()? + .resolver() + .find_rollout_file(id) + .ok()?; + Some(stat_stamp(&file)) + } + ArtifactType::Copilot => { + let file = bundle.copilot.as_ref()?.resolver().events_file(id).ok()?; + Some(stat_stamp(&file)) + } + ArtifactType::Opencode => { + let sessions = bundle.opencode.as_ref()?.io().list_sessions(None).ok()?; + let session = sessions.into_iter().find(|s| s.id == id)?; + Some((session.last_activity(), None)) + } + ArtifactType::Cursor => { + let headers = bundle.cursor.as_ref()?.io().read_composer_headers().ok()?; + let composer = headers + .all_composers + .into_iter() + .find(|c| c.composer_id == id)?; + Some((composer.last_updated_at_utc(), None)) + } + ArtifactType::Git => None, + } + } + /// `p cache rm` eviction: the doc is gone, so any record pointing /// at it downgrades to known-but-uncached (the artifact itself is /// still real; the next in-scope sync re-materializes it). pub(crate) fn evict_cache_id(cache_id: &str) -> Result<()> { - let mut manifest = load_manifest()?; - let mut changed = false; - for records in manifest.values_mut() { - for rec in records.values_mut() { - if rec.cache_id.as_deref() == Some(cache_id) { - rec.cache_id = None; - changed = true; + update_manifest(|manifest| { + for records in manifest.values_mut() { + for rec in records.values_mut() { + if rec.cache_id.as_deref() == Some(cache_id) { + rec.cache_id = None; + } } } - } - if changed { - save_manifest(&manifest) - } else { - Ok(()) - } + }) } // ── manifest IO ──────────────────────────────────────────────────── @@ -929,6 +1004,37 @@ mod engine { Ok(config_dir()?.join(MANIFEST_FILE)) } + /// Take the exclusive advisory lock serializing manifest writers + /// across processes (query auto-syncs and imports can run + /// concurrently). A sibling lock file — never renamed, unlike the + /// manifest itself — held until the returned handle drops. + fn lock_manifest() -> Result { + let path = manifest_path()?; + let dir = path.parent().expect("manifest path has a parent"); + std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; + let lock_path = dir.join(format!("{MANIFEST_FILE}.lock")); + let file = std::fs::File::create(&lock_path) + .with_context(|| format!("create {}", lock_path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600)); + } + file.lock() + .with_context(|| format!("lock {}", lock_path.display()))?; + Ok(file) + } + + /// One locked read-modify-write cycle against the manifest. Every + /// writer goes through here, so concurrent invocations merge their + /// records instead of clobbering each other's. + fn update_manifest(mutate: impl FnOnce(&mut Manifest)) -> Result<()> { + let _lock = lock_manifest()?; + let mut manifest = load_manifest()?; + mutate(&mut manifest); + save_manifest(&manifest) + } + pub(crate) fn load_manifest() -> Result { let path = manifest_path()?; let json = match std::fs::read_to_string(&path) { @@ -1213,17 +1319,37 @@ mod engine { let mut stubs = enumerate_stubs(&bundle, ArtifactType::Claude, None); stubs.push(make_stub(ArtifactType::Claude, "does-not-exist")); - let mut records = BTreeMap::new(); - let outcome = sync_stubs(&bundle, &stubs, &mut records, None).unwrap(); + let (outcome, writes) = + sync_stubs(&bundle, &stubs, &BTreeMap::new(), None).unwrap(); assert_eq!((outcome.new, outcome.failed), (1, 1)); - assert!(records.contains_key("sess-aaa")); + assert!(writes.contains_key("sess-aaa")); assert!( - !records.contains_key("does-not-exist"), + !writes.contains_key("does-not-exist"), "failed artifacts must not be recorded as synced" ); }); } + #[test] + fn all_none_stamps_never_read_as_unchanged() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); + + // A record whose stamps are all None (stat failed when it + // was written) must not match a stub whose stat also + // failed — unknowable freshness re-derives. + let mut records = load_manifest().unwrap()["claude"].clone(); + let rec = records.get_mut("sess-aaa").unwrap(); + rec.modified = None; + rec.size = None; + let stub = make_stub(ArtifactType::Claude, "sess-aaa"); + let (outcome, _) = sync_stubs(&bundle, &[stub], &records, None).unwrap(); + assert_eq!((outcome.updated, outcome.unchanged), (1, 0)); + }); + } + #[test] fn recorded_import_is_unchanged_to_the_next_sync() { with_cfg(|home| { @@ -1447,11 +1573,24 @@ mod engine { let bundle = claude_bundle(home); // Nothing synced yet: no fresh copy. - assert!(fresh_cache_id(&bundle, ArtifactType::Claude, "sess-aaa").is_none()); + assert!( + fresh_cache_id( + &bundle, + ArtifactType::Claude, + Some("/test/project"), + "sess-aaa" + ) + .is_none() + ); sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); - let cache_id = fresh_cache_id(&bundle, ArtifactType::Claude, "sess-aaa") - .expect("synced artifact is fresh"); + let cache_id = fresh_cache_id( + &bundle, + ArtifactType::Claude, + Some("/test/project"), + "sess-aaa", + ) + .expect("synced artifact is fresh"); // Source grows: stale until re-synced. let file = home.join(".claude/projects/-test-project/sess-aaa.jsonl"); @@ -1461,14 +1600,38 @@ mod engine { ); body.push('\n'); std::fs::write(&file, body).unwrap(); - assert!(fresh_cache_id(&bundle, ArtifactType::Claude, "sess-aaa").is_none()); + assert!( + fresh_cache_id( + &bundle, + ArtifactType::Claude, + Some("/test/project"), + "sess-aaa" + ) + .is_none() + ); sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); - assert!(fresh_cache_id(&bundle, ArtifactType::Claude, "sess-aaa").is_some()); + assert!( + fresh_cache_id( + &bundle, + ArtifactType::Claude, + Some("/test/project"), + "sess-aaa" + ) + .is_some() + ); // Evicted: known but not materialized, so not fresh. crate::cmd_cache::remove_cached(&cache_id).unwrap(); evict_cache_id(&cache_id).unwrap(); - assert!(fresh_cache_id(&bundle, ArtifactType::Claude, "sess-aaa").is_none()); + assert!( + fresh_cache_id( + &bundle, + ArtifactType::Claude, + Some("/test/project"), + "sess-aaa" + ) + .is_none() + ); }); } From 1e421ef7eded22e4eb6327f9312369c2493013a3 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Tue, 14 Jul 2026 12:47:35 -0400 Subject: [PATCH 18/20] fix(cli): fingerprint claude sessions across their whole rotation chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code rotates to a new JSONL file on continuation (plan-mode exit, resume, fork) while the chain keeps its oldest segment's id — appends land in the newest file, so statting the head file froze the fingerprint at the first rotation and sync never saw later turns. - toolpath-claude 0.12.1: ClaudeConvo::session_chain is now public (rides the cached chain index list_conversations already builds) - all three stamp sites (enumeration, import provenance, share fast path) share claude_chain_stamp: max segment mtime + summed segment sizes — exactly the files read_conversation merges, so fingerprint and derived doc move in lockstep across rotation-behavior changes - import normalizes its manifest key to the chain head so importing a successor-segment id records the artifact sync will look for --- CHANGELOG.md | 19 ++++++ CLAUDE.md | 6 +- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/path-cli/src/cmd_import.rs | 19 ++++-- crates/path-cli/src/sync.rs | 110 +++++++++++++++++++++++++----- crates/toolpath-claude/Cargo.toml | 2 +- crates/toolpath-claude/src/lib.rs | 15 ++-- site/_data/crates.json | 2 +- 9 files changed, 140 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae90a58f..c9799865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,19 @@ users no longer have to `p import` each session by hand. `record_is_current` — unknowable freshness re-derives); the `share` fast path stats its one artifact directly instead of enumerating the whole artifact type. + - Claude fingerprints cover the whole session chain. Claude Code + rotates to a new JSONL file on continuation (plan-mode exit, + resume, fork) while `list_conversations` keys the chain by its + *oldest* segment — appends land in the newest file, so statting + the head file froze the fingerprint at the first rotation and sync + never saw later turns. All three stamp sites (enumeration, import + provenance, the `share` fast path) now share `claude_chain_stamp`: + max mtime across the chain's segments plus the sum of their sizes + — exactly the files `read_conversation` merges, so the fingerprint + and the derived doc move in lockstep however rotation behavior + shifts across Claude Code versions. Import also normalizes its + manifest key to the chain head, so importing a successor-segment + id records the artifact sync will look for. - **`toolpath-gemini`** (0.6.1): new `PathResolver::list_session_entries` returns each session's listing id, inner `sessionId`, and backing file/dir path, and `peek_session_id` is now bounded — it scans the @@ -124,6 +137,12 @@ users no longer have to `p import` each session by hand. falls back to a full parse only when they don't. `list_sessions` delegates to it unchanged. This is what lets `p cache sync` fingerprint gemini sessions without reading chat bodies. +- **`toolpath-claude`** (0.12.1): `ClaudeConvo::session_chain` is now + public — it resolves the full session chain for a session id (oldest + segment first), which is what lets `p cache sync` fingerprint a + conversation across rotations without reading bodies. It rides the + same cached chain index as `list_conversations`, so calling it after + a listing costs no extra IO. - **`toolpath-cli`** (0.16.0): version bump only (tracks `path-cli`). ## Derive: resolve duplicate step ids — 2026-07-01 diff --git a/CLAUDE.md b/CLAUDE.md index 3c209c85..d64478e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,7 +212,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-cursor`: 78 unit + 8 integration round-trip + 1 real-DB sanity + 1 doc test (state.vscdb SQLite reader, bubble store + composer header parsing, content-addressed blob lookup, projector with full TOOL_TABLE coverage, JSONL transcript ingest in `examples/dump_fixture.rs`) - `toolpath-pi`: 133 unit + 26 integration + 5 doc tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `path-cli`: 356 unit + 121 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. +- `path-cli`: 357 unit + 121 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. - `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary) Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- p validate --input "$f"; done` @@ -263,7 +263,7 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - `PathOrRef::Path` is `Box` to avoid a large enum variant size difference - The git derivation (`toolpath-git`) uses `git2` (libgit2 bindings), not shelling out to git - Claude conversation data lives in `~/.claude/projects/` as JSONL files; `toolpath-claude` reads these directly -- `toolpath-claude` follows session chains by default — Claude Code rotates JSONL files on context overflow; `read_conversation` merges segments, `list_conversations` returns chain heads. `read_segment`/`list_segments` for single-file access. `ChainIndex` makes this incremental. +- `toolpath-claude` follows session chains by default — Claude Code rotates JSONL files on continuation (plan-mode exit, resume, fork; older versions on autocompact); `read_conversation` merges segments, `list_conversations` returns chain heads (the *oldest* segment of each chain — the rotation-stable id), `session_chain` resolves a chain's segments oldest-first (public since 0.12.1; it's how sync fingerprints whole chains). `read_segment`/`list_segments` for single-file access. `ChainIndex` makes this incremental. - Gemini CLI conversation data lives in `~/.gemini/tmp//chats/`. Main sessions sit at the top (`session--.json`, `kind: "main"`); sub-agents live in sibling `/` directories (`kind: "subagent"`). The `` slot is either a friendly name from `~/.gemini/projects.json` or the SHA-256 hex of the absolute project path; `toolpath-gemini` resolves both. - `toolpath-gemini` treats main file + sibling sub-agent UUID dir as one conversation. Sub-agent files are folded into `DelegatedWork` with populated `turns` (unlike `toolpath-claude`, whose sub-agent turns live in separate session files and stay empty). See `docs/agents/formats/gemini.md` for the full format reference. - Provider-specific extras convention: `Turn.extra` and `WatcherEvent::Progress.data` use provider-namespaced keys (e.g. `extra["claude"]`, `extra["gemini"]`). `toolpath-claude` populates `Turn.extra["claude"]` from `ConversationEntry.extra`; `toolpath-gemini` populates `Turn.extra["gemini"]` with the full `tokens` struct, per-thought metadata, and tool-call status. This lets trait-only consumers access provider metadata without importing provider types. @@ -282,5 +282,5 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. When the manifest shows the picked session unchanged since its last sync (`sync::fresh_cache_id`: stamps match, doc present; the freshness stat targets that one artifact directly, no sibling enumeration), share uploads the cached doc directly instead of re-deriving. The cache ingests maximally (thinking always included); `share` and `p export pathbase` strip thinking from uploads by default (`--include-thinking` opts in; the strip also scrubs sub-agent turns embedded in `delegations`, recursively) — local projection (`resume`, `p export `) keeps everything. - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. -- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs every artifact type. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: head segment via `list_conversations`' bounded first-lines peek; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek; copilot: `session-state//events.jsonl`, pure read-dir + stat) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type; writers serialize on an advisory lock (`sync.json.lock`) and every write is a locked read-merge-save — sync checkpoints merge only the records the run wrote — so concurrent invocations (query auto-syncs, imports) union their records instead of clobbering each other. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record's `cache_id` is *optional*: a record without one is "known, not materialized" — created when a `--parent-dir` constraint excluded a peeked artifact, or when `p cache rm` evicts a doc (rm downgrades the record; the next in-scope sync re-materializes it, and sync also verifies the doc file actually exists before skipping, so even out-of-band deletions self-heal). `--parent-dir ` / `-d` on both `p cache sync` and `path query` restricts ingestion to artifacts under that directory (subtree): path-keyed providers prune whole projects before enumerating (claude compares in *slug space* — its dir slugs are lossy, `/`/`_`/`.` all became `-`), cwd-keyed ones check the directory their cheap headers carry, and codex/copilot — whose cwd lives inside the session file — get a one-line peek only when new/changed, memoized into the record's `path`. The stat gate always runs first: unchanged+cached artifacts skip before any scope check. Out-of-scope work is tallied separately (`N out of scope`) and never touches a materialized record's stamp. Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's own recorded cwd rather than the lossy slug. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactStub` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_stub` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. +- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs every artifact type. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: the *whole session chain* — max segment mtime + summed segment sizes via `claude_chain_stamp`, because Claude Code rotates to a new file on continuation while the chain keeps its oldest segment's id, so appends land in the newest file, not the head; the chain comes from the same cached index `list_conversations` builds; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek; copilot: `session-state//events.jsonl`, pure read-dir + stat) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type; writers serialize on an advisory lock (`sync.json.lock`) and every write is a locked read-merge-save — sync checkpoints merge only the records the run wrote — so concurrent invocations (query auto-syncs, imports) union their records instead of clobbering each other. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record's `cache_id` is *optional*: a record without one is "known, not materialized" — created when a `--parent-dir` constraint excluded a peeked artifact, or when `p cache rm` evicts a doc (rm downgrades the record; the next in-scope sync re-materializes it, and sync also verifies the doc file actually exists before skipping, so even out-of-band deletions self-heal). `--parent-dir ` / `-d` on both `p cache sync` and `path query` restricts ingestion to artifacts under that directory (subtree): path-keyed providers prune whole projects before enumerating (claude compares in *slug space* — its dir slugs are lossy, `/`/`_`/`.` all became `-`), cwd-keyed ones check the directory their cheap headers carry, and codex/copilot — whose cwd lives inside the session file — get a one-line peek only when new/changed, memoized into the record's `path`. The stat gate always runs first: unchanged+cached artifacts skip before any scope check. Out-of-scope work is tallied separately (`N out of scope`) and never touches a materialized record's stamp. Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's own recorded cwd rather than the lossy slug. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactStub` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_stub` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. - `ArtifactType` (`crates/path-cli/src/sync.rs`) is the general enum naming artifact sources — the seven agent harnesses (incl. copilot) plus `Git` (8 variants). Git artifacts are *recorded* in the manifest by `p import git` (id `-`, `path` = the repo directory) but never *discovered* — there is no machine-wide registry of repos — so sync reports them and leaves them alone. Github and pathbase are deliberately not artifact types: they are remote services, not local artifact sources, and their imports stay out of the manifest. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`cmd_share.rs`) names the seven agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/Cargo.lock b/Cargo.lock index 17eb667d..f4e70cd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4073,7 +4073,7 @@ dependencies = [ [[package]] name = "toolpath-claude" -version = "0.12.0" +version = "0.12.1" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 12107ac1..89f96d87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ license = "Apache-2.0" toolpath = { version = "0.7.0", path = "crates/toolpath" } toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" } toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" } -toolpath-claude = { version = "0.12.0", path = "crates/toolpath-claude", default-features = false } +toolpath-claude = { version = "0.12.1", path = "crates/toolpath-claude", default-features = false } toolpath-gemini = { version = "0.6.1", path = "crates/toolpath-gemini", default-features = false } toolpath-codex = { version = "0.6.0", path = "crates/toolpath-codex" } toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" } diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index 47c1cb20..3213dcdc 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -15,7 +15,7 @@ use std::path::PathBuf; use toolpath::v1::Graph; use crate::cmd_cache::{make_id, write_cached}; -use crate::sync::{ArtifactStub, ArtifactType, codex_artifact_id, stat_stamp}; +use crate::sync::{ArtifactStub, ArtifactType, claude_chain_stamp, codex_artifact_id, stat_stamp}; #[derive(Subcommand, Debug)] pub enum ImportSource { @@ -537,11 +537,16 @@ pub(crate) fn derive_claude_session_with( project: &str, session: &str, ) -> Result { - let (modified, size) = manager - .resolver() - .conversation_file(project, session) - .map(|p| stat_stamp(&p)) - .unwrap_or((None, None)); + // Fingerprint the whole session chain, and key the manifest record + // by the chain head (the rotation-stable id sync enumerates), so a + // caller passing a successor-segment id still records the artifact + // sync will look for. + let (modified, size) = claude_chain_stamp(manager, project, session); + let artifact_id = manager + .session_chain(project, session) + .ok() + .and_then(|chain| chain.into_iter().next()) + .unwrap_or_else(|| session.to_string()); // The caller's project string often comes from claude's lossy dir // slugs ('/', '_', '.' all collapsed); leaving it out of the derive // lets path.base come from the session's own recorded cwd instead. @@ -570,7 +575,7 @@ pub(crate) fn derive_claude_session_with( doc: Graph::from_path(path), provenance: Some(ArtifactStub { artifact_type: ArtifactType::Claude, - id: session.to_string(), + id: artifact_id, path: Some(project.to_string()), modified, size, diff --git a/crates/path-cli/src/sync.rs b/crates/path-cli/src/sync.rs index 15ae395c..2e256d2c 100644 --- a/crates/path-cli/src/sync.rs +++ b/crates/path-cli/src/sync.rs @@ -124,6 +124,42 @@ pub(crate) fn stat_stamp( } } +/// Stat-level fingerprint of a whole claude session chain: max mtime +/// across the chain's segment files plus the sum of their sizes. Claude +/// Code rotates to a new file on continuation (plan-mode exit, resume, +/// fork) while the chain keeps the *first* segment's id — appends land +/// in the newest segment, so statting the head file alone would freeze +/// the fingerprint at the first rotation and sync would never see the +/// later turns. The chain here is exactly the set of files +/// `read_conversation` merges, so the fingerprint and the derived doc +/// move in lockstep. The chain index is already built (and cached) by +/// the `list_conversations` call every caller makes first. +pub(crate) fn claude_chain_stamp( + mgr: &toolpath_claude::ClaudeConvo, + project: &str, + session: &str, +) -> (Option>, Option) { + let segments = match mgr.session_chain(project, session) { + Ok(segments) if !segments.is_empty() => segments, + _ => vec![session.to_string()], + }; + let mut modified: Option> = None; + let mut size: Option = None; + for segment in &segments { + let Ok(file) = mgr.resolver().conversation_file(project, segment) else { + continue; + }; + let (m, s) = stat_stamp(&file); + if let Some(m) = m { + modified = Some(modified.map_or(m, |cur| cur.max(m))); + } + if let Some(s) = s { + size = Some(size.unwrap_or(0) + s); + } + } + (modified, size) +} + /// The trailing UUID of a codex rollout filename stem /// (`rollout--`), or the whole stem when it doesn't end /// in one. Codex's `read_session` resolves either form. @@ -560,8 +596,10 @@ mod engine { } /// Chain heads via `list_conversations` (bounded first-lines peek - /// per file, no full parse); fingerprint stats the head segment — - /// appends land there, and a rotation surfaces as a new head id. + /// per file, no full parse). The head is the chain's *oldest* + /// segment and its id is rotation-stable; the fingerprint covers + /// the whole chain (see `claude_chain_stamp`) because appends land + /// in the newest segment, not the head file. fn stubs_claude( mgr: &toolpath_claude::ClaudeConvo, parent_dir: Option<&Path>, @@ -589,11 +627,7 @@ mod engine { } }; for head in heads { - let (modified, size) = mgr - .resolver() - .conversation_file(&project, &head) - .map(|p| stat_stamp(&p)) - .unwrap_or((None, None)); + let (modified, size) = super::claude_chain_stamp(mgr, &project, &head); out.push(ArtifactStub { artifact_type: ArtifactType::Claude, id: head, @@ -919,15 +953,11 @@ mod engine { id: &str, ) -> Option<(Option>, Option)> { match artifact_type { - ArtifactType::Claude => { - let file = bundle - .claude - .as_ref()? - .resolver() - .conversation_file(project?, id) - .ok()?; - Some(stat_stamp(&file)) - } + ArtifactType::Claude => Some(super::claude_chain_stamp( + bundle.claude.as_ref()?, + project?, + id, + )), ArtifactType::Gemini => { let entries = bundle .gemini @@ -1330,6 +1360,54 @@ mod engine { }); } + #[test] + fn rotated_session_resyncs_under_its_head_id() { + with_cfg(|home| { + write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); + let bundle = claude_bundle(home); + sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap(); + let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] + .cache_id + .clone() + .unwrap(); + let steps_before = cached_step_count(&cache_id); + + // The session rotates: a successor file whose first entry + // carries the predecessor's sessionId (the bridge). + // Appends land here; sess-aaa.jsonl never changes again. + std::fs::write( + home.join(".claude/projects/-test-project/sess-bbb.jsonl"), + concat!( + r#"{"type":"user","uuid":"u-b0","timestamp":"2024-01-02T01:00:00Z","sessionId":"sess-aaa","cwd":"/test/project","message":{"role":"user","content":"bridge"}}"#, + "\n", + r#"{"type":"user","uuid":"u-b1","timestamp":"2024-01-02T01:00:01Z","sessionId":"sess-bbb","cwd":"/test/project","message":{"role":"user","content":"after rotation"}}"#, + "\n", + ), + ) + .unwrap(); + + let (_, outcome) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; + assert_eq!( + (outcome.new, outcome.updated, outcome.unchanged), + (0, 1, 0), + "the chain must re-sync under its head id, not read as unchanged" + ); + let manifest = load_manifest().unwrap(); + assert!( + !manifest["claude"].contains_key("sess-bbb"), + "successor segments are not separate artifacts" + ); + assert!( + cached_step_count(&cache_id) > steps_before, + "post-rotation turns must reach the cached doc" + ); + + // And the grown chain settles: a third sync is a no-op. + let (_, again) = sync_bundle(&bundle, &[ArtifactType::Claude], None).unwrap()[0]; + assert_eq!((again.updated, again.unchanged), (0, 1)); + }); + } + #[test] fn all_none_stamps_never_read_as_unchanged() { with_cfg(|home| { diff --git a/crates/toolpath-claude/Cargo.toml b/crates/toolpath-claude/Cargo.toml index 5f463412..0ed4b496 100644 --- a/crates/toolpath-claude/Cargo.toml +++ b/crates/toolpath-claude/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-claude" -version = "0.12.0" +version = "0.12.1" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-claude/src/lib.rs b/crates/toolpath-claude/src/lib.rs index 4d70a3d9..7251769e 100644 --- a/crates/toolpath-claude/src/lib.rs +++ b/crates/toolpath-claude/src/lib.rs @@ -340,13 +340,14 @@ impl ClaudeConvo { /// Resolves the full session chain containing `session_id`, returned /// in chronological order (oldest segment first). /// - /// For single-segment sessions, returns `[session_id]`. - #[allow(dead_code)] - pub(crate) fn session_chain( - &self, - project_path: &str, - session_id: &str, - ) -> Result> { + /// For single-segment sessions, returns `[session_id]`. This is the + /// set of files [`Self::read_conversation`] would merge — appends + /// land in the *last* segment while the chain keeps the first + /// segment's id, so anything fingerprinting a conversation must + /// stat every segment, not the head file alone. Uses the same + /// cached chain index as the other chain surfaces; after a + /// [`Self::list_conversations`] call it costs no extra IO. + pub fn session_chain(&self, project_path: &str, session_id: &str) -> Result> { self.chain_for(project_path, session_id) } diff --git a/site/_data/crates.json b/site/_data/crates.json index c0539598..33564bef 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -33,7 +33,7 @@ }, { "name": "toolpath-claude", - "version": "0.12.0", + "version": "0.12.1", "description": "Derive from Claude conversation logs", "docs": "https://docs.rs/toolpath-claude", "crate": "https://crates.io/crates/toolpath-claude", From 8c890b04bd7c970e22b6102fd19d2d8970fd097a Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Tue, 14 Jul 2026 12:57:23 -0400 Subject: [PATCH 19/20] fix(gemini): drop intra-doc link to private peek_session_id The doc quality gate (-D rustdoc::private-intra-doc-links) rejects public docs linking private items. --- crates/toolpath-gemini/src/paths.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/toolpath-gemini/src/paths.rs b/crates/toolpath-gemini/src/paths.rs index 9ba93dbe..561811ef 100644 --- a/crates/toolpath-gemini/src/paths.rs +++ b/crates/toolpath-gemini/src/paths.rs @@ -219,7 +219,8 @@ impl PathResolver { /// backing main file (or orphan sub-agent directory) and the inner /// `sessionId` when one could be peeked — enough for stat-level /// change detection without parsing chat bodies. The peek is - /// bounded; see [`peek_session_id`]. + /// bounded (see `peek_session_id`): it scans a fixed-size prefix + /// and falls back to a full parse only when identity isn't there. pub fn list_session_entries(&self, project_path: &str) -> Result> { let chats = match self.chats_dir(project_path) { Ok(p) => p, From bcc68494d7a5aa241d25f0cad8a56ec9a5159378 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Tue, 14 Jul 2026 15:28:55 -0400 Subject: [PATCH 20/20] feat(cli): sync progress, frequent checkpoints, newest-first derives The first sync on a machine with years of sessions used to grind silently and, if interrupted, forget everything in the in-progress type. - progress on stderr whenever a sync has pending work: a live \r-updating ' done/total' line on a TTY, a plain line every 25 items otherwise; no-op syncs stay silent - manifest checkpoints every 10 writes instead of once per type, so an interrupted run keeps nearly everything it derived (out-of-scope peek memoizations included) - derives run newest-first, so partial progress covers the sessions the user most likely wants --- CHANGELOG.md | 8 +- CLAUDE.md | 4 +- crates/path-cli/src/sync.rs | 242 +++++++++++++++++++++++++++++------- 3 files changed, 206 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9799865..a8b1c23b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,12 @@ users no longer have to `p import` each session by hand. opencode/cursor — so deciding "nothing changed" reads no session bodies and a no-op sync is milliseconds. The manifest is written atomically (temp file + rename, - `0600`) and checkpointed after each type, so an interrupted first - run keeps the types it finished. + `0600`) and checkpointed every 10 writes with derives running + newest-first, so an interrupted first run keeps nearly everything + it derived — and spent its time on the sessions that matter most. + Pending work reports progress on stderr (live ` done/total` + on a TTY, a plain line every 25 items otherwise; no-op syncs stay + silent). - `ArtifactType` (in `sync.rs`) is the general enum naming artifact sources: `p cache sync` types, the manifest keys, and import cache-id prefixes all use it (it absorbs the former `HarnessArg`). diff --git a/CLAUDE.md b/CLAUDE.md index d64478e5..55c50847 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,7 +212,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-cursor`: 78 unit + 8 integration round-trip + 1 real-DB sanity + 1 doc test (state.vscdb SQLite reader, bubble store + composer header parsing, content-addressed blob lookup, projector with full TOOL_TABLE coverage, JSONL transcript ingest in `examples/dump_fixture.rs`) - `toolpath-pi`: 133 unit + 26 integration + 5 doc tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `path-cli`: 357 unit + 121 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. +- `path-cli`: 359 unit + 121 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. - `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary) Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- p validate --input "$f"; done` @@ -282,5 +282,5 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. When the manifest shows the picked session unchanged since its last sync (`sync::fresh_cache_id`: stamps match, doc present; the freshness stat targets that one artifact directly, no sibling enumeration), share uploads the cached doc directly instead of re-deriving. The cache ingests maximally (thinking always included); `share` and `p export pathbase` strip thinking from uploads by default (`--include-thinking` opts in; the strip also scrubs sub-agent turns embedded in `delegations`, recursively) — local projection (`resume`, `p export `) keeps everything. - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. -- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs every artifact type. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: the *whole session chain* — max segment mtime + summed segment sizes via `claude_chain_stamp`, because Claude Code rotates to a new file on continuation while the chain keeps its oldest segment's id, so appends land in the newest file, not the head; the chain comes from the same cached index `list_conversations` builds; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek; copilot: `session-state//events.jsonl`, pure read-dir + stat) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed after each type; writers serialize on an advisory lock (`sync.json.lock`) and every write is a locked read-merge-save — sync checkpoints merge only the records the run wrote — so concurrent invocations (query auto-syncs, imports) union their records instead of clobbering each other. Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record's `cache_id` is *optional*: a record without one is "known, not materialized" — created when a `--parent-dir` constraint excluded a peeked artifact, or when `p cache rm` evicts a doc (rm downgrades the record; the next in-scope sync re-materializes it, and sync also verifies the doc file actually exists before skipping, so even out-of-band deletions self-heal). `--parent-dir ` / `-d` on both `p cache sync` and `path query` restricts ingestion to artifacts under that directory (subtree): path-keyed providers prune whole projects before enumerating (claude compares in *slug space* — its dir slugs are lossy, `/`/`_`/`.` all became `-`), cwd-keyed ones check the directory their cheap headers carry, and codex/copilot — whose cwd lives inside the session file — get a one-line peek only when new/changed, memoized into the record's `path`. The stat gate always runs first: unchanged+cached artifacts skip before any scope check. Out-of-scope work is tallied separately (`N out of scope`) and never touches a materialized record's stamp. Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's own recorded cwd rather than the lossy slug. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactStub` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_stub` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. +- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/sync.rs`) incrementally ingests artifacts into the cache — no args syncs every artifact type. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactStub` whose fingerprint is the source file's mtime + size (claude: the *whole session chain* — max segment mtime + summed segment sizes via `claude_chain_stamp`, because Claude Code rotates to a new file on continuation while the chain keeps its oldest segment's id, so appends land in the newest file, not the head; the chain comes from the same cached index `list_conversations` builds; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek; copilot: `session-state//events.jsonl`, pure read-dir + stat) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (the `derive_*_session_with` helpers in `cmd_import.rs`). Manifest at `~/.toolpath/sync.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed every 10 writes (interruption-safe: a killed run keeps nearly everything it derived, and derives run newest-first so partial progress covers the sessions that matter most); writers serialize on an advisory lock (`sync.json.lock`) and every write is a locked read-merge-save — checkpoints merge only the records the run wrote — so concurrent invocations (query auto-syncs, imports) union their records instead of clobbering each other. Pending work reports progress on stderr (`\r`-updating ` done/total` on a TTY, a plain line every 25 items otherwise; no-op syncs stay silent). Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record's `cache_id` is *optional*: a record without one is "known, not materialized" — created when a `--parent-dir` constraint excluded a peeked artifact, or when `p cache rm` evicts a doc (rm downgrades the record; the next in-scope sync re-materializes it, and sync also verifies the doc file actually exists before skipping, so even out-of-band deletions self-heal). `--parent-dir ` / `-d` on both `p cache sync` and `path query` restricts ingestion to artifacts under that directory (subtree): path-keyed providers prune whole projects before enumerating (claude compares in *slug space* — its dir slugs are lossy, `/`/`_`/`.` all became `-`), cwd-keyed ones check the directory their cheap headers carry, and codex/copilot — whose cwd lives inside the session file — get a one-line peek only when new/changed, memoized into the record's `path`. The stat gate always runs first: unchanged+cached artifacts skip before any scope check. Out-of-scope work is tallied separately (`N out of scope`) and never touches a materialized record's stamp. Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's own recorded cwd rather than the lossy slug. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactStub` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_stub` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. - `ArtifactType` (`crates/path-cli/src/sync.rs`) is the general enum naming artifact sources — the seven agent harnesses (incl. copilot) plus `Git` (8 variants). Git artifacts are *recorded* in the manifest by `p import git` (id `-`, `path` = the repo directory) but never *discovered* — there is no machine-wide registry of repos — so sync reports them and leaves them alone. Github and pathbase are deliberately not artifact types: they are remote services, not local artifact sources, and their imports stay out of the manifest. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`cmd_share.rs`) names the seven agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/crates/path-cli/src/sync.rs b/crates/path-cli/src/sync.rs index 2e256d2c..65212dea 100644 --- a/crates/path-cli/src/sync.rs +++ b/crates/path-cli/src/sync.rs @@ -263,13 +263,14 @@ mod engine { out } - /// Sync the given artifact types from `bundle` into the cache. The - /// manifest is checkpointed after each type so an interrupted first - /// run doesn't forget the types it already finished. Reads come - /// from a point-in-time snapshot; each checkpoint merges only the - /// records this run wrote, under the manifest lock, so concurrent - /// invocations (query auto-syncs, imports) union their records - /// instead of clobbering each other. + /// Sync the given artifact types from `bundle` into the cache, + /// newest artifacts first. The manifest is checkpointed every few + /// writes (see [`CHECKPOINT_EVERY`]), so an interrupted run keeps + /// nearly everything it derived. Reads come from a point-in-time + /// snapshot; each checkpoint merges only the records this run + /// wrote, under the manifest lock, so concurrent invocations + /// (query auto-syncs, imports) union their records instead of + /// clobbering each other. pub(crate) fn sync_bundle( bundle: &HarnessBundle, types: &[ArtifactType], @@ -283,48 +284,105 @@ mod engine { .get(artifact_type.name()) .cloned() .unwrap_or_default(); - let (outcome, writes) = sync_stubs(bundle, &stubs, &records, parent_dir)?; - if !writes.is_empty() { - update_manifest(|m| { - m.entry(artifact_type.name().to_string()) - .or_default() - .extend(writes); - })?; - } + let outcome = sync_stubs(bundle, &stubs, &records, parent_dir)?; out.push((artifact_type, outcome)); } Ok(out) } + /// How many manifest writes accumulate before a mid-run checkpoint. + /// Small enough that an interrupted first sync loses at most a few + /// records (the cache docs themselves survive either way); large + /// enough that manifest serialization stays noise against the + /// derives it punctuates. + const CHECKPOINT_EVERY: usize = 10; + + /// The stat gate: a materialized record whose real stamps match the + /// stub needs nothing — no read, no scope check. All-`None` stamps + /// mean freshness is unknowable; only a real stamp can vouch + /// (mirrors `record_is_current`). + fn is_unchanged(rec: Option<&SyncRecord>, stub: &ArtifactStub) -> bool { + rec.is_some_and(|rec| { + (rec.modified.is_some() || rec.size.is_some()) + && rec.modified == stub.modified + && rec.size == stub.size + && rec + .cache_id + .as_deref() + .is_some_and(|id| crate::cmd_cache::cache_path(id).is_ok_and(|p| p.exists())) + }) + } + + /// Stubs newest-first (unstamped last), so an interrupted run has + /// spent its time on the sessions the user most likely wants. + fn newest_first(stubs: &[ArtifactStub]) -> Vec<&ArtifactStub> { + let mut order: Vec<&ArtifactStub> = stubs.iter().collect(); + order.sort_by(|a, b| b.modified.cmp(&a.modified)); + order + } + + /// Merge staged records into the manifest under the lock and clear + /// the stage. + fn flush_writes( + pending: &mut BTreeMap<&'static str, BTreeMap>, + ) -> Result<()> { + if pending.is_empty() { + return Ok(()); + } + let batch = std::mem::take(pending); + update_manifest(move |manifest| { + for (name, records) in batch { + manifest + .entry(name.to_string()) + .or_default() + .extend(records); + } + }) + } + /// Sync one type's stubs against a snapshot of its manifest records, - /// returning the records this run wrote (for a locked merge by the - /// caller). Derivation failures are warned and tallied, not fatal; - /// cache-write failures (disk, permissions) abort. + /// newest first. Records are checkpointed to the manifest every + /// [`CHECKPOINT_EVERY`] writes (and once more at the end), so an + /// interrupted run keeps nearly everything it derived. Derivation + /// failures are warned and tallied, not fatal; cache-write failures + /// (disk, permissions) abort. fn sync_stubs( bundle: &HarnessBundle, stubs: &[ArtifactStub], records: &BTreeMap, parent_dir: Option<&Path>, - ) -> Result<(SyncOutcome, BTreeMap)> { + ) -> Result { let mut outcome = SyncOutcome::default(); - let mut writes: BTreeMap = BTreeMap::new(); - for stub in stubs { - let existing = records.get(&stub.id); - let is_new = existing.is_none(); - // Stat gate first, always: a materialized, unchanged artifact - // needs nothing — no read, no scope check. All-`None` stamps - // mean freshness is unknowable; only a real stamp can vouch - // (mirrors `record_is_current`). - if let Some(rec) = existing - && (rec.modified.is_some() || rec.size.is_some()) - && rec.modified == stub.modified - && rec.size == stub.size - && let Some(cache_id) = &rec.cache_id - && crate::cmd_cache::cache_path(cache_id).is_ok_and(|p| p.exists()) - { + // Evaluate the stat gate once per stub: the pass feeds both the + // progress denominator and the loop's skip decision. + let order: Vec<(&ArtifactStub, bool)> = newest_first(stubs) + .into_iter() + .map(|stub| (stub, is_unchanged(records.get(&stub.id), stub))) + .collect(); + let pending_total = order.iter().filter(|(_, unchanged)| !unchanged).count(); + let mut progress = Progress::start( + stubs + .first() + .map(|s| s.artifact_type.symbol()) + .unwrap_or(""), + pending_total, + ); + let mut writes: BTreeMap<&'static str, BTreeMap> = BTreeMap::new(); + let mut unflushed = 0usize; + for (stub, unchanged) in order { + if unchanged { outcome.unchanged += 1; continue; } + let existing = records.get(&stub.id); + let is_new = existing.is_none(); + let stage = |writes: &mut BTreeMap<&'static str, BTreeMap>, + record: SyncRecord| { + writes + .entry(stub.artifact_type.name()) + .or_default() + .insert(stub.id.clone(), record); + }; // Scope gate: only artifacts that would cost a derive get the // constraint check (with a bounded peek for codex, memoized in // the record so it happens at most once per artifact). @@ -348,8 +406,8 @@ mod engine { // of a materialized record, or its staleness would be // masked from the next in-scope sync. if existing.is_none_or(|r| r.cache_id.is_none()) { - writes.insert( - stub.id.clone(), + stage( + &mut writes, SyncRecord { path: dir, cache_id: None, @@ -358,6 +416,12 @@ mod engine { synced_at: Utc::now(), }, ); + unflushed += 1; + } + progress.tick(); + if unflushed >= CHECKPOINT_EVERY { + flush_writes(&mut writes)?; + unflushed = 0; } continue; } @@ -374,8 +438,8 @@ mod engine { // prior manual `p import` of the same session must not // error on the existing cache entry. write_cached(&derived.cache_id, &derived.doc, true)?; - writes.insert( - stub.id.clone(), + stage( + &mut writes, SyncRecord { path: memoized_path, cache_id: Some(derived.cache_id), @@ -387,6 +451,7 @@ mod engine { synced_at: Utc::now(), }, ); + unflushed += 1; if is_new { outcome.new += 1; } else { @@ -394,6 +459,7 @@ mod engine { } } Err(e) => { + progress.interrupt(); eprintln!( "warning: sync {}: {}: {e}", stub.artifact_type.name(), @@ -402,8 +468,70 @@ mod engine { outcome.failed += 1; } } + progress.tick(); + if unflushed >= CHECKPOINT_EVERY { + flush_writes(&mut writes)?; + unflushed = 0; + } + } + flush_writes(&mut writes)?; + progress.interrupt(); + Ok(outcome) + } + + /// Live sync progress on stderr: a `\r`-updating ` done/total` + /// line on a terminal, a plain line every 25 items otherwise. Only + /// artifacts needing work count toward the total — a no-op sync + /// draws nothing. + struct Progress { + label: &'static str, + total: usize, + done: usize, + tty: bool, + } + + impl Progress { + fn start(label: &'static str, total: usize) -> Self { + use std::io::IsTerminal; + let progress = Self { + label, + total, + done: 0, + tty: std::io::stderr().is_terminal(), + }; + progress.draw(); + progress + } + + fn line(&self) -> String { + format!("{} {}/{}", self.label, self.done, self.total) + } + + fn draw(&self) { + if self.total > 0 && self.tty { + eprint!("\r{}", self.line()); + } + } + + fn tick(&mut self) { + if self.total == 0 { + return; + } + self.done += 1; + if self.tty { + self.draw(); + } else if self.done.is_multiple_of(25) { + eprintln!("{}", self.line()); + } + } + + /// Clear the live line so a warning or summary prints clean; + /// the next `tick` redraws in full. + fn interrupt(&self) { + if self.total > 0 && self.tty { + eprint!("\r\x1b[2K"); + } } - Ok((outcome, writes)) } /// Derive one artifact through the same manager it was enumerated @@ -1349,12 +1477,12 @@ mod engine { let mut stubs = enumerate_stubs(&bundle, ArtifactType::Claude, None); stubs.push(make_stub(ArtifactType::Claude, "does-not-exist")); - let (outcome, writes) = - sync_stubs(&bundle, &stubs, &BTreeMap::new(), None).unwrap(); + let outcome = sync_stubs(&bundle, &stubs, &BTreeMap::new(), None).unwrap(); assert_eq!((outcome.new, outcome.failed), (1, 1)); - assert!(writes.contains_key("sess-aaa")); + let records = &load_manifest().unwrap()["claude"]; + assert!(records.contains_key("sess-aaa")); assert!( - !writes.contains_key("does-not-exist"), + !records.contains_key("does-not-exist"), "failed artifacts must not be recorded as synced" ); }); @@ -1423,7 +1551,7 @@ mod engine { rec.modified = None; rec.size = None; let stub = make_stub(ArtifactType::Claude, "sess-aaa"); - let (outcome, _) = sync_stubs(&bundle, &[stub], &records, None).unwrap(); + let outcome = sync_stubs(&bundle, &[stub], &records, None).unwrap(); assert_eq!((outcome.updated, outcome.unchanged), (1, 0)); }); } @@ -1777,6 +1905,32 @@ mod engine { assert!(err.to_string().contains("provider not available")); } + #[test] + fn newest_first_orders_by_mtime_with_unstamped_last() { + let mut old = make_stub(ArtifactType::Claude, "old"); + old.modified = Some("2026-01-01T00:00:00Z".parse().unwrap()); + let mut new = make_stub(ArtifactType::Claude, "new"); + new.modified = Some("2026-07-01T00:00:00Z".parse().unwrap()); + let unstamped = make_stub(ArtifactType::Claude, "unstamped"); + + let stubs = vec![old, unstamped, new]; + let ids: Vec<&str> = newest_first(&stubs).iter().map(|s| s.id.as_str()).collect(); + assert_eq!(ids, vec!["new", "old", "unstamped"]); + } + + #[test] + fn progress_line_counts_only_pending_work() { + let mut progress = Progress { + label: "claude ", + total: 3, + done: 0, + tty: false, + }; + progress.tick(); + progress.tick(); + assert_eq!(progress.line(), "claude 2/3"); + } + #[test] fn resolve_types_defaults_to_all_and_dedups() { assert_eq!(resolve_types(&[]), ArtifactType::ALL.to_vec());