diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a64f550..50d28b0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ All notable changes to the Toolpath workspace are documented here. +## Warm picker opens are effectively instant — 2026-08-04 + +The session picker (`path share`, and bare `path resume` once #154 +lands) re-scanned every session file on every open just to rebuild the +same row metadata. Now a stat-stamp listing cache remembers each row +next to the same mtime+size fingerprint the sync manifest uses, so a +gather where nothing changed does stat-level enumeration and no +session reads at all. + +- **path-cli** (0.16.3): `gather_artifacts` consults + `~/.toolpath/listing-cache.json` for the three expensive providers — + claude (keyed by chain head, whole-chain stamp, so appends after a + rotation still invalidate), codex, and opencode — and re-scans only + new or changed sessions. Rows rebuilt from cache are field-for-field + identical to a fresh scan (`matches_cwd` is recomputed per gather, + never cached); artifacts deleted upstream drop out of both picker + and cache. The file is a pure cache: corrupt or missing content + means a normal full scan, never an error. gemini / pi / copilot / + cursor already scan in well under a second and stay on the direct + path. + +## The share picker gathers harnesses in parallel — 2026-08-04 + +The unified session picker (`path share`, and bare `path resume` once +it lands) enumerated the seven providers one after another, so the +slowest scan — a big codex or claude history — stacked on top of all +the others before anything appeared. + +- **path-cli** (0.16.2): `gather_artifacts` runs the provider scans in + scoped threads, making pre-picker wall time max-of-providers instead + of sum-of-providers. Claude scans inline on the calling thread (its + chain-index cache is single-threaded); everything else fans out. + Row concatenation keeps the old provider order, so ranking + tie-breaks are unchanged. + ## Codex session listing no longer parses every byte — 2026-08-04 Listing Codex sessions read every rollout file end to end through @@ -18,20 +53,6 @@ upcoming bare `resume` picker — into a minute-plus silent stall. method: a first prompt buried past the head budget reports as `None`, and `line_count` counts non-empty lines rather than successfully parsed ones. -## The share picker gathers harnesses in parallel — 2026-08-04 - -The unified session picker (`path share`, and bare `path resume` once -it lands) enumerated the seven providers one after another, so the -slowest scan — a big codex or claude history — stacked on top of all -the others before anything appeared. - -- **path-cli** (0.16.2): `gather_artifacts` runs the provider scans in - scoped threads, making pre-picker wall time max-of-providers instead - of sum-of-providers. Claude scans inline on the calling thread (its - chain-index cache is single-threaded); everything else fans out. - Row concatenation keeps the old provider order, so ranking - tie-breaks are unchanged. - ## Projected Claude sessions are resumable again — 2026-07-30 Two fixes found by live-resuming a projected session against the real diff --git a/Cargo.lock b/Cargo.lock index 02a1ca0d..08c5b8d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2441,7 +2441,7 @@ dependencies = [ [[package]] name = "path-cli" -version = "0.16.2" +version = "0.16.3" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 10ecf9f7..3dffef1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ 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.16.2", path = "crates/path-cli" } +path-cli = { version = "0.16.3", 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/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index 137839d0..348e3a67 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "path-cli" -version = "0.16.2" +version = "0.16.3" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index aecf1a02..51a09510 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -8,12 +8,14 @@ use chrono::{DateTime, Utc}; use clap::Args; use std::path::PathBuf; -use crate::artifact::ArtifactType; +use crate::artifact::{ArtifactRef, ArtifactType}; use crate::cmd_export::RepoSpec; use crate::harness::{ - Harness, HarnessBundle, is_not_found_claude, is_not_found_codex, is_not_found_copilot, - is_not_found_cursor, is_not_found_gemini, is_not_found_opencode, is_not_found_pi, + Harness, HarnessBundle, is_not_found_copilot, is_not_found_cursor, is_not_found_gemini, + is_not_found_pi, }; +use crate::listing_cache::{CachedListing, CachedRow, ListingCache, ProviderListings}; +use crate::sync::sources::{ArtifactSource, claude_source, codex_source, opencode_source}; #[derive(Args, Debug)] pub struct ShareArgs { @@ -61,7 +63,7 @@ pub struct ShareArgs { /// 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)] +#[derive(Debug, Clone, PartialEq)] pub(crate) struct ArtifactRow { pub(crate) artifact_type: ArtifactType, /// Project path for keyed providers; `None` for codex/opencode. @@ -95,6 +97,17 @@ pub(crate) fn gather_artifacts( let want = |h: ArtifactType| harness_filter.is_none_or(|f| f == h); + // The listing cache: rows for artifacts whose stat stamp is + // unchanged since the last gather are rebuilt from cached fields + // instead of re-scanned. Loaded once here; the cache-backed + // collectors (claude/codex/opencode — the expensive scans) each + // take their section and return a refreshed one, written back + // after the fan-out only if something actually changed. + let mut listing_cache = ListingCache::load(); + let claude_cache = listing_cache.section(ArtifactType::Claude); + let codex_cache = listing_cache.section(ArtifactType::Codex); + let opencode_cache = listing_cache.section(ArtifactType::Opencode); + // Enumerate providers concurrently: each is an independent // read-only scan of its own on-disk tree, and the slowest (a big // codex or claude history) otherwise serializes behind the rest. @@ -105,10 +118,12 @@ pub(crate) fn gather_artifacts( // the old sequential provider order, so the stable sort's // tie-breaking matches the previous behavior exactly. let mut rows = Vec::new(); + let mut refreshed: Vec<(ArtifactType, ProviderListings)> = Vec::new(); let cwd_ref = &canonical_cwd; let project_ref = canonical_project.as_deref(); + type CollectOutput = (Vec, Option<(ArtifactType, ProviderListings)>); std::thread::scope(|s| { - let mut handles = Vec::new(); + let mut handles: Vec> = Vec::new(); macro_rules! spawn_collect { ($ty:expr, $mgr:expr, $collect:ident) => { @@ -118,7 +133,22 @@ pub(crate) fn gather_artifacts( handles.push(s.spawn(move || { let mut out = Vec::new(); $collect(mgr, cwd_ref, project_ref, &mut out); - out + (out, None) + })); + } + }; + } + + macro_rules! spawn_collect_cached { + ($ty:expr, $mgr:expr, $collect:ident, $cache:expr) => { + if want($ty) + && let Some(mgr) = $mgr + { + let cache = $cache; + handles.push(s.spawn(move || { + let mut out = Vec::new(); + let fresh = $collect(mgr, cwd_ref, project_ref, cache, &mut out); + (out, Some(($ty, fresh))) })); } }; @@ -126,28 +156,50 @@ pub(crate) fn gather_artifacts( spawn_collect!(ArtifactType::Gemini, &bundle.gemini, collect_gemini); spawn_collect!(ArtifactType::Pi, &bundle.pi, collect_pi); - spawn_collect!(ArtifactType::Codex, &bundle.codex, collect_codex); + spawn_collect_cached!( + ArtifactType::Codex, + &bundle.codex, + collect_codex, + &codex_cache + ); spawn_collect!(ArtifactType::Copilot, &bundle.copilot, collect_copilot); - spawn_collect!(ArtifactType::Opencode, &bundle.opencode, collect_opencode); + spawn_collect_cached!( + ArtifactType::Opencode, + &bundle.opencode, + collect_opencode, + &opencode_cache + ); spawn_collect!(ArtifactType::Cursor, &bundle.cursor, collect_cursor); if want(ArtifactType::Claude) && let Some(mgr) = &bundle.claude { - collect_claude(mgr, cwd_ref, project_ref, &mut rows); + let fresh = collect_claude(mgr, cwd_ref, project_ref, &claude_cache, &mut rows); + refreshed.push((ArtifactType::Claude, fresh)); } for handle in handles { match handle.join() { - Ok(out) => rows.extend(out), + Ok((out, section)) => { + rows.extend(out); + if let Some(section) = section { + refreshed.push(section); + } + } // A panicking collector degrades to "that provider is // missing from the picker", matching how collector-level - // errors already warn-and-continue. + // errors already warn-and-continue. Its cache section is + // left untouched. Err(_) => eprintln!("warning: a session collector panicked; its rows are skipped"), } } }); + for (artifact_type, fresh) in refreshed { + listing_cache.replace_section(artifact_type, fresh); + } + listing_cache.save_if_dirty(); + rows.sort_by(|a, b| { b.matches_cwd .cmp(&a.matches_cwd) @@ -164,51 +216,191 @@ fn paths_match(a: &std::path::Path, b: &std::path::Path) -> bool { canonicalize_or_self(a) == canonicalize_or_self(b) } -fn collect_claude( - mgr: &toolpath_claude::ClaudeConvo, +// ── stat-stamp listing cache plumbing ────────────────────────────── +// +// The expensive providers (claude/codex/opencode) collect through +// `collect_with_cache`: enumerate stat-level `ArtifactRef`s via the +// same `ArtifactSource` machinery sync uses, rebuild rows from the +// listing cache for every artifact whose stamp still matches, and run +// the real metadata scan only for what is new or changed. See +// `docs/superpowers/specs/2026-08-04-listing-cache-design.md`. + +/// How a provider's fresh scan orders its rows — reproduced on +/// cache-backed gathers so a warm gather's output is field-for-field +/// identical to a cold one, ties included. +enum ListingOrder { + /// claude (and the other project-keyed providers): projects in + /// enumeration order, sessions within each project by descending + /// last activity. + ByActivityWithinPathRuns, + /// codex/opencode: descending last activity across the provider. + ByActivity, +} + +/// Stable-sort `rows[start..]` by descending last activity — the same +/// ordering (`sort_by_key(Reverse(last_activity))`, `None` last) every +/// provider's fresh listing applies. +fn sort_rows_by_activity(rows: &mut [ArtifactRow], start: usize) { + rows[start..].sort_by_key(|r| std::cmp::Reverse(r.last_activity)); +} + +/// Rebuild a picker row from cached fields. `matches_cwd` is +/// deliberately not cached — it depends on the caller's cwd — so it is +/// recomputed here from the cached `path`/`cwd` with the same +/// canonicalized matching the fresh scans use. +fn row_from_cached( + artifact_type: ArtifactType, + cached: &CachedRow, + canonical_cwd: &std::path::Path, +) -> ArtifactRow { + let key = cached.path.as_deref().or(cached.cwd.as_deref()); + let matches_cwd = key.is_some_and(|k| paths_match(std::path::Path::new(k), canonical_cwd)); + ArtifactRow { + artifact_type, + path: cached.path.clone(), + cwd: cached.cwd.clone(), + session_id: cached.session_id.clone(), + title: cached.title.clone(), + last_activity: cached.last_activity, + message_count: cached.message_count, + matches_cwd, + } +} + +/// The cacheable complement of [`row_from_cached`]. +fn cached_from_row(row: &ArtifactRow) -> CachedRow { + CachedRow { + path: row.path.clone(), + cwd: row.cwd.clone(), + session_id: row.session_id.clone(), + title: row.title.clone(), + last_activity: row.last_activity, + message_count: row.message_count, + } +} + +/// Whether `row` survives the `--project` filter: its project (keyed +/// providers) or recorded cwd canonicalizes to the filter path. Rows +/// with neither are dropped under a filter, exactly like the fresh +/// scans. Applied after cache reconstruction — the cache itself is +/// filter-agnostic. +fn row_passes_project_filter(row: &ArtifactRow, project_filter: Option<&std::path::Path>) -> bool { + let Some(filter) = project_filter else { + return true; + }; + let key = row.path.as_deref().or(row.cwd.as_deref()); + key.is_some_and(|k| paths_match(std::path::Path::new(k), filter)) +} + +/// The shared cache-backed collection loop: walk the enumerated refs +/// in listing order, rebuild rows from the cache on a stamp hit, call +/// `scan_miss` otherwise (a `None` means the scan failed — warned by +/// the provider closure — and the artifact is neither listed nor +/// cached, so the next gather retries it). Returns the refreshed +/// section, which contains exactly the enumerated artifacts — +/// anything that vanished upstream drops out, matching sync's +/// self-heal semantics. +#[allow(clippy::too_many_arguments)] +fn collect_with_cache( + artifact_type: ArtifactType, + refs: &[ArtifactRef], + cache: &ProviderListings, + order: ListingOrder, + mut scan_miss: impl FnMut(&ArtifactRef) -> Option, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, out: &mut Vec, -) { - let projects = match mgr.list_projects() { - Ok(ps) if !ps.is_empty() => ps, - Ok(_) => return, - Err(e) if is_not_found_claude(&e) => return, - Err(e) => { - eprintln!("warning: claude aggregation failed: {e}"); - return; - } - }; - for project in projects { - let project_path = std::path::Path::new(&project); - if let Some(filter) = project_filter - && !paths_match(project_path, filter) +) -> ProviderListings { + let mut fresh = ProviderListings::new(); + let mut rows: Vec = Vec::with_capacity(refs.len()); + let mut run_start = 0usize; + let mut prev_path: Option<&Option> = None; + for r in refs { + // Project-keyed enumerations are project-major; close each + // run with the within-project activity sort the fresh scan + // applies per project. + if matches!(order, ListingOrder::ByActivityWithinPathRuns) + && prev_path.is_some_and(|p| p != &r.path) { - continue; + sort_rows_by_activity(&mut rows, run_start); + run_start = rows.len(); } - let metas = match mgr.list_conversation_metadata(&project) { - Ok(m) => m, - Err(e) => { - eprintln!("warning: claude project {project} failed: {e}"); - continue; + prev_path = Some(&r.path); + let row = match cache.get(&r.id) { + Some(entry) if entry.matches(r) => { + Some(row_from_cached(artifact_type, &entry.row, canonical_cwd)) } + _ => scan_miss(r), }; - let matches_cwd = paths_match(project_path, canonical_cwd); - for m in metas { - out.push(ArtifactRow { - 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: Some(m.message_count), - matches_cwd, - }); + if let Some(row) = row { + fresh.insert( + r.id.clone(), + CachedListing { + modified: r.modified, + size: r.size, + row: cached_from_row(&row), + }, + ); + rows.push(row); } } + // Final run — for `ByActivity` this is the whole provider. + sort_rows_by_activity(&mut rows, run_start); + out.extend( + rows.into_iter() + .filter(|r| row_passes_project_filter(r, project_filter)), + ); + fresh +} + +fn collect_claude( + mgr: &toolpath_claude::ClaudeConvo, + canonical_cwd: &std::path::Path, + project_filter: Option<&std::path::Path>, + cache: &ProviderListings, + out: &mut Vec, +) -> ProviderListings { + // Chain heads with whole-chain stamps (`claude_chain_stamp`): an + // append to any segment of a chain invalidates the head's entry. + let refs = claude_source(mgr).enumerate(); + collect_with_cache( + ArtifactType::Claude, + &refs, + cache, + ListingOrder::ByActivityWithinPathRuns, + |r| { + let project = r.path.as_deref()?; + match mgr.read_conversation_metadata(project, &r.id) { + Ok(m) => Some(claude_row(m, canonical_cwd)), + Err(e) => { + eprintln!("Warning: Failed to read metadata for {}: {e}", r.id); + None + } + } + }, + canonical_cwd, + project_filter, + out, + ) +} + +fn claude_row( + m: toolpath_claude::ConversationMetadata, + canonical_cwd: &std::path::Path, +) -> ArtifactRow { + let matches_cwd = paths_match(std::path::Path::new(&m.project_path), canonical_cwd); + ArtifactRow { + 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: Some(m.message_count), + matches_cwd, + } } fn collect_gemini( @@ -322,45 +514,64 @@ fn collect_codex( mgr: &toolpath_codex::CodexConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, + cache: &ProviderListings, out: &mut Vec, -) { - let metas = match mgr.list_sessions() { - Ok(m) if !m.is_empty() => m, - Ok(_) => return, - Err(e) if is_not_found_codex(&e) => return, - Err(e) => { - eprintln!("warning: codex aggregation failed: {e}"); - return; - } - }; - for m in metas { - let cwd_str = m.cwd.as_ref().map(|p| p.to_string_lossy().into_owned()); - if let Some(filter) = project_filter { - let stored = match cwd_str.as_deref() { - Some(s) => std::path::PathBuf::from(s), - None => continue, - }; - if !paths_match(&stored, filter) { - continue; +) -> ProviderListings { + let refs = codex_source(mgr).enumerate(); + // One lazy directory walk maps session id → rollout path for the + // misses; a per-miss `find_rollout_file` would re-walk the whole + // date-bucketed tree every time. + let mut files: Option> = None; + collect_with_cache( + ArtifactType::Codex, + &refs, + cache, + ListingOrder::ByActivity, + |r| { + let files = files.get_or_insert_with(|| { + mgr.io() + .list_rollout_files() + .unwrap_or_default() + .into_iter() + .filter_map(|p| { + let stem = p.file_stem()?.to_str()?; + Some((toolpath_codex::session_id_from_stem(stem).to_string(), p)) + }) + .collect() + }); + let file = files.get(&r.id)?; + match mgr.io().read_metadata(file) { + Ok(m) => Some(codex_row(m, canonical_cwd)), + Err(e) => { + eprintln!("Warning: failed to read {}: {e}", file.display()); + None + } } - } - let matches_cwd = m - .cwd - .as_deref() - .map(|p| paths_match(p, canonical_cwd)) - .unwrap_or(false); - out.push(ArtifactRow { - 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: Some(m.line_count), - matches_cwd, - }); + }, + canonical_cwd, + project_filter, + out, + ) +} + +fn codex_row(m: toolpath_codex::SessionMetadata, canonical_cwd: &std::path::Path) -> ArtifactRow { + let cwd_str = m.cwd.as_ref().map(|p| p.to_string_lossy().into_owned()); + let matches_cwd = m + .cwd + .as_deref() + .map(|p| paths_match(p, canonical_cwd)) + .unwrap_or(false); + ArtifactRow { + 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: Some(m.line_count), + matches_cwd, } } @@ -411,40 +622,59 @@ fn collect_opencode( mgr: &toolpath_opencode::OpencodeConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, + cache: &ProviderListings, out: &mut Vec, -) { - let metas = match mgr.io().list_session_metadata(None) { - Ok(m) if !m.is_empty() => m, - Ok(_) => return, - Err(e) if is_not_found_opencode(&e) => return, - Err(e) => { - eprintln!("warning: opencode aggregation failed: {e}"); - return; - } +) -> ProviderListings { + let refs = opencode_source(mgr).enumerate(); + // opencode metadata is one DB pass over every session (it loads + // each session's messages), so the first miss triggers the full + // scan once and later misses read from it. A fully-warm gather + // never opens the message tables at all. + let mut metas: Option> = None; + collect_with_cache( + ArtifactType::Opencode, + &refs, + cache, + ListingOrder::ByActivity, + |r| { + let metas = metas.get_or_insert_with(|| match mgr.io().list_session_metadata(None) { + Ok(ms) => ms + .into_iter() + .map(|m| (m.id.clone(), opencode_row(m, canonical_cwd))) + .collect(), + Err(e) => { + eprintln!("warning: opencode aggregation failed: {e}"); + std::collections::HashMap::new() + } + }); + metas.get(&r.id).cloned() + }, + canonical_cwd, + project_filter, + out, + ) +} + +fn opencode_row( + m: toolpath_opencode::SessionMetadata, + canonical_cwd: &std::path::Path, +) -> ArtifactRow { + let matches_cwd = paths_match(&m.directory, canonical_cwd); + let cwd_str = m.directory.to_string_lossy().into_owned(); + let title = match (&m.first_user_message, m.title.is_empty()) { + (Some(s), _) if !s.is_empty() => s.clone(), + (_, false) => m.title.clone(), + _ => "(no prompt)".to_string(), }; - for m in metas { - if let Some(filter) = project_filter - && !paths_match(&m.directory, filter) - { - continue; - } - let matches_cwd = paths_match(&m.directory, canonical_cwd); - let cwd_str = m.directory.to_string_lossy().into_owned(); - let title = match (&m.first_user_message, m.title.is_empty()) { - (Some(s), _) if !s.is_empty() => s.clone(), - (_, false) => m.title.clone(), - _ => "(no prompt)".to_string(), - }; - out.push(ArtifactRow { - artifact_type: ArtifactType::Opencode, - path: None, - cwd: Some(cwd_str), - session_id: m.id, - title, - last_activity: m.last_activity, - message_count: Some(m.message_count), - matches_cwd, - }); + ArtifactRow { + artifact_type: ArtifactType::Opencode, + path: None, + cwd: Some(cwd_str), + session_id: m.id, + title, + last_activity: m.last_activity, + message_count: Some(m.message_count), + matches_cwd, } } @@ -970,14 +1200,81 @@ mod tests { use std::path::Path; use tempfile::TempDir; + /// Pin `$TOOLPATH_CONFIG_DIR` to a tempdir for the guard's + /// lifetime, so gathers read and write a scratch listing cache + /// instead of the developer's real `~/.toolpath`. Holds the shared + /// env lock to serialize with other env-mutating tests. + struct ScopedConfigDir { + temp: TempDir, + prev: Option, + _lock: std::sync::MutexGuard<'static, ()>, + } + + fn scoped_config_dir() -> ScopedConfigDir { + let lock = crate::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let temp = TempDir::new().unwrap(); + let prev = std::env::var_os(crate::config::CONFIG_DIR_ENV); + unsafe { + std::env::set_var(crate::config::CONFIG_DIR_ENV, temp.path().join(".toolpath")); + } + ScopedConfigDir { + temp, + prev, + _lock: lock, + } + } + + impl ScopedConfigDir { + /// The tempdir root, for provider fixtures that should live + /// and die with the pinned config dir. + fn root(&self) -> &Path { + self.temp.path() + } + + fn listing_cache_file(&self) -> std::path::PathBuf { + self.temp + .path() + .join(".toolpath") + .join(crate::config::LISTING_CACHE_FILE_NAME) + } + } + + impl Drop for ScopedConfigDir { + fn drop(&mut self) { + unsafe { + match &self.prev { + Some(v) => std::env::set_var(crate::config::CONFIG_DIR_ENV, v), + None => std::env::remove_var(crate::config::CONFIG_DIR_ENV), + } + } + } + } + fn write_claude_session(claude_dir: &Path, project_slug: &str, session: &str, prompt: &str) { + write_claude_session_at(claude_dir, project_slug, session, prompt, "2024-01-02"); + } + + /// Like [`write_claude_session`] but with a caller-chosen day, so + /// sibling fixtures get distinct `last_activity` values. (Rows with + /// identical timestamps tie-break on claude's chain-head + /// enumeration order, which is not stable run to run — true before + /// the listing cache too.) + fn write_claude_session_at( + claude_dir: &Path, + project_slug: &str, + session: &str, + prompt: &str, + day: &str, + ) { let project_dir = claude_dir.join("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}"}}}}"# + r#"{{"type":"user","uuid":"u-{session}","timestamp":"{day}T00: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"}}}}"# + r#"{{"type":"assistant","uuid":"a-{session}","timestamp":"{day}T00:00:01Z","message":{{"role":"assistant","content":"hi"}}}}"# ); std::fs::write( project_dir.join(format!("{session}.jsonl")), @@ -998,6 +1295,7 @@ mod tests { #[test] fn gather_artifacts_includes_claude_rows_for_a_project() { + let _cfg = scoped_config_dir(); let temp = TempDir::new().unwrap(); write_claude_session( &temp.path().join(".claude"), @@ -1018,6 +1316,7 @@ mod tests { #[test] fn gather_artifacts_marks_non_matching_project_rows() { + let _cfg = scoped_config_dir(); let temp = TempDir::new().unwrap(); write_claude_session( &temp.path().join(".claude"), @@ -1035,6 +1334,7 @@ mod tests { #[test] fn gather_artifacts_skips_harness_with_no_home_dir() { + let _cfg = scoped_config_dir(); // Empty bundle => no rows, no panic. let bundle = HarnessBundle::default(); let rows = gather_artifacts(&bundle, Path::new("/anywhere"), None, None); @@ -1043,6 +1343,7 @@ mod tests { #[test] fn gather_artifacts_filters_by_harness() { + let _cfg = scoped_config_dir(); let temp = TempDir::new().unwrap(); write_claude_session( &temp.path().join(".claude"), @@ -1080,6 +1381,7 @@ mod tests { #[test] fn gather_artifacts_includes_codex_rows_with_cwd_match() { + let _cfg = scoped_config_dir(); let temp = TempDir::new().unwrap(); write_codex_session( &temp.path().join(".codex"), @@ -1118,6 +1420,7 @@ mod tests { #[test] fn gather_sessions_includes_copilot_rows_with_cwd_match() { + let _cfg = scoped_config_dir(); let temp = TempDir::new().unwrap(); write_copilot_session(&temp.path().join(".copilot"), "sess-aa", "/work/proj"); let bundle = copilot_only_bundle(temp.path()); @@ -1130,6 +1433,7 @@ mod tests { #[test] fn gather_sessions_filters_to_copilot() { + let _cfg = scoped_config_dir(); let temp = TempDir::new().unwrap(); write_copilot_session(&temp.path().join(".copilot"), "sess-aa", "/work/proj"); let bundle = copilot_only_bundle(temp.path()); @@ -1145,6 +1449,7 @@ mod tests { #[test] fn gather_artifacts_ranks_cwd_matches_first() { + let _cfg = scoped_config_dir(); // 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(); @@ -1168,6 +1473,385 @@ mod tests { assert!(!rows[1].matches_cwd); } + // ── listing cache ────────────────────────────────────────────── + + /// A bundle with both cache-backed file providers: two claude + /// sessions in one project plus one codex rollout. + fn claude_codex_bundle(home: &Path) -> HarnessBundle { + let claude_dir = home.join(".claude"); + let codex_dir = home.join(".codex"); + std::fs::create_dir_all(&claude_dir).unwrap(); + std::fs::create_dir_all(&codex_dir).unwrap(); + HarnessBundle { + claude: Some(toolpath_claude::ClaudeConvo::with_resolver( + toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir), + )), + codex: Some(toolpath_codex::CodexConvo::with_resolver( + toolpath_codex::PathResolver::new().with_codex_dir(&codex_dir), + )), + ..Default::default() + } + } + + fn write_cache_fixtures(home: &Path) { + write_claude_session( + &home.join(".claude"), + "-test-project", + "sess-aaa", + "First topic", + ); + write_claude_session_at( + &home.join(".claude"), + "-test-project", + "sess-bbb", + "Second topic", + "2024-01-03", + ); + write_codex_session( + &home.join(".codex"), + "00000000-0000-0000-0000-0000000000aa", + "/work/proj", + ); + } + + fn append_line(file: &Path, line: &str) { + let mut body = std::fs::read_to_string(file).unwrap(); + body.push_str(line); + body.push('\n'); + std::fs::write(file, body).unwrap(); + } + + #[test] + fn warm_gather_reproduces_cold_gather_rows() { + let cfg = scoped_config_dir(); + let home = cfg.root(); + write_cache_fixtures(home); + + // Cold: nothing cached, everything scanned. + let cold = gather_artifacts( + &claude_codex_bundle(home), + Path::new("/test/project"), + None, + None, + ); + assert_eq!(cold.len(), 3); + assert!( + cfg.listing_cache_file().exists(), + "cold gather must write the listing cache" + ); + + // Warm, through a fresh bundle (new managers, like a new CLI + // invocation): rows must be field-for-field identical. + let warm = gather_artifacts( + &claude_codex_bundle(home), + Path::new("/test/project"), + None, + None, + ); + assert_eq!(warm, cold); + + // And the warm pass replaced nothing: sections carry one entry + // per artifact. + let cache = ListingCache::load(); + assert_eq!(cache.section(ArtifactType::Claude).len(), 2); + assert_eq!(cache.section(ArtifactType::Codex).len(), 1); + } + + #[test] + fn warm_gather_reads_rows_from_the_listing_cache() { + let cfg = scoped_config_dir(); + let home = cfg.root(); + write_cache_fixtures(home); + gather_artifacts( + &claude_codex_bundle(home), + Path::new("/test/project"), + None, + None, + ); + + // Tamper with a cached title while the source stamps stay + // put. A stamp hit must surface the cached row verbatim — + // proof the warm path reads the cache instead of re-scanning. + let file = cfg.listing_cache_file(); + let json = std::fs::read_to_string(&file).unwrap(); + assert!(json.contains("First topic")); + std::fs::write(&file, json.replace("First topic", "From the cache")).unwrap(); + + let warm = gather_artifacts( + &claude_codex_bundle(home), + Path::new("/test/project"), + None, + None, + ); + assert!( + warm.iter() + .any(|r| r.session_id == "sess-aaa" && r.title == "From the cache"), + "stamp-matched rows must come from the cache" + ); + } + + #[test] + fn appended_claude_session_invalidates_its_cached_row() { + let cfg = scoped_config_dir(); + let home = cfg.root(); + write_cache_fixtures(home); + let cold = gather_artifacts( + &claude_codex_bundle(home), + Path::new("/test/project"), + None, + None, + ); + let cold_row = cold.iter().find(|r| r.session_id == "sess-aaa").unwrap(); + assert_eq!(cold_row.message_count, Some(2)); + + // The session continues: a later user turn bumps the file's + // mtime and size, so the chain stamp no longer matches. + append_line( + &home.join(".claude/projects/-test-project/sess-aaa.jsonl"), + r#"{"type":"user","uuid":"u-2","timestamp":"2024-01-02T00:05:00Z","cwd":"/test/project","message":{"role":"user","content":"And another thing"}}"#, + ); + + let warm = gather_artifacts( + &claude_codex_bundle(home), + Path::new("/test/project"), + None, + None, + ); + let row = warm.iter().find(|r| r.session_id == "sess-aaa").unwrap(); + assert_eq!( + row.message_count, + Some(3), + "changed session must be re-scanned" + ); + // The untouched sibling still matches its cold row. + assert_eq!( + warm.iter().find(|r| r.session_id == "sess-bbb"), + cold.iter().find(|r| r.session_id == "sess-bbb"), + ); + } + + #[test] + fn rotated_claude_chain_invalidates_under_its_head_id() { + let cfg = scoped_config_dir(); + let home = cfg.root(); + write_claude_session(&home.join(".claude"), "-test-project", "sess-aaa", "Topic"); + let bundle = || claude_codex_bundle(home); + let cold = gather_artifacts(&bundle(), Path::new("/test/project"), None, None); + assert_eq!(cold.len(), 1); + assert_eq!(cold[0].message_count, Some(2)); + + // The session rotates: appends land in a successor file whose + // first entry bridges back to sess-aaa. The chain keeps the + // head id; the whole-chain stamp must invalidate the entry + // even though sess-aaa.jsonl itself never changed. + std::fs::write( + home.join(".claude/projects/-test-project/sess-ccc.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-ccc","cwd":"/test/project","message":{"role":"user","content":"after rotation"}}"#, + "\n", + ), + ) + .unwrap(); + + let warm = gather_artifacts(&bundle(), Path::new("/test/project"), None, None); + assert_eq!(warm.len(), 1, "successor segments are not separate rows"); + assert_eq!(warm[0].session_id, "sess-aaa"); + assert!( + warm[0].message_count.unwrap() > 2, + "post-rotation turns must reach the row" + ); + let cache = ListingCache::load(); + let section = cache.section(ArtifactType::Claude); + assert!(section.contains_key("sess-aaa")); + assert!( + !section.contains_key("sess-ccc"), + "cache keys by chain head id" + ); + } + + #[test] + fn appended_codex_rollout_invalidates_its_cached_row() { + let cfg = scoped_config_dir(); + let home = cfg.root(); + write_cache_fixtures(home); + let cold = gather_artifacts( + &claude_codex_bundle(home), + Path::new("/work/proj"), + None, + None, + ); + let cold_row = cold + .iter() + .find(|r| r.artifact_type == ArtifactType::Codex) + .unwrap(); + assert_eq!(cold_row.message_count, Some(2)); + + append_line( + &home.join( + ".codex/sessions/2026/05/07/rollout-2026-05-07T00-00-00-00000000-0000-0000-0000-0000000000aa.jsonl", + ), + r#"{"timestamp":"2026-05-07T00:00:02Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"done"}]}}"#, + ); + + let warm = gather_artifacts( + &claude_codex_bundle(home), + Path::new("/work/proj"), + None, + None, + ); + let row = warm + .iter() + .find(|r| r.artifact_type == ArtifactType::Codex) + .unwrap(); + assert_eq!(row.message_count, Some(3)); + } + + #[test] + fn deleted_artifact_drops_row_and_cache_entry() { + let cfg = scoped_config_dir(); + let home = cfg.root(); + write_cache_fixtures(home); + gather_artifacts( + &claude_codex_bundle(home), + Path::new("/test/project"), + None, + None, + ); + assert_eq!(ListingCache::load().section(ArtifactType::Codex).len(), 1); + + // The rollout vanishes upstream. Enumeration is authoritative: + // no row, and the stale cache entry self-heals away. + std::fs::remove_file(home.join( + ".codex/sessions/2026/05/07/rollout-2026-05-07T00-00-00-00000000-0000-0000-0000-0000000000aa.jsonl", + )) + .unwrap(); + + let warm = gather_artifacts( + &claude_codex_bundle(home), + Path::new("/test/project"), + None, + None, + ); + assert!( + warm.iter().all(|r| r.artifact_type != ArtifactType::Codex), + "deleted artifacts must not produce rows" + ); + assert!( + ListingCache::load().section(ArtifactType::Codex).is_empty(), + "deleted artifacts must drop out of the cache" + ); + } + + #[test] + fn corrupt_listing_cache_falls_back_to_fresh_scan() { + let cfg = scoped_config_dir(); + let home = cfg.root(); + write_cache_fixtures(home); + let cold = gather_artifacts( + &claude_codex_bundle(home), + Path::new("/test/project"), + None, + None, + ); + + std::fs::write(cfg.listing_cache_file(), "definitely not json").unwrap(); + let rows = gather_artifacts( + &claude_codex_bundle(home), + Path::new("/test/project"), + None, + None, + ); + assert_eq!(rows, cold, "a corrupt cache must never block the picker"); + } + + #[test] + fn project_filter_applies_to_cached_rows() { + let cfg = scoped_config_dir(); + let home = cfg.root(); + write_cache_fixtures(home); + let bundle = || claude_codex_bundle(home); + let cwd = Path::new("/test/project"); + + // A filtered cold gather still warms the cache for everyone + // (the filter is applied after reconstruction, not baked in). + let cold_filtered = gather_artifacts(&bundle(), cwd, None, Some(Path::new("/work/proj"))); + assert_eq!(cold_filtered.len(), 1); + assert_eq!(cold_filtered[0].artifact_type, ArtifactType::Codex); + let cache = ListingCache::load(); + assert_eq!(cache.section(ArtifactType::Claude).len(), 2); + assert_eq!(cache.section(ArtifactType::Codex).len(), 1); + + // Warm filtered gathers reproduce the cold filtered rows, and + // an unfiltered warm gather surfaces everything from cache. + let warm_filtered = gather_artifacts(&bundle(), cwd, None, Some(Path::new("/work/proj"))); + assert_eq!(warm_filtered, cold_filtered); + let warm_all = gather_artifacts(&bundle(), cwd, None, None); + assert_eq!(warm_all.len(), 3); + } + + /// Synthetic cold-vs-warm timing over a few hundred sessions. Not + /// a correctness gate — run manually with + /// `cargo test -p path-cli --release bench_listing_cache -- --ignored --nocapture`. + #[test] + #[ignore = "timing benchmark, run manually with --nocapture"] + fn bench_listing_cache_cold_vs_warm() { + let cfg = scoped_config_dir(); + let home = cfg.root(); + let claude_dir = home.join(".claude"); + let codex_dir = home.join(".codex"); + // 200 codex rollouts padded with filler turns + 100 claude + // sessions across 10 projects. + for i in 0..200 { + let id = format!("00000000-0000-0000-0000-0000000{i:05}"); + write_codex_session(&codex_dir, &id, "/work/proj"); + let file = codex_dir.join(format!( + "sessions/2026/05/07/rollout-2026-05-07T00-00-00-{id}.jsonl" + )); + let filler: String = (0..200) + .map(|n| format!( + r#"{{"timestamp":"2026-05-07T00:01:{:02}Z","type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"turn {n} pad pad pad pad pad pad pad pad pad pad pad pad"}}]}}}}{}"#, + n % 60, "\n" + )) + .collect(); + let mut body = std::fs::read_to_string(&file).unwrap(); + body.push_str(&filler); + std::fs::write(&file, body).unwrap(); + } + for p in 0..10 { + for s in 0..10 { + write_claude_session_at( + &claude_dir, + &format!("-proj-{p}"), + &format!("sess-{p}-{s}"), + "benchmark prompt", + &format!("2024-01-{:02}", s + 1), + ); + } + } + + let t0 = std::time::Instant::now(); + let cold = gather_artifacts( + &claude_codex_bundle(home), + Path::new("/work/proj"), + None, + None, + ); + let cold_t = t0.elapsed(); + let t1 = std::time::Instant::now(); + let warm = gather_artifacts( + &claude_codex_bundle(home), + Path::new("/work/proj"), + None, + None, + ); + let warm_t = t1.elapsed(); + assert_eq!(warm, cold); + assert_eq!(cold.len(), 300); + println!("cold gather: {cold_t:?}, warm gather: {warm_t:?} (300 sessions)"); + } + #[test] #[cfg(unix)] fn paths_match_canonicalizes_through_symlink() { diff --git a/crates/path-cli/src/config.rs b/crates/path-cli/src/config.rs index 86a923f8..502a8074 100644 --- a/crates/path-cli/src/config.rs +++ b/crates/path-cli/src/config.rs @@ -13,6 +13,8 @@ pub(crate) const CONFIG_DIR_ENV: &str = "TOOLPATH_CONFIG_DIR"; /// The artifact manifest under the config dir (see `sync::engine`). pub(crate) const MANIFEST_FILE_NAME: &str = "manifest.json"; +/// The picker listing cache under the config dir (see `listing_cache`). +pub(crate) const LISTING_CACHE_FILE_NAME: &str = "listing-cache.json"; /// Sibling advisory lock serializing manifest writers. A separate /// file because the manifest itself is replaced by rename on every /// write, which would drop any lock held on it. diff --git a/crates/path-cli/src/lib.rs b/crates/path-cli/src/lib.rs index 14ed9bba..515b82db 100644 --- a/crates/path-cli/src/lib.rs +++ b/crates/path-cli/src/lib.rs @@ -34,6 +34,7 @@ mod fuzzy; pub mod harness; mod io; mod kinds; +mod listing_cache; mod query; mod schema; #[cfg(all(not(target_os = "emscripten"), feature = "embedded-picker"))] diff --git a/crates/path-cli/src/listing_cache.rs b/crates/path-cli/src/listing_cache.rs new file mode 100644 index 00000000..dff59b6f --- /dev/null +++ b/crates/path-cli/src/listing_cache.rs @@ -0,0 +1,352 @@ +//! Sidecar stat-stamp cache for picker listing metadata. +//! +//! `gather_artifacts` (the `path share` / bare-resume session picker) +//! rebuilds the same row metadata — title, cwd, last activity, message +//! count — on every invocation, and the metadata scans are the +//! expensive part of picker startup. This cache stores each artifact's +//! picker-row fields next to the same stat-level fingerprint the sync +//! manifest uses (mtime+size for file providers, row updated-at for +//! the DB providers), so a gather can reuse the row for every artifact +//! whose stamp still matches and scan only what changed. +//! +//! Lives at `$CONFIG_DIR/listing-cache.json` (0600, atomic +//! temp+rename), sibling to the sync manifest but deliberately +//! simpler: it is a CACHE. Corrupt, missing, unreadable, or +//! wrong-version content is treated as empty — never an error, never a +//! blocked picker. And unlike the manifest there is no advisory lock: +//! last-writer-wins is fine for a cache, because the worst outcome of +//! a lost write is one redundant re-scan on the next gather. +//! +//! See `docs/superpowers/specs/2026-08-04-listing-cache-design.md`. + +#![cfg(not(target_os = "emscripten"))] + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +use crate::artifact::{ArtifactRef, ArtifactType}; +use crate::config::{LISTING_CACHE_FILE_NAME, config_dir}; + +/// Bump to invalidate every cache on disk (schema or semantics +/// change). Old versions load as empty and are overwritten wholesale +/// on the next dirty save. +const LISTING_CACHE_VERSION: u32 = 1; + +/// The cached picker-row fields for one artifact — everything an +/// `ArtifactRow` carries except `matches_cwd`, which depends on the +/// caller's cwd and is recomputed per gather from `path`/`cwd`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct CachedRow { + /// Project path for keyed providers (claude/gemini/pi). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) path: Option, + /// Recorded cwd from the session (codex/opencode/cursor/copilot). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) cwd: Option, + pub(crate) session_id: String, + pub(crate) title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) last_activity: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) message_count: Option, +} + +/// One cache entry: the row plus the stat-level fingerprint of the +/// source it was scanned from. Stamps serialize exactly like the sync +/// manifest's (`modified` mtime/updated-at + `size`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct CachedListing { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) modified: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) size: Option, + pub(crate) row: CachedRow, +} + +impl CachedListing { + /// Whether this entry vouches for `artifact`'s current state — + /// the same rule as sync's stat gate: at least one stamp component + /// must be `Some`, and both must match. All-`None` stamps mean + /// freshness is unknowable and never read as a hit. + pub(crate) fn matches(&self, artifact: &ArtifactRef) -> bool { + (self.modified.is_some() || self.size.is_some()) + && self.modified == artifact.modified + && self.size == artifact.size + } +} + +/// One provider's section: artifact id → cache entry. Claude keys by +/// chain head id (rotation-stable), matching `claude_chain_stamp`. +pub(crate) type ProviderListings = BTreeMap; + +/// On-disk shape. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct FileFormat { + version: u32, + #[serde(default)] + providers: BTreeMap, +} + +/// The loaded cache plus a dirty bit. Providers a gather consults +/// replace their whole section (enumeration is authoritative, so +/// vanished artifacts drop out); sections of providers that were +/// filtered away or not installed are carried through untouched. +#[derive(Debug, Default)] +pub(crate) struct ListingCache { + providers: BTreeMap, + dirty: bool, +} + +impl ListingCache { + /// Load the cache, treating every failure mode — no config dir, + /// missing file, unreadable file, corrupt JSON, wrong version — + /// as an empty cache. + pub(crate) fn load() -> Self { + let Some(path) = file_path() else { + return Self::default(); + }; + let Ok(json) = std::fs::read_to_string(&path) else { + return Self::default(); + }; + match serde_json::from_str::(&json) { + Ok(f) if f.version == LISTING_CACHE_VERSION => Self { + providers: f.providers, + dirty: false, + }, + _ => Self::default(), + } + } + + /// A clone of one provider's section (empty when absent). + pub(crate) fn section(&self, artifact_type: ArtifactType) -> ProviderListings { + self.providers + .get(artifact_type.name()) + .cloned() + .unwrap_or_default() + } + + /// Replace one provider's section with the refreshed one, marking + /// the cache dirty only when something actually changed — a + /// fully-warm gather stays clean and skips the write. + pub(crate) fn replace_section(&mut self, artifact_type: ArtifactType, fresh: ProviderListings) { + let name = artifact_type.name(); + // An absent section and an empty one are the same state. + let unchanged = match self.providers.get(name) { + Some(old) => *old == fresh, + None => fresh.is_empty(), + }; + if unchanged { + return; + } + if fresh.is_empty() { + self.providers.remove(name); + } else { + self.providers.insert(name.to_string(), fresh); + } + self.dirty = true; + } + + /// Write the cache back if anything changed. Failures warn and are + /// otherwise ignored: the gather already has its rows, and the + /// next run simply re-scans. + pub(crate) fn save_if_dirty(&self) { + if !self.dirty { + return; + } + if let Err(e) = self.save() { + eprintln!("warning: listing cache not updated: {e}"); + } + } + + fn save(&self) -> anyhow::Result<()> { + use anyhow::Context; + let path = file_path().ok_or_else(|| anyhow::anyhow!("no config directory"))?; + let dir = path.parent().expect("cache 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 file = FileFormat { + version: LISTING_CACHE_VERSION, + providers: self.providers.clone(), + }; + let json = serde_json::to_string_pretty(&file)?; + let tmp = dir.join(format!( + "{LISTING_CACHE_FILE_NAME}.{}.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())) + } +} + +fn file_path() -> Option { + config_dir().ok().map(|d| d.join(LISTING_CACHE_FILE_NAME)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{CONFIG_DIR_ENV, TEST_ENV_LOCK}; + + /// Run `f` with `$TOOLPATH_CONFIG_DIR` pinned to a tempdir. + 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(); + unsafe { + match prev { + Some(v) => std::env::set_var(CONFIG_DIR_ENV, v), + None => std::env::remove_var(CONFIG_DIR_ENV), + } + } + result + } + + fn entry(title: &str, size: u64) -> CachedListing { + CachedListing { + modified: Some("2026-08-01T00:00:00Z".parse().unwrap()), + size: Some(size), + row: CachedRow { + path: Some("/test/project".to_string()), + cwd: None, + session_id: "sess-1".to_string(), + title: title.to_string(), + last_activity: Some("2026-08-01T00:00:00Z".parse().unwrap()), + message_count: Some(3), + }, + } + } + + fn make_ref(modified: Option<&str>, size: Option) -> ArtifactRef { + ArtifactRef { + artifact_type: ArtifactType::Claude, + id: "sess-1".to_string(), + path: Some("/test/project".to_string()), + modified: modified.map(|m| m.parse().unwrap()), + size, + } + } + + #[test] + fn roundtrips_through_disk() { + with_cfg(|| { + let mut cache = ListingCache::load(); + assert!(cache.section(ArtifactType::Claude).is_empty()); + + let mut section = ProviderListings::new(); + section.insert("sess-1".to_string(), entry("Add a feature", 42)); + cache.replace_section(ArtifactType::Claude, section.clone()); + cache.save_if_dirty(); + + let reloaded = ListingCache::load(); + assert_eq!(reloaded.section(ArtifactType::Claude), section); + assert!(reloaded.section(ArtifactType::Codex).is_empty()); + }); + } + + #[test] + fn corrupt_or_wrong_version_loads_as_empty() { + with_cfg(|| { + let mut cache = ListingCache::load(); + let mut section = ProviderListings::new(); + section.insert("sess-1".to_string(), entry("t", 1)); + cache.replace_section(ArtifactType::Claude, section); + cache.save_if_dirty(); + let path = file_path().unwrap(); + + std::fs::write(&path, "not json").unwrap(); + assert!( + ListingCache::load() + .section(ArtifactType::Claude) + .is_empty() + ); + + let future = serde_json::json!({ + "version": LISTING_CACHE_VERSION + 1, + "providers": { "claude": { "sess-1": entry("t", 1) } }, + }); + std::fs::write(&path, future.to_string()).unwrap(); + assert!( + ListingCache::load() + .section(ArtifactType::Claude) + .is_empty() + ); + }); + } + + #[test] + fn identical_replace_is_not_dirty() { + with_cfg(|| { + let mut cache = ListingCache::load(); + let mut section = ProviderListings::new(); + section.insert("sess-1".to_string(), entry("t", 1)); + cache.replace_section(ArtifactType::Claude, section.clone()); + cache.save_if_dirty(); + + let mut warm = ListingCache::load(); + assert!(!warm.dirty); + warm.replace_section(ArtifactType::Claude, section); + assert!(!warm.dirty, "identical section must not dirty the cache"); + // And an empty replace of an already-absent section is clean. + warm.replace_section(ArtifactType::Codex, ProviderListings::new()); + assert!(!warm.dirty); + + warm.replace_section(ArtifactType::Claude, ProviderListings::new()); + assert!(warm.dirty, "dropping every entry must dirty the cache"); + }); + } + + #[test] + fn stamp_match_requires_a_real_stamp() { + let e = entry("t", 42); + assert!(e.matches(&make_ref(Some("2026-08-01T00:00:00Z"), Some(42)))); + assert!(!e.matches(&make_ref(Some("2026-08-01T00:00:00Z"), Some(43)))); + assert!(!e.matches(&make_ref(Some("2026-08-02T00:00:00Z"), Some(42)))); + assert!(!e.matches(&make_ref(None, None))); + + let unstamped = CachedListing { + modified: None, + size: None, + ..e + }; + assert!( + !unstamped.matches(&make_ref(None, None)), + "all-None stamps must never vouch" + ); + } + + #[cfg(unix)] + #[test] + fn cache_file_is_0600() { + use std::os::unix::fs::PermissionsExt; + with_cfg(|| { + let mut cache = ListingCache::load(); + let mut section = ProviderListings::new(); + section.insert("sess-1".to_string(), entry("t", 1)); + cache.replace_section(ArtifactType::Claude, section); + cache.save_if_dirty(); + let mode = std::fs::metadata(file_path().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + }); + } +} diff --git a/crates/path-cli/src/sync/sources.rs b/crates/path-cli/src/sync/sources.rs index ba76608c..cf54dafe 100644 --- a/crates/path-cli/src/sync/sources.rs +++ b/crates/path-cli/src/sync/sources.rs @@ -50,10 +50,10 @@ pub(crate) fn source_for<'a>( t: ArtifactType, ) -> Option> { match t { - ArtifactType::Claude => Some(Box::new(ClaudeSource(bundle.claude.as_ref()?))), + ArtifactType::Claude => Some(Box::new(claude_source(bundle.claude.as_ref()?))), ArtifactType::Gemini => Some(Box::new(GeminiSource(bundle.gemini.as_ref()?))), - ArtifactType::Codex => Some(Box::new(CodexSource(bundle.codex.as_ref()?))), - ArtifactType::Opencode => Some(Box::new(OpencodeSource(bundle.opencode.as_ref()?))), + ArtifactType::Codex => Some(Box::new(codex_source(bundle.codex.as_ref()?))), + ArtifactType::Opencode => Some(Box::new(opencode_source(bundle.opencode.as_ref()?))), ArtifactType::Cursor => Some(Box::new(CursorSource(bundle.cursor.as_ref()?))), ArtifactType::Pi => Some(Box::new(PiSource(bundle.pi.as_ref()?))), ArtifactType::Copilot => Some(Box::new(CopilotSource(bundle.copilot.as_ref()?))), @@ -61,6 +61,27 @@ pub(crate) fn source_for<'a>( } } +// Per-manager constructors for the providers whose enumeration is also +// consumed outside the sync engine (the listing cache behind +// `gather_artifacts` partitions picker rows by these stamps). Exposed +// so that reuse goes through the one enumeration path instead of a +// re-implementation. + +/// Claude's [`ArtifactSource`] over a single manager. +pub(crate) fn claude_source(mgr: &toolpath_claude::ClaudeConvo) -> impl ArtifactSource + '_ { + ClaudeSource(mgr) +} + +/// Codex's [`ArtifactSource`] over a single manager. +pub(crate) fn codex_source(mgr: &toolpath_codex::CodexConvo) -> impl ArtifactSource + '_ { + CodexSource(mgr) +} + +/// opencode's [`ArtifactSource`] over a single manager. +pub(crate) fn opencode_source(mgr: &toolpath_opencode::OpencodeConvo) -> impl ArtifactSource + '_ { + OpencodeSource(mgr) +} + /// The project path a path-keyed artifact was enumerated under. fn require_path(artifact: &ArtifactRef) -> Result<&str> { artifact diff --git a/docs/superpowers/plans/2026-08-04-listing-cache.md b/docs/superpowers/plans/2026-08-04-listing-cache.md new file mode 100644 index 00000000..617b5d88 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-listing-cache.md @@ -0,0 +1,67 @@ +# Listing cache — implementation plan + +Spec: `docs/superpowers/specs/2026-08-04-listing-cache-design.md` +(issue #158; branch `bryan/listing-cache`, based on +`bryan/parallel-gather`). + +## Task 1: Spec + plan + +- [x] Write the design spec with the locked-in decisions table +- [x] Write this plan + +## Task 2: `listing_cache` module + +- [x] `crates/path-cli/src/listing_cache.rs`: `CachedRow`, + `CachedListing` (stamp + row), `ListingCache` (load / section / + replace_section / save_if_dirty), version field, atomic 0600 + write, corrupt-or-missing → empty +- [x] `LISTING_CACHE_FILE_NAME` constant in `config.rs` +- [x] Unit tests: roundtrip, version mismatch → empty, corrupt → + empty, clean replace is not dirty, 0600 perms + +## Task 3: Cache-backed gather for claude / codex / opencode + +- [x] Expose per-provider `ArtifactSource` constructors in + `sync/sources.rs` (`claude_source` / `codex_source` / + `opencode_source`); `source_for` delegates to them +- [x] `cmd_share.rs`: shared `collect_with_cache` loop (partition by + stamp, rebuild hits, scan misses, rebuild ordering, apply + project filter, return refreshed section) +- [x] Replace `collect_claude` / `collect_codex` / `collect_opencode` + with cache-aware versions; single-session row builders shared + between hit and miss paths +- [x] `gather_artifacts`: load cache once, thread sections through the + scoped-thread fan-out, replace sections + `save_if_dirty` after + joining +- [x] Pin `$TOOLPATH_CONFIG_DIR` in the existing gather unit tests so + they stop touching the real `~/.toolpath` + +## Task 4: Correctness-gate tests + +- [x] Cold gather == warm gather, field-for-field (`ArtifactRow` gains + `PartialEq`) +- [x] Warm gather actually reads the cache (tampered cached title + surfaces on a stamp hit) +- [x] Append to a claude session file invalidates and refreshes the + row (message count grows) +- [x] Claude chain rotation: append lands in a successor segment; the + chain-head row refreshes (design decision 8) +- [x] Codex rollout append invalidates and refreshes +- [x] Deleted artifact drops its row and its cache entry +- [x] Corrupt listing cache is ignored and gather still returns fresh + rows +- [x] `project_filter` on a warm gather matches the cold filtered rows + +## Task 5: Version bump + changelog + +- [x] path-cli 0.16.2 → 0.16.3 in `crates/path-cli/Cargo.toml`, root + `Cargo.toml` workspace deps, `site/_data/crates.json` +- [x] CHANGELOG.md new H2 at top, dated 2026-08-04 + +## Task 6: Gates + +- [x] `RUSTFLAGS="-D warnings" cargo test -p path-cli` +- [x] `cargo clippy --workspace -- -D warnings` +- [x] `cargo fmt -p path-cli -- --check` +- [x] `scripts/quality_gates.sh format clippy test doc examples plugin` + (report, don't fix, any format drift in untouched files) diff --git a/docs/superpowers/specs/2026-08-04-listing-cache-design.md b/docs/superpowers/specs/2026-08-04-listing-cache-design.md new file mode 100644 index 00000000..6309c2bf --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-listing-cache-design.md @@ -0,0 +1,86 @@ +# Listing cache — instant warm picker gathers + +**Status:** Implemented +**Date:** 2026-08-04 + +## Goal + +Issue #158: the pre-picker gather (`path share`, and bare `path resume` +once #154 lands) re-scans every session file end to end on every +invocation just to rebuild the same `ArtifactRow` metadata (title, cwd, +last activity, message count). #156 and #157 got a cold gather to +~2.5 s; this spec makes the *warm* gather — nothing changed since last +time — effectively instant by caching listing metadata keyed by the +sync machinery's existing stat stamps. + +`p cache sync` already proves "nothing changed" in milliseconds without +reading session bodies: each `ArtifactRef` carries an mtime+size +fingerprint (claude: whole-chain stamp; codex: rollout file stat; +opencode/cursor: DB row updated-at). The listing cache reuses exactly +that enumeration and stamping, and stores the picker-row fields +alongside the stamp. + +## Decisions Locked In + +| # | Decision | Choice | +|---|----------|--------| +| 1 | Where the metadata lives | Sidecar file `$CONFIG_DIR/listing-cache.json` (respects `$TOOLPATH_CONFIG_DIR`), **not** extra fields on sync-manifest records. 0600 perms, atomic temp+rename writes. | +| 2 | Failure semantics | It is a CACHE: corrupt / missing / unreadable / wrong version → treated as empty, never an error, never blocks the picker. | +| 3 | Locking | None. Last-writer-wins is fine for a cache — the worst case is a redundant re-scan on the next gather. (The sync manifest keeps its advisory lock; this file deliberately does not copy it.) | +| 4 | Schema | Top-level `version` (bump to invalidate), then per artifact-type a map of artifact id → `{ modified?, size?, row }` where `row` = `{ path?, cwd?, session_id, title, last_activity?, message_count? }`. Stamps serialize the same way the sync manifest's do. | +| 5 | `matches_cwd` | NOT cached — it depends on the caller's cwd. Recomputed per gather from the cached `path`/`cwd` fields with the same canonicalized-path matching the collectors use. | +| 6 | Enumeration | Reuses the `ArtifactSource` machinery from `sync/sources.rs` (per-provider constructors exposed for the collectors); the engine does not grow a second enumeration path. | +| 7 | Stamp match rule | Same as sync's `is_unchanged`: at least one of `modified`/`size` must be `Some`, and both must equal the enumerated ref's. All-`None` stamps never vouch. | +| 8 | Claude keying | Cache key = chain head id; stamp = the same whole-chain stamp `claude_chain_stamp` computes, so an append to *any* segment of a chain invalidates that chain's entry. | +| 9 | Filters | `harness_filter` limits which providers consult the cache at all; `project_filter` is applied AFTER cache reconstruction (the cache is filter-agnostic — a filtered gather still warms it for everyone). | +| 10 | Eviction | Enumeration is authoritative: entries whose artifact no longer appears in the enumeration are dropped from the cache and produce no row — matching sync's self-heal semantics. | +| 11 | Write-back | The refreshed section replaces the provider's old one only when something actually changed; a fully-warm gather performs no write. | +| 12 | Correctness gate | Cached-row reconstruction must produce field-for-field identical picker rows to a fresh scan, in the same order. Tests: cold-vs-warm equality, append-invalidation, chain-rotation invalidation, deletion drop, corrupt-cache tolerance. | +| 13 | Cached providers | claude, codex, opencode — the three expensive scans. gemini / pi / copilot / cursor stay on the direct scan (all sub-second); adding one later is one `collect_*_cached` call site. | +| 14 | Picker behavior | Unchanged: same ranking, same row formatting, same `gather_artifacts` signature. Callers get the cache transparently. | + +## Flow per cached provider + +1. Enumerate `ArtifactRef`s stat-level (milliseconds, no session bodies). +2. For each ref whose stamp matches its cache entry, rebuild the + `ArtifactRow` from the cached fields (recomputing `matches_cwd` + against the canonical cwd). +3. Only new/changed refs get the expensive metadata scan: + - **claude**: `read_conversation_metadata(project, head)` per missed + chain (chain-aware, same read the old full scan looped over); + - **codex**: one lazy `list_rollout_files()` walk builds an id→path + map on the first miss, then `io().read_metadata(path)` per miss; + - **opencode**: metadata is one DB pass, so the first miss triggers + the full `list_session_metadata(None)` scan once and later misses + read from it. +4. Rows are re-ordered to match the fresh scan exactly: claude sorts by + descending `last_activity` within each project run (enumeration is + project-major, like the scan); codex/opencode sort globally by + descending `last_activity` (stable, so provider listing order breaks + ties exactly as before). +5. `project_filter` is applied, rows are appended in the usual provider + order, and the global (matches_cwd, last_activity) sort runs as + today. +6. The refreshed section is written back only if it differs. + +## Ordering caveat + +Byte-identical warm-vs-cold ordering relies on the enumeration listing +artifacts in the same relative order as the metadata scan (it does: +both walk the same directory listings / run the same `ORDER BY`). +Rows with *identical* `last_activity` inside one provider tie-break on +that listing order — which for claude (chain-head map iteration) is +not stable run to run. That nondeterminism predates the listing cache: +the old full scan sorted the same unstable input. Equal-timestamp ties +may therefore order differently between *any* two gathers, warm or +cold; the row *set* and the ranking contract are unaffected. + +## Alternatives considered + +Progressive hydration (stream rows into the picker) — rejected in the +issue: the ranking contract needs a global sort, so rows would visibly +reshuffle, and it masks the cost instead of removing it. + +Manifest-record extension — rejected: keeps the manifest schema stable, +and the manifest's locked read-merge-save discipline is overkill for +data that can be regenerated by one scan. diff --git a/site/_data/crates.json b/site/_data/crates.json index 5852d652..05d00d3e 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -113,7 +113,7 @@ }, { "name": "path-cli", - "version": "0.16.2", + "version": "0.16.3", "description": "Unified CLI (binary: path)", "docs": "https://docs.rs/path-cli", "crate": "https://crates.io/crates/path-cli",