diff --git a/Cargo.lock b/Cargo.lock index 068a34c7b..a1786c9e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1383,6 +1383,7 @@ dependencies = [ "chrono", "libc", "notify", + "regex", "rusqlite", "serde", "serde_json", diff --git a/crates/freshell-freshagent/src/claude_snapshot.rs b/crates/freshell-freshagent/src/claude_snapshot.rs index d8a370022..d387c23e8 100644 --- a/crates/freshell-freshagent/src/claude_snapshot.rs +++ b/crates/freshell-freshagent/src/claude_snapshot.rs @@ -70,7 +70,7 @@ pub fn locate_transcript(session_id: &str) -> Option { /// lines (100% of real user/assistant lines carry it -- ledger A5 census). Needed /// because the CLI's resume lookup is scoped to the original cwd's project slug /// (ledger A15). Reads lazily, stops at the first hit; malformed lines skipped. -pub(crate) fn transcript_cwd(path: &Path) -> Option { +pub fn transcript_cwd(path: &Path) -> Option { use std::io::BufRead; let file = std::fs::File::open(path).ok()?; for line in std::io::BufReader::new(file).lines() { @@ -87,6 +87,52 @@ pub(crate) fn transcript_cwd(path: &Path) -> Option { None } +/// Node's `CWD_SCAN_BYTES` (`claude-transcript-locator.ts:31`): the resolve +/// endpoint's cwd read never scans past the first 64 KiB of a transcript. +const CWD_SCAN_BYTES: u64 = 64 * 1024; + +/// Bounded variant of [`transcript_cwd`] for the resume-resolve claude +/// exact-id fallback (`crates/freshell-server/src/main.rs`). Node parity +/// (`claude-transcript-locator.ts:121-152` `readCwdFromTranscript`): read AT +/// MOST the first [`CWD_SCAN_BYTES`] of the file, split that prefix on +/// `\n`, and attempt to parse EVERY segment INCLUDING the final one — +/// Node's `head.split('\n')` loop has no discard-the-truncated-tail rule (a +/// fragment cut at the 64 KiB boundary simply fails `JSON.parse` and is +/// skipped, while a COMPLETE final line with no trailing newline still +/// parses). First non-empty string `cwd` wins. One resolve request against +/// a multi-GB transcript (or a single enormous line) must not allocate or +/// scan past the 64 KiB prefix — do NOT swap this for [`transcript_cwd`]'s +/// unbounded `BufRead::lines()` loop. +/// +/// Errors are swallowed to `None` like [`transcript_cwd`]; the +/// error-PROPAGATING variant for the provider-error channel is +/// [`transcript_cwd_checked`]. +pub fn transcript_cwd_bounded(path: &Path) -> Option { + use std::io::Read; + let file = std::fs::File::open(path).ok()?; + let mut head = Vec::new(); + file.take(CWD_SCAN_BYTES).read_to_end(&mut head).ok()?; + // Node's Buffer.toString('utf8') is lossy at the truncation boundary; + // from_utf8_lossy matches (replacement chars only ever land in the + // final fragment, which then fails to parse — same as Node). + let head = String::from_utf8_lossy(&head); + for segment in head.split('\n') { + let trimmed = segment.trim(); + if !trimmed.starts_with('{') { + continue; + } + let Ok(value) = serde_json::from_str::(trimmed) else { + continue; + }; + if let Some(cwd) = value.get("cwd").and_then(Value::as_str) { + if !cwd.is_empty() { + return Some(cwd.to_string()); + } + } + } + None +} + /// Locate `/projects/*/.jsonl` (or one subdir deeper, e.g. /// `//...` layouts). Filename scan, NEVER slug re-derivation: /// the cwd->slug encoding is lossy (`docs/port-plan.md:45`). Sorted dirs for @@ -132,6 +178,179 @@ pub(crate) fn find_transcript(claude_home: &Path, session_id: &str) -> Option Result, std::io::Error> { + // PASS 1 — direct layout across all roots. + for projects in projects_roots { + if let Some(path) = find_transcript_checked_direct(projects, session_id)? { + return Ok(Some(path)); + } + } + // PASS 2 — subagent layout, only when the direct layout missed everywhere. + for projects in projects_roots { + if let Some(path) = find_transcript_checked_subagent(projects, session_id)? { + return Ok(Some(path)); + } + } + Ok(None) +} + +/// Node parity (`claude-transcript-locator.ts:33-37`): expected absence is +/// `ENOENT || ENOTDIR` — a missing dir OR a non-directory path component is +/// a genuine miss; everything else is a provider failure. +fn is_expected_absence(e: &std::io::Error) -> bool { + matches!( + e.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) +} + +/// The traversal guard [`find_transcript`] applies, shared by the checked +/// helpers: reject ids that could escape the store root. +fn is_safe_session_id(session_id: &str) -> bool { + !(session_id.is_empty() + || session_id.contains('/') + || session_id.contains('\\') + || session_id.contains("..")) +} + +/// Sorted entry paths of `dir` — Node's `readdirOrEmpty` +/// (`claude-transcript-locator.ts:95-102`): expected absence reads as an +/// EMPTY listing; any other error PROPAGATES. No file-type filtering: Node +/// probes every entry and lets a non-directory read as an ENOTDIR miss at +/// the candidate probe. Sorted for determinism (the unchecked +/// `find_transcript` convention). +fn read_dir_sorted_or_empty(dir: &Path) -> Result, std::io::Error> { + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) if is_expected_absence(&e) => return Ok(Vec::new()), + Err(e) => return Err(e), + }; + let mut out: Vec = Vec::new(); + for entry in entries { + out.push(entry?.path()); + } + out.sort(); + Ok(out) +} + +/// One candidate probe (Node's `probeTranscript` stat, +/// `claude-transcript-locator.ts:105-113`): `Ok(true)` iff a regular file +/// exists at `path`; expected absence (incl. ENOTDIR from a file path +/// component) is a miss; any OTHER error propagates. `std::fs::metadata`, +/// never the error-swallowing `Path::is_file()`. +fn candidate_is_file(path: &Path) -> Result { + match std::fs::metadata(path) { + Ok(meta) => Ok(meta.is_file()), + Err(e) if is_expected_absence(&e) => Ok(false), + Err(e) => Err(e), + } +} + +/// PASS-1 helper: Node's DIRECT layout `//.jsonl` +/// (`claude-transcript-locator.ts:39-44,71-76`), with error propagation. +/// CAUTION: intentionally NOT the unchecked [`find_transcript`] layout — that +/// one probes `//.jsonl` without the `subagents` +/// segment, which diverges from Node and misses child sessions. +fn find_transcript_checked_direct( + projects: &Path, + session_id: &str, +) -> Result, std::io::Error> { + if !is_safe_session_id(session_id) { + return Ok(None); + } + let filename = format!("{session_id}.jsonl"); + for dir in read_dir_sorted_or_empty(projects)? { + let candidate = dir.join(&filename); + if candidate_is_file(&candidate)? { + return Ok(Some(candidate)); + } + } + Ok(None) +} + +/// PASS-2 helper: Node's SUBAGENT layout +/// `///subagents/.jsonl` +/// (`claude-transcript-locator.ts:45-48,78-88`), with error propagation. +fn find_transcript_checked_subagent( + projects: &Path, + session_id: &str, +) -> Result, std::io::Error> { + if !is_safe_session_id(session_id) { + return Ok(None); + } + let filename = format!("{session_id}.jsonl"); + for dir in read_dir_sorted_or_empty(projects)? { + for parent in read_dir_sorted_or_empty(&dir)? { + let candidate = parent.join("subagents").join(&filename); + if candidate_is_file(&candidate)? { + return Ok(Some(candidate)); + } + } + } + Ok(None) +} + +/// Error-AWARE variant of [`transcript_cwd_bounded`] for the resolve +/// endpoint's provider-health channel. An open error of expected-absence +/// kind is `Ok(None)` — the file existed a moment ago (the locate probe +/// succeeded), so absence = raced deletion and the hit survives, cwd-less +/// (Node behaves the same, `claude-transcript-locator.ts:121-129`); any +/// OTHER open/read error PROPAGATES (Node wraps these in +/// `ClaudeTranscriptLocatorError`). Same 64 KiB bounded read + tolerant +/// per-segment parse as [`transcript_cwd_bounded`] — malformed lines are +/// skipped, the final unterminated segment is still attempted. +pub fn transcript_cwd_checked(path: &Path) -> Result, std::io::Error> { + use std::io::Read; + let file = match std::fs::File::open(path) { + Ok(file) => file, + Err(e) if is_expected_absence(&e) => return Ok(None), + Err(e) => return Err(e), + }; + let mut head = Vec::new(); + file.take(CWD_SCAN_BYTES).read_to_end(&mut head)?; + let head = String::from_utf8_lossy(&head); + for segment in head.split('\n') { + let trimmed = segment.trim(); + if !trimmed.starts_with('{') { + continue; + } + let Ok(value) = serde_json::from_str::(trimmed) else { + continue; + }; + if let Some(cwd) = value.get("cwd").and_then(Value::as_str) { + if !cwd.is_empty() { + return Ok(Some(cwd.to_string())); + } + } + } + Ok(None) +} + /// Why a claude snapshot could not be served. #[derive(Debug)] pub(crate) enum ClaudeSnapshotError { @@ -469,6 +688,213 @@ mod tests { assert_eq!(transcript_cwd(&empty), None); } + #[test] + fn transcript_cwd_bounded_never_scans_past_the_64kib_prefix() { + // Node parity (`CWD_SCAN_BYTES`, `claude-transcript-locator.ts`): a + // cwd line that begins beyond the first 64 KiB is invisible to the + // resolve fallback's bounded reader — while the unbounded + // `transcript_cwd` (other consumers) still finds it. Also covers the + // boundary-straddling case: the line cut at the 64 KiB edge is a + // truncated fragment that fails to parse and is skipped, like Node's + // JSON.parse catch. + let home = temp_home(); + let file = home.path().join("big.jsonl"); + let filler_line = "{\"type\":\"noise\"}\n"; + let mut content = String::new(); + while content.len() <= 64 * 1024 { + content.push_str(filler_line); + } + content.push_str("{\"type\":\"user\",\"cwd\":\"/beyond/prefix\"}\n"); + std::fs::write(&file, &content).unwrap(); + assert_eq!(transcript_cwd_bounded(&file), None); + assert_eq!(transcript_cwd(&file), Some("/beyond/prefix".to_string())); + } + + #[test] + fn transcript_cwd_bounded_parses_a_complete_unterminated_final_line() { + // Node's `head.split('\n')` loop has no discard-the-tail rule: a + // COMPLETE final line with no trailing newline (small transcript) + // still parses. Do not drop the final segment. + let home = temp_home(); + let file = home.path().join("small.jsonl"); + std::fs::write( + &file, + "{\"type\":\"summary\"}\n{\"type\":\"user\",\"cwd\":\"/home/user/proj\"}", + ) + .unwrap(); + assert_eq!( + transcript_cwd_bounded(&file), + Some("/home/user/proj".to_string()) + ); + // First non-empty string cwd wins; empty-string cwd is skipped. + let skip = home.path().join("skip.jsonl"); + std::fs::write(&skip, "{\"cwd\":\"\"}\n{\"cwd\":42}\n{\"cwd\":\"/real\"}\n").unwrap(); + assert_eq!(transcript_cwd_bounded(&skip), Some("/real".to_string())); + // Missing file: swallowed to None (checked variant is deferred). + assert_eq!( + transcript_cwd_bounded(&home.path().join("absent.jsonl")), + None + ); + } + + // -- Task 6 (resolve parity): checked locator + checked cwd reader ------ + // + // HERMETIC BY CONSTRUCTION: every test below builds a temp projects dir + // and passes it via the `projects_roots` parameter; NO test mutates + // process-global env (CLAUDE_HOME/CLAUDE_CONFIG_DIR/HOME), so there is + // nothing to race against the crate's env-mutating claude tests. + + #[test] + fn locate_transcript_checked_misses_on_an_absent_projects_dir() { + let home = temp_home(); + let roots = vec![home.path().join("projects")]; // never created + assert_eq!( + locate_transcript_checked(&roots, "11111111-1111-4111-8111-111111111111").unwrap(), + None + ); + } + + #[test] + fn locate_transcript_checked_finds_the_subagent_child_layout() { + // Node layout pass 2 (`claude-transcript-locator.ts:39-48`): + // ///subagents/.jsonl. + let home = temp_home(); + let projects = home.path().join("projects"); + let sub = projects + .join("-repo-alpha") + .join("99999999-9999-4999-8999-999999999999") + .join("subagents"); + std::fs::create_dir_all(&sub).unwrap(); + let file = sub.join("22222222-2222-4222-8222-222222222222.jsonl"); + std::fs::write(&file, "{}\n").unwrap(); + assert_eq!( + locate_transcript_checked(&[projects], "22222222-2222-4222-8222-222222222222").unwrap(), + Some(file) + ); + } + + #[test] + fn locate_transcript_checked_treats_a_file_project_entry_as_a_miss_enotdir_parity() { + // Node reports ENOTDIR as a normal miss (`claude-transcript-locator + // .ts:33-37`): a candidate path whose component is a REGULAR FILE + // (descending into it fails NotADirectory) yields Ok(None), not Err. + let home = temp_home(); + let projects = home.path().join("projects"); + std::fs::create_dir_all(&projects).unwrap(); + std::fs::write(projects.join("-not-a-dir"), "i am a file\n").unwrap(); + assert_eq!( + locate_transcript_checked(&[projects], "33333333-3333-4333-8333-333333333333").unwrap(), + None + ); + } + + #[cfg(unix)] + #[test] + fn locate_transcript_checked_propagates_permission_denied() { + use std::os::unix::fs::PermissionsExt; + let home = temp_home(); + let projects = home.path().join("projects"); + std::fs::create_dir_all(&projects).unwrap(); + std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o000)).unwrap(); + // Running as root / CAP_DAC_OVERRIDE bypasses mode bits — probe first. + if std::fs::read_dir(&projects).is_ok() { + std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o755)).unwrap(); + eprintln!("skipping: euid bypasses permission checks"); + return; + } + let err = locate_transcript_checked( + std::slice::from_ref(&projects), + "44444444-4444-4444-8444-444444444444", + ) + .unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); + // Restore so TempDir cleanup works. + std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + #[test] + fn locate_transcript_checked_probes_direct_across_all_roots_before_any_subagent() { + // Node's GLOBAL two-pass order (`claude-transcript-locator.ts:69-88`): + // with roots [A, B], a DIRECT hit in B outranks a SUBAGENT hit in A — + // NOT per-root direct+subagent. + let home = temp_home(); + let root_a = home.path().join("a-projects"); + let root_b = home.path().join("b-projects"); + let id = "55555555-5555-4555-8555-555555555555"; + let sub = root_a + .join("-repo") + .join("88888888-8888-4888-8888-888888888888") + .join("subagents"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(sub.join(format!("{id}.jsonl")), "{}\n").unwrap(); + let direct_dir = root_b.join("-repo"); + std::fs::create_dir_all(&direct_dir).unwrap(); + let direct = direct_dir.join(format!("{id}.jsonl")); + std::fs::write(&direct, "{}\n").unwrap(); + assert_eq!( + locate_transcript_checked(&[root_a, root_b], id).unwrap(), + Some(direct) + ); + } + + #[test] + fn transcript_cwd_checked_is_bounded_to_the_64kib_prefix() { + // Node parity (`CWD_SCAN_BYTES`, `claude-transcript-locator.ts:30-31, + // 131-135`): a cwd line starting beyond the first 64 KiB is invisible. + let home = temp_home(); + let file = home.path().join("big.jsonl"); + let filler_line = "{\"type\":\"noise\"}\n"; + let mut content = String::new(); + while content.len() <= 64 * 1024 { + content.push_str(filler_line); + } + content.push_str("{\"type\":\"user\",\"cwd\":\"/beyond/prefix\"}\n"); + std::fs::write(&file, &content).unwrap(); + assert_eq!(transcript_cwd_checked(&file).unwrap(), None); + // A COMPLETE final line with no trailing newline still parses — Node's + // `head.split('\n')` loop has no discard-the-tail rule. + let small = home.path().join("small.jsonl"); + std::fs::write( + &small, + "{\"type\":\"summary\"}\n{\"type\":\"user\",\"cwd\":\"/home/user/proj\"}", + ) + .unwrap(); + assert_eq!( + transcript_cwd_checked(&small).unwrap(), + Some("/home/user/proj".to_string()) + ); + } + + #[test] + fn transcript_cwd_checked_treats_a_raced_deletion_as_a_cwdless_hit() { + // Expected-absence open error ⇒ Ok(None): the locate hit survives, + // cwd-less — Node behaves the same (`claude-transcript-locator.ts: + // 121-129`). + let home = temp_home(); + assert_eq!( + transcript_cwd_checked(&home.path().join("absent.jsonl")).unwrap(), + None + ); + } + + #[cfg(unix)] + #[test] + fn transcript_cwd_checked_propagates_a_permission_denied_open() { + use std::os::unix::fs::PermissionsExt; + let home = temp_home(); + let file = home.path().join("locked.jsonl"); + std::fs::write(&file, "{\"cwd\":\"/x\"}\n").unwrap(); + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).unwrap(); + if std::fs::File::open(&file).is_ok() { + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap(); + eprintln!("skipping: euid bypasses permission checks"); + return; + } + let err = transcript_cwd_checked(&file).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap(); + } + const SAMPLE_TRANSCRIPT: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../test/fixtures/fresh-agent/claude-transcript-sample.jsonl" diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index 720eb209e..b3d11ca35 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -48,10 +48,15 @@ pub mod spawn_gate; pub mod terminal_tabs; pub use claude::FreshClaudeState; -// Kata 09v1: the ONE claude_snapshot item visible outside this crate — the -// raw-file existence check freshell-server's IndexExistenceProbe shares with -// the attach arm. Keep the rest of claude_snapshot crate-private. -pub use claude_snapshot::locate_transcript; +// Kata 09v1 + SYNC-06: the TWO claude_snapshot items visible outside this +// crate — the raw-file existence check freshell-server's IndexExistenceProbe +// shares with the attach arm, and the original-cwd reader the resume-resolve +// claude fallback pairs with it (`claude-transcript-locator.ts` parity). +// Keep the rest of claude_snapshot crate-private. +pub use claude_snapshot::{ + locate_transcript, locate_transcript_checked, transcript_cwd, transcript_cwd_bounded, + transcript_cwd_checked, +}; pub use codex::FreshCodexState; pub use identity_sink::{ FreshAgentBindingUpsert, FreshAgentSettings, PaneIdentitySink, SharedPaneIdentitySink, diff --git a/crates/freshell-freshagent/tests/transcript_cwd_export.rs b/crates/freshell-freshagent/tests/transcript_cwd_export.rs new file mode 100644 index 000000000..79cd3c998 --- /dev/null +++ b/crates/freshell-freshagent/tests/transcript_cwd_export.rs @@ -0,0 +1,42 @@ +//! SYNC-06: the resume-resolve claude fallback needs the transcript's +//! original cwd (`claude-transcript-locator.ts` parity: first line carrying a +//! non-empty string `cwd`, malformed lines skipped). This pins the crate-root +//! export and the first-non-empty-cwd semantics. + +use std::io::Write; + +fn temp_transcript(lines: &[&str]) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!( + "freshell-transcript-cwd-{}-{}.jsonl", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let mut file = std::fs::File::create(&path).expect("create fixture transcript"); + for line in lines { + writeln!(file, "{line}").expect("write fixture line"); + } + path +} + +#[test] +fn first_non_empty_cwd_wins_and_malformed_lines_are_skipped() { + let path = temp_transcript(&[ + "not json at all {", + r#"{"type":"summary","cwd":""}"#, + r#"{"type":"user","cwd":"/repo/gamma","message":{}}"#, + r#"{"type":"assistant","cwd":"/repo/other"}"#, + ]); + assert_eq!( + freshell_freshagent::transcript_cwd(&path), + Some("/repo/gamma".to_string()) + ); +} + +#[test] +fn transcript_without_cwd_yields_none() { + let path = temp_transcript(&[r#"{"type":"summary"}"#, r#"{"leafUuid":"x"}"#]); + assert_eq!(freshell_freshagent::transcript_cwd(&path), None); +} diff --git a/crates/freshell-protocol/tests/roundtrip.rs b/crates/freshell-protocol/tests/roundtrip.rs index 2188393b6..9472e09f3 100644 --- a/crates/freshell-protocol/tests/roundtrip.rs +++ b/crates/freshell-protocol/tests/roundtrip.rs @@ -200,7 +200,7 @@ fn terminal_inventory_and_settings_parse_from_transcript() { )); assert_eq!( s.settings.coding_cli.enabled_providers, - vec!["claude", "codex", "opencode"] + vec!["claude", "codex", "opencode", "amplifier"] ); } other => panic!("expected SettingsUpdated, got {other:?}"), diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index 99c160d44..2a4a4d056 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -33,6 +33,7 @@ mod recovery_inventory; mod repo_icon; mod repo_icon_detect; mod repo_icon_git; +mod resolve; mod screenshots; mod serve_client; mod session_directory; @@ -1021,6 +1022,9 @@ async fn main() -> ExitCode { freshell_ws::opencode_signal::OpencodeSignalWatcher::new(signal_root), ); } + // SYNC-06: the resolve endpoint reads the SAME session index the History + // surfaces read (clone before the move below into `session_directory_state`). + let resolve_session_index = session_index.clone(); // DIAG-05: the diag router's `sessionsProjects` reads the SAME session // index (clone before the move below into `session_directory_state`). let diag_session_index = session_index.clone(); @@ -1105,7 +1109,7 @@ async fn main() -> ExitCode { let session_metadata_store = session_metadata::SessionMetadataStore::new(session_metadata_dir); let session_metadata_state = session_metadata::SessionMetadataApiState { auth_token: Arc::clone(&auth_token), - store: session_metadata_store, + store: session_metadata_store.clone(), // W5 fix-forward: the SAME shared `sessions.changed` bus + revision // counter minted above (and already wired into // `ws_state`/`fresh_agent_state`/`sessions::SessionsState`) so a @@ -1212,6 +1216,99 @@ async fn main() -> ExitCode { // sweep/fresh-agent producers. sessions_revision: Arc::clone(&sessions_revision), })) + .merge(resolve::router(resolve::ResolveState { + auth_token: Arc::clone(&auth_token), + // SYNC-06 deleted-override filter: the SAME settings store the + // sidebar overlay (`SessionDirectoryState.settings`) and + // `PATCH /api/sessions/{id}` write path use (constructed once + // at ~line 196; Clone shares the Arc-backed innards). + settings: settings_store.clone(), + session_index: resolve_session_index, + // SYNC-06 sessionType overlay: the SAME store `POST + // /api/session-metadata` writes (Node overlays it in + // `session-indexer.ts:1159-1161`). + session_metadata: session_metadata_store.clone(), + // Resolve fallbacks mirror Node's buildResolveFallbacks over the + // FIXED provider registry (server/index.ts wires ALL FOUR + // codingCliProviders into it unconditionally): settings do NOT + // gate the exact-id fallbacks — they only gate INDEXING and feed + // unsearchedProviders. Both closures are therefore ALWAYS wired; + // gating them on boot-time settings would produce false misses + // after a live settings change and diverge from Node for + // disabled-provider exact IDs. + // + // opencode `ses_*` exact-id fallback: the SAME data home the + // OpencodeSource uses, answered by the hardened direct by-id row + // query (`opencode_session_row_by_id`, Node's + // `opencode-by-id-query.ts`) — archived + child sessions + // included, full row (title/lastActivityAt) returned. Read + // errors REPORT as `Err(ProviderFailure)` (the provider-health + // channel) — never a silent `Ok(None)` miss. + opencode_session_by_id: Some({ + std::sync::Arc::new(|session_id: &str| { + let data_home = freshell_sessions::parse::default_opencode_data_home(); + freshell_sessions::parse::opencode_session_row_by_id(&data_home, session_id) + .map(|row| { + row.map(|r| freshell_sessions::resume_resolve::OpencodeByIdHit { + session_id: r.session_id, + cwd: r.cwd, + title: r.title, + last_activity_at: r.last_activity_at, + }) + }) + .map_err(|e| { + // Node production parity: the opencode worker + // boundary STRIPS `.code` — the worker serializes + // only {name, message} + // (`opencode-by-id.worker.ts:41-42`) and the + // runner rebuilds the Error without it + // (`opencode-by-id-runner.ts:103-106`), so Node's + // wire entry is message-only + // (`sessions-resolve-router.test.ts:308-320`). + // Emitting SQLITE_* codes here would DIVERGE from + // Node. Task 4's OpencodeByIdError still carries + // the code — log it (structured, with provider + + // code) for diagnosability, then drop it from the + // wire. + tracing::warn!( + provider = "opencode", + code = ?e.code, + message = %e.message, + "opencode by-id lookup failed" + ); + freshell_sessions::resume_resolve::ProviderFailure { + code: None, + message: e.message, + } + }) + }) as crate::resolve::OpencodeByIdLookup + }), + // claude transcript exact-id fallback: the CHECKED locator over + // Node's authoritative two layouts (direct + `/subagents/ + // .jsonl`) with the CHECKED bounded cwd reader — read errors + // REPORT as `Err(ProviderFailure)` carrying the symbolic errno + // (Node preserves `cause.code` verbatim). Node's locator + // lowercases the id before scanning and returns the lowercased + // id — mirrored here. + locate_claude_transcript: Some(std::sync::Arc::new(|session_id: &str| { + resolve_claude_exact_id_fallback(session_id) + }) as crate::resolve::ClaudeLocator), + // See `resolve_wire_home_dir` for the Node `os.homedir()` parity + // derivation (USERPROFILE on Windows; HOME else passwd-entry + // home on POSIX). + home_dir: resolve_wire_home_dir(), + // Node's hard 15 s by-id worker timeout, applied to EACH + // blocking fallback dispatch (permit wait + fallback) — never + // the in-memory resolver around it (see `resolve.rs` for the + // abandonment semantics). + resolve_deadline: resolve::RESOLVE_FALLBACK_DEADLINE, + // Admission cap on concurrent blocking fallback tasks — see + // `RESOLVE_MAX_CONCURRENCY` for why abandoned (uncancellable) + // blocking tasks must be bounded in COUNT, not just latency. + resolve_permits: Arc::new(tokio::sync::Semaphore::new( + resolve::RESOLVE_MAX_CONCURRENCY, + )), + })) .merge(files::router(files_state)) .merge(repo_icon::router(repo_icon_state)) .merge(terminals::router(terminals_state)) @@ -1568,6 +1665,45 @@ fn resolve_amplifier_events_path(projects_root: &Path, session_id: &str) -> Opti None } +/// Wire error code for a provider-error summary. Node preserves the ORIGINAL +/// `cause.code` VERBATIM (`ClaudeTranscriptLocatorError`, +/// `claude-transcript-locator.ts:19-27`): EPERM stays EPERM, EIO stays EIO. +/// So derive the symbolic errno name from the RAW OS errno — do NOT map from +/// `ErrorKind`, which would collapse EPERM into EACCES and drop EIO/EMFILE +/// entirely. +#[cfg(unix)] +fn errno_code(err: &std::io::Error) -> Option { + let raw = err.raw_os_error()?; + let name = match raw { + libc::EACCES => "EACCES", + libc::EPERM => "EPERM", + libc::ENOENT => "ENOENT", + libc::ENOTDIR => "ENOTDIR", + libc::EIO => "EIO", + libc::EMFILE => "EMFILE", + libc::ENFILE => "ENFILE", + libc::ELOOP => "ELOOP", + libc::ENAMETOOLONG => "ENAMETOOLONG", + libc::EBADF => "EBADF", + libc::EINVAL => "EINVAL", + _ => return None, // unknown errno ⇒ omit code, keep the message + }; + Some(name.to_string()) +} + +/// Non-unix fallback: `raw_os_error()` is a Win32 code there, not an errno; +/// map the coarse kinds Node's libuv also names. (The resolve fallbacks' +/// primary target is unix; parity of the fine-grained codes is a unix +/// concern.) +#[cfg(not(unix))] +fn errno_code(err: &std::io::Error) -> Option { + match err.kind() { + std::io::ErrorKind::PermissionDenied => Some("EACCES".to_string()), + std::io::ErrorKind::NotFound => Some("ENOENT".to_string()), + _ => None, + } +} + fn resolve_home() -> Option { std::env::var("FRESHELL_HOME") .ok() @@ -1576,6 +1712,74 @@ fn resolve_home() -> Option { .map(PathBuf::from) } +/// The `homeDir` wire field for `POST /api/sessions/resolve`. Node sends +/// `os.homedir()` (`sessions-router.ts:306-314`) — the USER's home, which on +/// Windows is `USERPROFILE`-backed and on POSIX is HOME (set and non-empty) +/// else the passwd-entry home. Resolved via the SAME +/// `session_directory::provider_home()` helper the session index sources use +/// (it implements exactly those platform semantics). Do NOT reuse +/// `resolve_home()`: it prefers `FRESHELL_HOME`, a config/storage root that +/// can differ from the real home, and the dialog would prefill a cwd-less +/// resume into the wrong directory. +fn resolve_wire_home_dir() -> Option> { + session_directory::provider_home().map(|h| Arc::new(h.to_string_lossy().into_owned())) +} + +/// The production claude exact-id fallback body (`crate::resolve::ClaudeLocator`): +/// the CHECKED locator over Node's authoritative two layouts (direct + +/// `/subagents/.jsonl`) with the CHECKED bounded cwd reader — +/// read errors REPORT as `Err(ProviderFailure)` carrying the symbolic errno +/// (Node preserves `cause.code` verbatim). Node's locator lowercases the id +/// before scanning and returns the lowercased id — mirrored here. +/// +/// Root resolution is Node-parity (`server/claude-home.ts:4-7` + +/// `providers/claude.ts:524-535`): `CLAUDE_HOME` (non-empty) else +/// `/.claude`, joined with `projects` — the SAME +/// `session_directory::provider_home()` root the session index uses (Node +/// `os.homedir()` platform semantics: USERPROFILE on Windows, where Tauri +/// deliberately leaves HOME unset; on POSIX HOME when set and non-empty, +/// else the passwd-entry home — USERPROFILE is never consulted there). +/// Note CLAUDE_HOME alone suffices even when no home resolves (Node's +/// `getClaudeHome()` honors it directly); no root ⇒ `Ok(None)`, a miss. +/// Deliberately NOT `claude_home_candidates()`: its extra +/// CLAUDE_CONFIG_DIR/bare-CLAUDE_HOME roots would expose transcripts from +/// roots Node never searches. +fn resolve_claude_exact_id_fallback( + session_id: &str, +) -> Result< + Option, + freshell_sessions::resume_resolve::ProviderFailure, +> { + let lowered = session_id.to_ascii_lowercase(); + let claude_home = match std::env::var("CLAUDE_HOME").ok().filter(|v| !v.is_empty()) { + Some(v) => Some(std::path::PathBuf::from(v)), + None => session_directory::provider_home().map(|h| h.join(".claude")), + }; + let roots: Vec = match claude_home { + Some(h) => vec![h.join("projects")], + None => return Ok(None), + }; + match freshell_freshagent::locate_transcript_checked(&roots, &lowered) { + Ok(Some(path)) => match freshell_freshagent::transcript_cwd_checked(&path) { + Ok(cwd) => Ok(Some( + freshell_sessions::resume_resolve::ClaudeTranscriptHit { + cwd, + session_id: lowered, + }, + )), + Err(e) => Err(freshell_sessions::resume_resolve::ProviderFailure { + code: errno_code(&e), + message: format!("Claude transcript read failed: {e}"), + }), + }, + Ok(None) => Ok(None), + Err(e) => Err(freshell_sessions::resume_resolve::ProviderFailure { + code: errno_code(&e), + message: format!("Claude transcript scan failed: {e}"), + }), + } +} + /// P1.8 tombstone-deletion gate (V10.md): `true` ONLY when a DIRECT /// filesystem check by provider path convention finds no transcript. /// Mirror each provider's on-disk convention from its freshell-sessions @@ -1682,6 +1886,11 @@ fn walk_contains_filename_fragment(root: &std::path::Path, fragment: &str) -> bo /// so the PanePicker surfaces the real coding-CLI agents); `featureFlags.kilroy` /// defaults off (no `KILROY_ENABLED` wiring yet); `featureFlags.aiEnabled` /// mirrors `AI_CONFIG.enabled()` (see [`ai_enabled`]). +/// `featureFlags.sessionResolve` is the unconditional literal both servers +/// declare now that the hardened resolve response surface +/// (degraded/providerErrors/unsearchedProviders/homeDir, warming default) +/// landed — see `docs/plans/2026-07-30-rust-resolve-parity-hardened.md` +/// Tasks 2-6 (SYNC-06). fn build_platform_payload( available_clis: serde_json::Value, env: &dyn freshell_platform::Env, @@ -1691,7 +1900,7 @@ fn build_platform_payload( "platform": platform, "availableClis": available_clis, "hostName": read_host_name(), - "featureFlags": { "kilroy": false, "aiEnabled": ai_enabled(env) }, + "featureFlags": { "kilroy": false, "aiEnabled": ai_enabled(env), "sessionResolve": true }, }) } @@ -1980,6 +2189,7 @@ mod sessions_sweep_tests { provider: "claude".to_string(), project_path: "/tmp".to_string(), title: None, + title_provider_generated: false, summary: None, first_user_message: None, last_activity_at, @@ -2189,6 +2399,231 @@ mod tests { use super::*; use freshell_platform::MapEnv; + /// Save-and-restore guard for one env var (tests below mutate real + /// process env; the shared `HOME_ENV_TEST_LOCK` serializes them + /// crate-wide). Restores on drop, panic included. + struct EnvVarGuard { + name: &'static str, + saved: Option, + } + + impl EnvVarGuard { + fn unset(name: &'static str) -> Self { + let saved = std::env::var_os(name); + std::env::remove_var(name); + Self { name, saved } + } + + fn set(name: &'static str, value: &str) -> Self { + let saved = std::env::var_os(name); + std::env::set_var(name, value); + Self { name, saved } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match self.saved.take() { + Some(v) => std::env::set_var(self.name, v), + None => std::env::remove_var(self.name), + } + } + } + + fn env_test_temp_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "frs-main-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("mkdir temp dir"); + dir + } + + // -- Node `os.homedir()` platform parity for the resolve wiring + // (reviewer findings, iterations 2 + 3): production Tauri inherits the + // desktop environment WITHOUT setting `HOME` on native Windows, where + // Node's `os.homedir()` reads USERPROFILE (HOME is never consulted); on + // POSIX it reads HOME when set and non-empty, else the effective user's + // passwd-entry home — USERPROFILE is never consulted there. Every home + // consumer on the resolve path (the exact-id claude fallback, the + // `homeDir` wire field) routes through + // `session_directory::provider_home()`, which implements exactly those + // platform semantics. + + #[cfg(unix)] + #[test] + fn wire_home_dir_unix_treats_empty_home_as_unset_using_passwd_entry() { + let _lock = crate::session_directory::HOME_ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _home = EnvVarGuard::set("HOME", ""); + let _userprofile = EnvVarGuard::set("USERPROFILE", "/Users/win-fixture-wire"); + let resolved = resolve_wire_home_dir().map(|h| h.as_str().to_string()); + assert_ne!( + resolved.as_deref(), + Some("/Users/win-fixture-wire"), + "POSIX must NEVER consult USERPROFILE (Node os.homedir() reads it on Windows only)" + ); + assert_eq!( + resolved, + Some( + crate::session_directory::passwd_entry_home() + .to_string_lossy() + .into_owned() + ), + "an EMPTY HOME must fall back to the passwd-entry home (Node os.homedir() POSIX parity)" + ); + } + + #[cfg(windows)] + #[test] + fn wire_home_dir_windows_uses_userprofile_never_home() { + let _lock = crate::session_directory::HOME_ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _home = EnvVarGuard::set("HOME", "C:\\never-consulted"); + let _userprofile = EnvVarGuard::set("USERPROFILE", "C:\\Users\\win-fixture-wire"); + assert_eq!( + resolve_wire_home_dir().map(|h| h.as_str().to_string()), + Some("C:\\Users\\win-fixture-wire".to_string()), + "Windows must read USERPROFILE and never consult HOME (Node os.homedir() parity)" + ); + } + + #[cfg(unix)] + #[test] + fn claude_exact_id_fallback_finds_transcript_under_home() { + // The exact-id fallback itself — not just `provider_home()` — must + // find the transcript under `/.claude/projects`, or a + // cwd-less/subagent transcript omitted from the index produces a + // healthy-looking ready-empty where Node (os.homedir()) finds it. + const SESSION_ID: &str = "AB2AFDA6-A340-443E-BA60-024A1B3554B4"; + let _lock = crate::session_directory::HOME_ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let home = env_test_temp_dir("claude-fallback"); + let _home = EnvVarGuard::set("HOME", home.to_str().unwrap()); + let _claude_home = EnvVarGuard::unset("CLAUDE_HOME"); + + let project = home.join(".claude").join("projects").join("-repo-alpha"); + std::fs::create_dir_all(&project).expect("mkdir claude project dir"); + std::fs::write( + project.join(format!("{}.jsonl", SESSION_ID.to_ascii_lowercase())), + "{\"cwd\":\"/repo/alpha\"}\n", + ) + .expect("write transcript fixture"); + + let hit = resolve_claude_exact_id_fallback(SESSION_ID) + .expect("fallback must not report a provider failure") + .expect("fallback must FIND the transcript under HOME/.claude"); + assert_eq!(hit.session_id, SESSION_ID.to_ascii_lowercase()); + assert_eq!(hit.cwd.as_deref(), Some("/repo/alpha")); + + std::fs::remove_dir_all(&home).ok(); + } + + #[cfg(unix)] + #[test] + fn claude_exact_id_fallback_unix_never_reads_userprofile() { + // Node's `os.homedir()` on POSIX never consults USERPROFILE: with + // HOME unset, the fallback root is the passwd-entry home — a + // transcript living ONLY under `USERPROFILE/.claude` must NOT be + // surfaced (the pre-fix HOME||USERPROFILE approximation found it). + const SESSION_ID: &str = "0C7B39D1-52E4-4F0F-9E7C-4E2B7A11D9AA"; + let _lock = crate::session_directory::HOME_ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let profile = env_test_temp_dir("claude-fallback-userprofile"); + let _home = EnvVarGuard::unset("HOME"); + let _claude_home = EnvVarGuard::unset("CLAUDE_HOME"); + let _userprofile = EnvVarGuard::set("USERPROFILE", profile.to_str().unwrap()); + + let project = profile.join(".claude").join("projects").join("-repo-beta"); + std::fs::create_dir_all(&project).expect("mkdir claude project dir"); + std::fs::write( + project.join(format!("{}.jsonl", SESSION_ID.to_ascii_lowercase())), + "{\"cwd\":\"/repo/beta\"}\n", + ) + .expect("write transcript fixture"); + + let hit = resolve_claude_exact_id_fallback(SESSION_ID) + .expect("fallback must not report a provider failure"); + assert!( + hit.is_none(), + "POSIX must resolve the passwd-entry home, never USERPROFILE/.claude" + ); + + std::fs::remove_dir_all(&profile).ok(); + } + + #[cfg(windows)] + #[test] + fn claude_exact_id_fallback_finds_transcript_in_a_userprofile_only_environment() { + // The documented Tauri environment: USERPROFILE = the real user home + // containing `.claude/projects/...` (Node's `os.homedir()` is + // USERPROFILE-backed on Windows). The exact-id fallback itself — + // not just `provider_home()` — must find the transcript there. + const SESSION_ID: &str = "AB2AFDA6-A340-443E-BA60-024A1B3554B4"; + let _lock = crate::session_directory::HOME_ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let home = env_test_temp_dir("claude-fallback"); + let _claude_home = EnvVarGuard::unset("CLAUDE_HOME"); + let _userprofile = EnvVarGuard::set("USERPROFILE", home.to_str().unwrap()); + + let project = home.join(".claude").join("projects").join("-repo-alpha"); + std::fs::create_dir_all(&project).expect("mkdir claude project dir"); + std::fs::write( + project.join(format!("{}.jsonl", SESSION_ID.to_ascii_lowercase())), + "{\"cwd\":\"/repo/alpha\"}\n", + ) + .expect("write transcript fixture"); + + let hit = resolve_claude_exact_id_fallback(SESSION_ID) + .expect("fallback must not report a provider failure") + .expect("fallback must FIND the transcript under USERPROFILE/.claude"); + assert_eq!(hit.session_id, SESSION_ID.to_ascii_lowercase()); + assert_eq!(hit.cwd.as_deref(), Some("/repo/alpha")); + + std::fs::remove_dir_all(&home).ok(); + } + + // -- Task 6 (resolve parity): errno-name derivation for provider errors. + // Node preserves the ORIGINAL `cause.code` VERBATIM + // (`ClaudeTranscriptLocatorError`, `claude-transcript-locator.ts:19-27`): + // EPERM stays EPERM — a kind-based mapping would collapse it into EACCES + // (both are `ErrorKind::PermissionDenied`) and drop EIO entirely. + + #[cfg(unix)] + #[test] + fn errno_code_preserves_the_raw_errno_name_verbatim() { + use std::io; + assert_eq!( + errno_code(&io::Error::from_raw_os_error(libc::EPERM)).as_deref(), + Some("EPERM") + ); + assert_eq!( + errno_code(&io::Error::from_raw_os_error(libc::EACCES)).as_deref(), + Some("EACCES") + ); + assert_eq!( + errno_code(&io::Error::from_raw_os_error(libc::EIO)).as_deref(), + Some("EIO") + ); + // Synthetic error without a raw errno: omit the code, keep the message. + assert_eq!( + errno_code(&io::Error::new( + io::ErrorKind::PermissionDenied, + "no raw errno" + )), + None + ); + } + // -- P1.8: `transcript_definitively_absent`, the tombstone-DELETION gate // (V10.md). Deletion is the destructive branch, so every uncertain path // must answer `false` (present => defer); only a readable tree with NO @@ -2303,13 +2738,17 @@ mod tests { #[test] fn platform_payload_feature_flags_shape_matches_legacy() { - // `server/platform-router.ts#detectFeatureFlags`: `{ kilroy, aiEnabled }`, - // camelCase, no extra fields — mirrored 1:1 in the Rust payload. + // `server/platform-router.ts#detectFeatureFlags`: `{ kilroy, aiEnabled, + // sessionResolve }`, camelCase, no extra fields — mirrored 1:1 in the + // Rust payload. `sessionResolve` is TRUE again: the hardened resolve + // response surface (degraded/providerErrors/unsearchedProviders/ + // homeDir, warming default) landed via the hardened plan Tasks 2-6 + // (SYNC-06), so the flag is now genuinely earned. let env = MapEnv::new().with("GOOGLE_GENERATIVE_AI_API_KEY", "sk-live-abc123"); let payload = build_platform_payload(serde_json::json!({}), &env); assert_eq!( payload["featureFlags"], - serde_json::json!({ "kilroy": false, "aiEnabled": true }) + serde_json::json!({ "kilroy": false, "aiEnabled": true, "sessionResolve": true }) ); } @@ -2319,7 +2758,7 @@ mod tests { let payload = build_platform_payload(serde_json::json!({}), &env); assert_eq!( payload["featureFlags"], - serde_json::json!({ "kilroy": false, "aiEnabled": false }) + serde_json::json!({ "kilroy": false, "aiEnabled": false, "sessionResolve": true }) ); } diff --git a/crates/freshell-server/src/resolve.rs b/crates/freshell-server/src/resolve.rs new file mode 100644 index 000000000..5c418c76e --- /dev/null +++ b/crates/freshell-server/src/resolve.rs @@ -0,0 +1,2415 @@ +//! `POST /api/sessions/resolve` — SYNC-06 port of the resolve route +//! (`server/sessions-router.ts`) + the hardened matching semantics of +//! `server/coding-cli/resolve-session.ts` and `resolve-fallbacks.ts` +//! (exact→fallback→prefix ordering, case-sensitivity gating, subagent +//! exclusion, candidate work budget, full-id shape gates + per-request +//! fallback budget). +//! +//! DIVERGENCE LEDGER (full detail in the core's module doc, +//! `freshell-sessions/src/resume_resolve.rs`; history in +//! `docs/plans/2026-07-30-rust-resolve-parity-hardened.md`). The +//! `sessionResolve` capability flag is declared `true` (`main.rs`, +//! `build_platform_payload`): the wire surface, the failure-reporting +//! production fallbacks (checked claude locator, propagating opencode by-id +//! query), and the scan-failure/unsearched-provider route merge all landed +//! in plan Task 6, and the resume-button e2e matrix ran green against this +//! route (plan Task 7). No known unported divergences remain beyond the +//! RECORDED DEVIATIONS documented below (explicit 500 on a resolver panic +//! instead of Node's undefined behavior; `homeDir` omitted when the server +//! has no resolvable home). +//! +//! Behavior contract: +//! - wire shape (`ResumeResolveResponseSchema`, `sessions-router.ts:306-314`): +//! `{status, matches, hint, providerErrors, unsearchedProviders, homeDir}` +//! — `providerErrors`/`unsearchedProviders` always present, `homeDir` +//! omitted only when the server has no resolvable home. +//! - `providerErrors` = the core's fallback failures merged with the index's +//! scan failures (enabled providers only; fallback errors win the dedupe — +//! they carry the more specific message/code). A DISABLED provider is +//! reported in `unsearchedProviders`, never as an error — otherwise a +//! failed-then-disabled provider would stick degraded forever. +//! - `status`: warming stays warming; otherwise any provider error makes the +//! response `degraded` — EVEN WITH matches (a failed provider means a +//! higher-priority exact match may have been missed, so the client must +//! never auto-resume a surviving lower-priority match). A degraded +//! response fire-and-forgets `SessionIndex::request_refresh()` so a client +//! Retry can converge once the provider recovers. +//! - disabled providers are filtered OUT of the index snapshot BEFORE core +//! resolution (Node's index excludes them at scan time, +//! `session-indexer.ts:1454-1467`); the exact-id FALLBACKS stay ungated +//! (Node invokes all wired fallbacks regardless of settings, +//! `resolve-session.ts:127-156`). +//! - async hygiene: the ENTIRE `resolve_resume_input` call runs inside +//! `tokio::task::spawn_blocking` (bounded in-memory matching), and each +//! blocking fallback invocation (rusqlite query, transcript directory +//! walk) is dispatched onto its OWN nested `spawn_blocking` task; no +//! DB/FS wait ever blocks the async runtime, and per-request work is +//! bounded by `MAX_RESUME_CANDIDATES` (8) × `FALLBACK_BUDGET_PER_REQUEST` +//! (2 per provider) fallback calls + one index scan per token. Keep any +//! new closure invocation inside that dispatch. ONLY the fallback +//! dispatch — never input parsing, index-only resolution, warming, or +//! no-candidate responses — is bounded THREE ways (Node scopes its 15 s +//! timeout to the individual by-id worker, `opencode-by-id-runner.ts`; +//! its cheap paths never wait on worker availability): (1) +//! [`RESOLVE_FALLBACK_DEADLINE`] bounds each admitted fallback task, and +//! on elapse the task is ABANDONED (blocking tasks cannot be killed — +//! recorded deviation from Node's `worker.terminate()`) with a timeout +//! `ProviderFailure` blaming ONLY that provider; (2) a +//! [`RESOLVE_MAX_CONCURRENCY`]-permit semaphore whose permit MOVES INTO +//! the fallback task caps how many (abandoned or live) fallback tasks +//! can exist at once — admission is a SYNCHRONOUS `try_acquire_owned()` +//! BEFORE the fallback task exists, so a permit-starved dispatch fails +//! fast with a concurrency-limit provider error instead of queueing (it +//! never parks the resolver's blocking worker on the semaphore); (3) +//! the per-provider budget (`FALLBACK_BUDGET_PER_REQUEST` +//! = 2) caps how many dispatches — and therefore abandoned tasks — one +//! request can produce. Each dispatch is bounded INDEPENDENTLY: a +//! timeout blames only the provider whose dispatch elapsed, and later +//! candidates/providers are still attempted with their own bounds +//! (Node records the rejection per provider and keeps iterating, +//! `resolve-session.ts:133-156`). ERRATA: commit bb357a598 applied the +//! deadline + admission around the WHOLE resolver as an interim +//! approximation; corrected to fallback-only scoping. ERRATA: commit +//! ffa4aac1a shared one cooperative cancel flag across both providers, +//! so one timeout skipped every later fallback and fabricated timeouts +//! for providers never dispatched; corrected to per-dispatch scoping. +//! ERRATA: through commit 37be35b9a admission awaited `acquire_owned()` +//! INSIDE the outer resolver worker, so under saturation each dispatch +//! pinned an unbounded blocking-pool worker for its full deadline — +//! the exact exhaustion the semaphore exists to prevent; corrected to +//! fail-fast admission before any fallback task exists. +//! - RECORDED DEVIATION: a `JoinError` (the resolver PANICKED) answers an +//! explicit 500 `{"error":"Resolve failed"}`. Node has no defined behavior +//! here — a top-level resolver throw becomes an unhandled rejection in the +//! async Express 4 handler (no response at all) — so the explicit 500 is +//! the honest port, not a wire mismatch; fabricating ready-empty would +//! present an unsearchable state as a healthy "not found". +//! +//! Behavior contract (validation/readiness): +//! - auth: same `x-auth-token` / `freshell-auth` cookie check as every other +//! `/api` route (`boot::is_authed`), 401 `{"error":"Unauthorized"}`. +//! - validation: strict body `{ input: string 1..=20000 }` (UTF-16 code +//! units); any failure → 400 +//! `{"error":"Invalid resolve request","details":[issues]}` where the +//! issue literals replicate the ACTUAL zod 4.3.6 wire output — field set, +//! key ORDER (`expected`/`origin` before `code`; `preserve_order` + `json!` +//! insertion order provide it), and message wording, probed against the +//! real `ResumeResolveRequestSchema`. NOTHING reads `details` (the client +//! dialog treats any non-2xx as request-failed without inspecting the +//! body), so this is test-pinned parity; the literals are pinned to zod +//! 4.3.6 and MUST be re-probed on any zod bump. +//! - membership: the index snapshot is filtered through `deleted: true` +//! session overrides before matching — Node's resolve reads the +//! post-filter project groups (`session-indexer.ts:209,1155-1156`) and the +//! Rust sidebar applies the same overlay (`session_directory.rs` +//! `apply_session_overrides`). The exact-id fallbacks BYPASS the filter, +//! as Node's do (they read sqlite/the filesystem directly). +//! - success is ALWAYS 200 — "not found" is `{status:"ready",matches:[]}`, +//! cold index is `{status:"warming",matches:[],hint}` (never 404/5xx). +//! +//! Accepted deviations (status parity only, recorded): payloads Express's +//! strict body parser rejects with an HTML 400 before zod runs (malformed +//! JSON; JSON scalars string/number/bool/null) get the zod-shaped JSON 400 +//! here; axum's default 2 MB body limit vs express `json({limit:'1mb'})`; +//! `PATCH`/`GET /api/sessions/resolve` answer 405 on the merged Rust router +//! where Express would dispatch `:sessionId="resolve"` (unreachable by any +//! known client). +//! +//! Readiness: `SessionIndex::peek()` `None` = never-published = Node's +//! `isIndexReady() === false`. A machine with no resolvable provider home +//! (`session_index: None`) also answers `warming` — the same honest-Unknown +//! convention `NoIndexProbe` uses for existence. + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::body::Bytes; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use axum::{Json, Router}; +use serde_json::{json, Map, Value}; + +use freshell_sessions::directory_index::{IndexedSession, SessionIndex}; +use freshell_sessions::resume_input::ResumeHint; +use freshell_sessions::resume_resolve::{ + resolve_resume_input, ClaudeTranscriptHit, OpencodeByIdHit, ProviderFailure, ResolveDeps, + ResumeResolveMatch, ResumeResolveProviderError, ResumeResolveStatus, +}; + +use crate::boot::{is_authed, unauthorized}; +use crate::session_metadata::SessionMetadataStore; +use crate::settings_store::SettingsStore; + +/// zod `.max(20000)` on `input` (`shared/resume-resolve-contract.ts`). +const RESOLVE_INPUT_MAX_UTF16: usize = 20000; + +/// Deadline on EACH admitted blocking fallback task (admission itself is a +/// synchronous fail-fast `try_acquire`, never a wait), mirroring Node's hard +/// per-worker by-id timeout +/// (`opencode-by-id-runner.ts` `DEFAULT_TIMEOUT_MS = 15_000`; the listing +/// runner uses the same value). Node scopes this timeout to the individual +/// worker — its parsing/index paths never wait on it — and so does this +/// port. Without it, a filesystem or SQLite operation stalled OUTSIDE +/// SQLite's 500 ms busy handling would hold this request and a +/// blocking-pool worker alive indefinitely. +pub const RESOLVE_FALLBACK_DEADLINE: std::time::Duration = std::time::Duration::from_millis(15_000); + +/// Admission cap on CONCURRENT blocking fallback tasks. Node needs no such +/// cap — its by-id runner `worker.terminate()`s a stalled worker thread at +/// the 15 s timeout, so stalled work never accumulates. A Rust blocking task +/// cannot be killed: on deadline elapse it is ABANDONED and keeps its +/// blocking-pool thread until the underlying FS/SQLite op returns. Without +/// admission control, repeated authenticated requests against a stalled +/// provider store would accumulate abandoned tasks without bound and exhaust +/// Tokio's blocking pool (default max 512 threads), starving UNRELATED +/// server work (every other `spawn_blocking` user). The permit is MOVED INTO +/// the blocking fallback task, so an abandoned task keeps holding it until +/// its underlying op returns — stalled-task accumulation is therefore capped +/// at this count, and the worst case degrades ONLY fallback-requiring +/// resolve requests: parsing, index-only resolution, warming, and +/// no-candidate responses never acquire a permit. Admission is a +/// SYNCHRONOUS `try_acquire_owned()` performed BEFORE the fallback task +/// exists: a starved dispatch fails fast with a degraded provider error +/// rather than parking its resolver worker on the semaphore (queueing there +/// would pin one unbounded outer blocking-pool worker per dispatch for the +/// full deadline — the exact exhaustion this cap exists to prevent). +/// 8 permits comfortably exceed any realistic resolve concurrency (one +/// interactive dialog per user) while staying a small fraction of the pool. +pub const RESOLVE_MAX_CONCURRENCY: usize = 8; + +/// opencode `ses_*` by-id fallback: `Ok(Some(hit))` = the lookup resolved the +/// id, `Ok(None)` = miss, `Err(ProviderFailure)` = the provider store could +/// not be searched (recorded as a provider error, result degrades — never a +/// 5xx). +pub type OpencodeByIdLookup = + Arc Result, ProviderFailure> + Send + Sync>; + +/// claude transcript exact-id fallback: lowercased id + original cwd, same +/// `Result` contract as [`OpencodeByIdLookup`]. +pub type ClaudeLocator = + Arc Result, ProviderFailure> + Send + Sync>; + +/// The generic exact-id fallback closure shape shared by +/// [`OpencodeByIdLookup`] (`T = OpencodeByIdHit`) and [`ClaudeLocator`] +/// (`T = ClaudeTranscriptHit`); [`bounded_fallback`] wraps one in the +/// permit + per-dispatch-deadline dispatch. +type FallbackFn = Arc Result, ProviderFailure> + Send + Sync>; + +/// Shared state for the resolve surface. +#[derive(Clone)] +pub struct ResolveState { + pub auth_token: Arc, + /// `config.sessionOverrides` reader (`settings_store.rs`): the resolve + /// read model drops `deleted: true` sessions exactly like the sidebar's + /// `apply_session_overrides` and Node's post-filter `getProjects()`. + pub settings: SettingsStore, + pub session_index: Option>, + pub session_metadata: SessionMetadataStore, + pub opencode_session_by_id: Option, + pub locate_claude_transcript: Option, + /// The USER's home (Node sends `os.homedir()`, `sessions-router.ts:306-314`) + /// — lets the client prefill a CONCRETE cwd instead of the `~` sentinel. + /// `None` (no resolvable home) omits `homeDir` from the wire. + pub home_dir: Option>, + /// Deadline for EACH admitted blocking fallback task (admission fails + /// fast, never waits) — never the resolver around it. Production wires + /// [`RESOLVE_FALLBACK_DEADLINE`] (Node's 15 s by-id worker timeout); + /// injectable so tests exercise the timeout path without waiting 15 s. + pub resolve_deadline: std::time::Duration, + /// Admission semaphore bounding concurrent blocking FALLBACK tasks (see + /// [`RESOLVE_MAX_CONCURRENCY`]). Production wires a fresh semaphore with + /// that many permits; injectable so tests exercise saturation without + /// spawning eight stalled fallbacks. + pub resolve_permits: Arc, +} + +/// `KNOWN_RESUME_PROVIDERS` = `DEFAULT_ENABLED_CLI_PROVIDERS` +/// (`shared/coding-cli-defaults.ts:3`). The indexer scans ONLY +/// settings-enabled providers, so a disabled provider's sessions can never +/// be found — report those as UNSEARCHED so "not found" never overclaims. +/// Order matches the canonical provider list. +const KNOWN_RESUME_PROVIDERS: [&str; 4] = ["claude", "codex", "opencode", "amplifier"]; + +/// Wire response (`ResumeResolveResponseSchema`): the core outcome plus the +/// router-level provider-health fields. `providerErrors`/`unsearchedProviders` +/// are always present (zod defaults exist for legacy tolerance, but Node +/// always sends them); `homeDir` is omitted only when the server has no +/// resolvable home. Struct field order IS wire key order (workspace-wide +/// `preserve_order`) — it matches the Node object literal, +/// `sessions-router.ts:306-314`. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct ResolveWireResponse { + status: ResumeResolveStatus, + matches: Vec, + hint: Option, + provider_errors: Vec, + unsearched_providers: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + home_dir: Option, +} + +pub fn router(state: ResolveState) -> Router { + Router::new() + .route("/api/sessions/resolve", post(resolve_session)) + .with_state(state) +} + +/// zod v4's received-type word for a JSON value. +fn received_type(value: &Value) -> &'static str { + match value { + Value::Array(_) => "array", + Value::String(_) => "string", + Value::Number(_) => "number", + Value::Bool(_) => "boolean", + Value::Null => "null", + Value::Object(_) => "object", + } +} + +/// Validate the request body against `ResumeResolveRequestSchema` semantics: +/// strict object, `input: string`, 1..=20000 UTF-16 code units. Returns the +/// input on success, or the `details` issue array on failure — every literal +/// (field set, key ORDER, message wording) is the ACTUAL zod 4.3.6 wire +/// output, probed against the real schema; see the module doc for the +/// version-fragility and no-consumer notes. `json!` insertion order IS the +/// serialized key order (workspace-wide `preserve_order`). +fn validate_resolve_body(body: &Value) -> Result { + let Value::Object(map) = body else { + // zod 4.3.6: `expected` precedes `code`; message carries the + // received type: `[1,2]` -> "...received array", `"x"` -> + // "...received string", etc. + return Err(json!([{ + "expected": "object", + "code": "invalid_type", + "path": [], + "message": format!("Invalid input: expected object, received {}", received_type(body)) + }])); + }; + let mut issues: Vec = Vec::new(); + // zod emits the shape (`input`) issue BEFORE `unrecognized_keys` + // (probed: `{foo:1}` -> [invalid_type(input), unrecognized_keys]). + match map.get("input") { + Some(Value::String(s)) => { + let len = s.encode_utf16().count(); + if len < 1 { + issues.push(json!({ + "origin": "string", + "code": "too_small", + "minimum": 1, + "inclusive": true, + "path": ["input"], + "message": "Too small: expected string to have >=1 characters" + })); + } else if len > RESOLVE_INPUT_MAX_UTF16 { + issues.push(json!({ + "origin": "string", + "code": "too_big", + "maximum": RESOLVE_INPUT_MAX_UTF16, + "inclusive": true, + "path": ["input"], + "message": "Too big: expected string to have <=20000 characters" + })); + } + } + other => { + // Missing (`received undefined`) and non-string values both + // surface zod's invalid_type, with the actual received type. + let received = other.map_or("undefined", received_type); + issues.push(json!({ + "expected": "string", + "code": "invalid_type", + "path": ["input"], + "message": format!("Invalid input: expected string, received {received}") + })); + } + } + let unknown: Vec<&str> = map + .keys() + .map(String::as_str) + .filter(|k| *k != "input") + .collect(); + if !unknown.is_empty() { + // zod 4.3.6: double-quoted names, singular/plural noun. + let listed = unknown + .iter() + .map(|k| format!("\"{k}\"")) + .collect::>() + .join(", "); + let noun = if unknown.len() == 1 { "key" } else { "keys" }; + issues.push(json!({ + "code": "unrecognized_keys", + "keys": unknown, + "path": [], + "message": format!("Unrecognized {noun}: {listed}") + })); + } + if issues.is_empty() { + Ok(map + .get("input") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string()) + } else { + Err(Value::Array(issues)) + } +} + +/// Wrap ONE provider's exact-id fallback closure in the bounded dispatch +/// that ports Node's per-worker containment (`opencode-by-id-runner.ts`): +/// admission is a SYNCHRONOUS `try_acquire_owned()` on `permits` +/// ([`RESOLVE_MAX_CONCURRENCY`]) — a starved dispatch fails fast with a +/// degraded provider error, never queues — and only an admitted invocation +/// runs on its OWN `spawn_blocking` task, bounded by `deadline` (Node's +/// hard 15 s worker timeout). +/// +/// The deadline is scoped to THIS dispatch alone — a timeout here never +/// affects later fallback invocations in the same request. Node bounds +/// each by-id worker individually, records the rejection for that ONE +/// provider, and continues through later candidates/providers +/// (`resolve-session.ts:133-156`); this port does the same, so a stalled +/// opencode store can never fabricate a claude timeout (or vice versa). +/// ERRATA: commit ffa4aac1a shared one cooperative cancel flag across +/// both providers' dispatches, so one timeout skipped every later +/// fallback and blamed providers that were never dispatched; corrected +/// to this per-dispatch scoping. +/// +/// RECORDED DEVIATION from Node's cancellation: a blocking task cannot be +/// killed, so on deadline elapse the task is ABANDONED (Node instead +/// `worker.terminate()`s the stalled thread) — the permit MOVES INTO the +/// task, so an abandoned task keeps holding it until its underlying op +/// returns, capping stalled-task accumulation at the permit count; within +/// one request the per-provider budget (`FALLBACK_BUDGET_PER_REQUEST` = 2) +/// caps abandonment at budget × wired providers. A panicking fallback is +/// resumed on the caller thread so the resolver task's JoinError still +/// answers the explicit 500. +/// +/// Called from the resolver's blocking thread: admission is synchronous +/// (no runtime needed), then `handle.block_on` re-enters the runtime for +/// the timeout machinery only (never from an async worker thread — the +/// resolver always runs under `spawn_blocking`). +fn bounded_fallback( + inner: FallbackFn, + handle: tokio::runtime::Handle, + permits: Arc, + deadline: std::time::Duration, +) -> FallbackFn { + Arc::new(move |id: &str| { + // Admission FIRST, synchronously, BEFORE any queueing or fallback + // task exists: a starved dispatch fails fast into the degraded + // provider-error shape instead of parking this (unbounded) outer + // resolver worker on the semaphore for up to the full deadline. + // ERRATA: commits bb357a598..37be35b9a awaited `acquire_owned()` + // inside the deadline from this thread, so full saturation pinned + // one outer blocking-pool worker per dispatch for the entire + // deadline — enough crafted requests could exhaust the pool, the + // exact failure the semaphore exists to prevent; corrected to this + // fail-fast `try_acquire_owned()`. (Node has no analogue of this + // state: it never caps admission because `worker.terminate()` + // reclaims a stalled worker at its timeout.) + let permit = match Arc::clone(&permits).try_acquire_owned() { + Ok(permit) => permit, + Err(_no_permits) => { + // The semaphore is never closed, so this is NoPermits: + // every permit is held by live or abandoned fallbacks. + tracing::warn!( + "resolve fallback rejected: concurrency limit reached; failing fast" + ); + return Err(ProviderFailure { + code: None, + message: "resolve concurrency limit reached".to_string(), + }); + } + }; + // Node's worker-timeout rejection shape: message-only providerError. + let message = format!("resolve timed out after {}ms", deadline.as_millis()); + let id = id.to_string(); + let inner = Arc::clone(&inner); + let task = handle.spawn_blocking(move || { + let _permit = permit; + inner(&id) + }); + let joined = handle.block_on(tokio::time::timeout(deadline, task)); + match joined { + Ok(Ok(result)) => result, + Ok(Err(join_error)) => { + if join_error.is_panic() { + // Propagate into the resolver task: its JoinError answers + // the explicit 500 (RECORDED DEVIATION, module doc) — + // never a fabricated ready-empty. + std::panic::resume_unwind(join_error.into_panic()); + } + Err(ProviderFailure { + code: None, + message: "resolve fallback task was cancelled".to_string(), + }) + } + Err(_elapsed) => { + // Deadline elapsed — the fallback itself stalled (the + // permit was already held before the task was spawned, so + // admission can never consume this budget). Abandon it and + // blame ONLY this provider; later dispatches in the same + // request run with their own bounds (Node continues through + // later candidates/providers, `resolve-session.ts:133-156`). + tracing::warn!( + deadline_ms = deadline.as_millis() as u64, + "resolve fallback timed out; abandoning its blocking task" + ); + Err(ProviderFailure { + code: None, + message, + }) + } + } + }) +} + +/// Node's override-key lookup for ONE indexed session +/// (`buildProjectGroups`, `session-indexer.ts:1173-1186`): the composite +/// `"{provider}:{sessionId}"` key first, then the bare session id; only when +/// NEITHER hits and the session is a claude transcript whose file basename +/// differs from its parsed session id (a pre-sessionId-parsing-era override) +/// do the legacy keys apply — composite `"claude:{basename}"`, then the bare +/// basename. First PRESENT entry wins, exactly like Node's `||` chain: an +/// earlier key that maps to an (even empty) object stops the fallthrough. +fn lookup_session_override<'a>( + overrides: &'a Map, + session: &IndexedSession, +) -> Option<&'a Map> { + let direct = overrides + .get(&session.key()) + .or_else(|| overrides.get(&session.session_id)) + .filter(|v| !v.is_null()); + if let Some(ov) = direct { + return ov.as_object(); + } + if session.provider != "claude" { + return None; + } + // `path.basename(sourceFile, '.jsonl')` (`session-indexer.ts:1176`). + let basename = session.source_file.as_deref()?.file_name()?.to_str()?; + let legacy_id = basename.strip_suffix(".jsonl").unwrap_or(basename); + if legacy_id.is_empty() || legacy_id == session.session_id { + return None; + } + overrides + .get(&format!("claude:{legacy_id}")) + .or_else(|| overrides.get(legacy_id)) + .filter(|v| !v.is_null()) + .and_then(Value::as_object) +} + +/// Node's `applyOverride` (`session-indexer.ts:204-220`) restricted to the +/// fields the resolve wire shape can observe: `deleted` hides the session +/// entirely (`None`), a non-empty `titleOverride` replaces its title +/// (Node's `!!ov?.titleOverride` — an empty string is falsy, never applied). +/// The other fields Node merges (`summaryOverride`, `createdAtOverride`, +/// `archived`) never reach a resolve match — matches carry only +/// title/cwd/lastActivityAt/sessionType/firstUserMessage — so they are not +/// projected here. Node's provider-generated-title suppression is ported too +/// (`session-indexer.ts:210-211`): when the CURRENT parsed title is +/// provider-generated (`IndexedSession::title_provider_generated`, Node's +/// cached `titleSource === 'provider-generated'`) and the stored override's +/// recorded `titleSource` is `dir` or `first-message`, the override is NOT +/// applied — the provider's own title survives, exactly as Node preserves it. +fn project_session_through_overrides( + session: &IndexedSession, + overrides: &Map, +) -> Option { + let Some(ov) = lookup_session_override(overrides, session) else { + return Some(session.clone()); + }; + if ov.get("deleted").and_then(Value::as_bool).unwrap_or(false) { + return None; + } + let mut projected = session.clone(); + // Node's `shouldApplyTitleOverride` (`session-indexer.ts:210-211`): the + // negated conjunction suppresses the override ONLY when the current title + // is provider-generated AND the override was recorded under the `dir` or + // `first-message` source — every other source (`user`, `ai`, `legacy`, + // absent) still substitutes. + let suppressed = session.title_provider_generated + && matches!( + ov.get("titleSource").and_then(Value::as_str), + Some("dir" | "first-message") + ); + if let Some(title) = ov + .get("titleOverride") + .and_then(Value::as_str) + .filter(|t| !t.is_empty() && !suppressed) + { + projected.title = Some(title.to_string()); + } + Some(projected) +} + +/// Does the request's `Content-Type` match what Node's global +/// `express.json()` (`server/index.ts:185`, express 4.22.1 / body-parser +/// 1.20.4, default `type: 'application/json'`) actually parses? type-is +/// semantics for that default: strip media-type parameters (`; charset=...`), +/// lowercase, then require the EXACT `application/json` media type — +/// `mimeMatch` only widens on `*` patterns, so `application/*+json` (e.g. +/// `application/vnd.api+json`) does NOT match, and an absent or unparseable +/// `Content-Type` never matches. +fn content_type_is_json(headers: &HeaderMap) -> bool { + headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(';').next()) + .is_some_and(|media| media.trim().eq_ignore_ascii_case("application/json")) +} + +/// `POST /api/sessions/resolve`. Body taken as raw bytes (never an +/// axum-flavored rejection), parsed as JSON ONLY when the `Content-Type` +/// matches Node's `express.json()` matcher ([`content_type_is_json`]): a +/// skipped (non-JSON/absent Content-Type), ABSENT, or UNPARSEABLE body +/// becomes `{}` — body-parser 1.20.x leaves `req.body = {}` when it skips, +/// and Express's `req.body ?? {}` hands zod that same value — so it 400s +/// with the missing-`input` issue. Parsed non-object values +/// (array/string/number/bool/null) flow to the invalid_type-object branch. +/// Recorded deviation (module doc): Express's strict body parser answers +/// malformed JSON and JSON scalars with an HTML 400 before zod ever runs; +/// this port answers those with the zod-shaped JSON 400 (status parity only +/// — no consumer reads 400 bodies). Arrays reach zod on both sides. +/// ERRATA: until this commit the bytes were parsed REGARDLESS of +/// Content-Type, so a valid object under `text/plain` resolved on Rust +/// while Node 400s it — an unrecorded divergence, now closed. +async fn resolve_session( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !is_authed(&headers, &state.auth_token) { + return unauthorized(); + } + let parsed: Value = if content_type_is_json(&headers) { + serde_json::from_slice(&body).unwrap_or_else(|_| Value::Object(Map::new())) + } else { + Value::Object(Map::new()) + }; + let input = match validate_resolve_body(&parsed) { + Ok(input) => input, + Err(details) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Invalid resolve request", "details": details })), + ) + .into_response(); + } + }; + + // Readiness gate = Node's `getIndexReadiness()`: a never-published (or + // absent) index answers `warming`. When a snapshot exists, + // `snapshot_with_failures()` returns it immediately + // (stale-while-revalidate) — it only blocks when truly cold, which + // `peek()` has already excluded. + // + // ONE COHERENT READ: the snapshot AND its scan failures come from the + // SAME published generation, in one lock acquisition. Reading them + // separately (snapshot here, `scan_failures()` after the resolve task) + // opened a window where a background sweep published in between — the + // response could pair a failed-scan empty snapshot with a subsequently + // cleared failure set (a healthy-looking `ready + matches: []` lie), or + // a recovered snapshot with stale failures. A warming index has no + // published generation, hence no failures. + let (snapshot, scan_failure_names) = match state.session_index.as_ref() { + Some(index) => match index.peek() { + Some(_) => { + let (items, failures) = index.snapshot_with_failures().await; + (Some(items), failures) + } + None => (None, Vec::new()), + }, + None => (None, Vec::new()), + }; + + // Override projection: Node's resolve reads the POST-override project + // groups (`resolve-session.ts:85` via `session-indexer.ts:1173-1187`, + // `applyOverride` at `session-indexer.ts:204-220`), so the Rust read + // model must project the snapshot through the SAME overlay before any + // matching: `deleted` hides the session, `titleOverride` replaces its + // title, and the override is looked up under EVERY key shape Node + // recognizes (composite `provider:sessionId`, bare session id, and the + // legacy claude transcript-basename keys) — see + // `lookup_session_override` / `project_session_through_overrides`. The + // exact-id FALLBACKS below intentionally BYPASS this projection — + // Node's fallbacks read sqlite/the filesystem directly and never + // consult overrides — bug-for-bug. + // Read the enabled set BEFORE dispatching the core resolve, and FILTER + // the snapshot with it: Node's index EXCLUDES disabled providers at scan + // time (`session-indexer.ts:1454-1467`), so its resolution never sees + // their sessions (`resolve-session.ts:85`). The Rust SessionIndex is + // built with all four sources unconditionally, so the route must apply + // the equivalent gate — otherwise a disabled provider's indexed session + // resolves while the same response lists that provider under + // `unsearchedProviders`. Fallbacks stay UNGATED (Node invokes all wired + // exact-id fallbacks regardless of settings — `resolve-session.ts:127-156`). + let enabled: std::collections::HashSet = state + .settings + .coding_cli_enabled_providers() + .await + .into_iter() + .collect(); + + let snapshot: Option> = snapshot.map(|sessions| { + let overrides = state.settings.session_overrides(); + sessions + .iter() + .filter(|session| enabled.contains(&session.provider)) + .filter_map(|session| project_session_through_overrides(session, &overrides)) + .collect() + }); + + // sessionType overlay (Node: `session-indexer.ts:1159-1161`), keyed + // `"{provider}:{session_id}"`. Only needed when we can match at all. + let session_types: HashMap = if snapshot.is_some() { + state + .session_metadata + .get_all() + .await + .into_iter() + .filter_map(|(key, entry)| { + entry + .get("sessionType") + .and_then(Value::as_str) + .map(|t| (key, t.to_string())) + }) + .collect() + } else { + HashMap::new() + }; + + // Each fallback dispatch below is bounded INDEPENDENTLY (own deadline, + // own permit): a timeout in one provider's dispatch never skips — or + // fabricates a timeout for — later dispatches, matching Node's + // per-worker containment (`resolve-session.ts:133-156` records the + // rejection for that provider and continues). Abandoned-task + // accumulation is capped by the per-provider budget within a request + // and by the admission semaphore across requests. + // Captured HERE (async context) so the blocking resolver thread can + // dispatch each fallback back onto the runtime via `Handle::block_on`. + let handle = tokio::runtime::Handle::current(); + let opencode: Option = state.opencode_session_by_id.clone().map(|inner| { + bounded_fallback( + inner, + handle.clone(), + Arc::clone(&state.resolve_permits), + state.resolve_deadline, + ) + }); + let claude: Option = state.locate_claude_transcript.clone().map(|inner| { + bounded_fallback( + inner, + handle.clone(), + Arc::clone(&state.resolve_permits), + state.resolve_deadline, + ) + }); + // The resolver task itself runs WITHOUT a permit and WITHOUT a deadline + // (Node parity: parsing, warming checks, in-memory index matching, and + // no-candidate responses never wait on fallback-worker availability — + // its 15 s timeout wraps the individual by-id worker only, + // `opencode-by-id-runner.ts`). Its non-fallback work is bounded + // in-memory matching, and every stall-prone fallback invocation inside + // it is bounded by the permit + deadline dispatch above, so this task's + // lifetime is bounded even against a fully stalled provider store. + let joined = tokio::task::spawn_blocking(move || { + let deps = ResolveDeps { + // as_deref (Option> -> Option<&[T]>): as_ref().map(|s| s.as_slice()) + // trips clippy's warn-by-default `option_as_ref_deref` under -D warnings. + sessions: snapshot.as_deref(), + session_types: &session_types, + locate_claude_transcript: claude.as_deref(), + opencode_session_by_id: opencode.as_deref(), + }; + resolve_resume_input(&input, &deps) + }) + .await; + + // JoinError = the resolve task PANICKED. RECORDED DEVIATION (module + // doc): Node has no defined behavior here (unhandled rejection, no + // response); the explicit 500 is the honest port — the hardened + // contract forbids presenting an unsearchable state as a healthy + // "not found", so NEVER fabricate a ready-empty result. The panic + // itself is already on stderr for diagnosis. + let outcome = match joined { + Ok(outcome) => outcome, + Err(join_error) => { + tracing::error!(error = %join_error, "resolve task panicked"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "Resolve failed" })), + ) + .into_response(); + } + }; + + // Router-level merge (`sessions-router.ts:280-314`). + let unsearched_providers: Vec = KNOWN_RESUME_PROVIDERS + .iter() + .filter(|name| !enabled.contains(**name)) + .map(|name| (*name).to_string()) + .collect(); + // Scan failures: enabled-only, fallback errors win the dedupe (more + // specific code/message). A DISABLED provider is unsearched (reported + // above), never a provider error — otherwise a failed-then-disabled + // provider would keep responses degraded forever (no successful scan + // could ever clear it). + // `scan_failure_names` was captured atomically WITH the snapshot above + // (one generation, one lock) — never re-read here, where a background + // sweep completing mid-request could pair the earlier snapshot with a + // newer (cleared or stale) failure set. + let mut provider_errors = outcome.provider_errors; + for name in scan_failure_names { + if !enabled.contains(&name) || provider_errors.iter().any(|e| e.provider == name) { + continue; + } + provider_errors.push(ResumeResolveProviderError { + provider: name, + code: None, + message: Some("session scan failed".to_string()), + }); + } + // degraded = something FAILED — even when matches exist: a failed + // provider means a HIGHER-priority exact match may have been missed, so + // the client must never auto-resume a surviving lower-priority match. + let status = match outcome.status { + ResumeResolveStatus::Warming => ResumeResolveStatus::Warming, + _ if !provider_errors.is_empty() => ResumeResolveStatus::Degraded, + _ => ResumeResolveStatus::Ready, + }; + // Fire-and-forget: give the user's Retry a chance to converge once a + // failed provider recovers (scan failures only clear on a new scan). + if status == ResumeResolveStatus::Degraded { + if let Some(index) = state.session_index.as_ref() { + index.request_refresh(); + } + } + Json(ResolveWireResponse { + status, + matches: outcome.matches, + hint: outcome.hint, + provider_errors, + unsearched_providers, + home_dir: state.home_dir.as_ref().map(|h| h.as_str().to_string()), + }) + .into_response() +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use tower::ServiceExt; + + use freshell_sessions::directory_index::{ + FileStat, IndexedSession, SessionIndex, SessionSource, + }; + + const CLAUDE_ID: &str = "ed2afda6-a340-443e-ba60-024a1b3554b4"; + + /// A file-less, direct-listed source: `discover()` empty, `direct_list()` + /// serves the fixture rows — a hermetic SessionIndex with zero disk IO. + struct FixtureSource(Vec); + + impl SessionSource for FixtureSource { + fn discover(&self) -> Vec { + Vec::new() + } + fn parse(&self, _path: &std::path::Path) -> Option { + None + } + fn direct_change_token(&self) -> Option { + Some(1) + } + fn direct_list(&self) -> Result, String> { + Ok(self.0.clone()) + } + } + + async fn fixture_index(sessions: Vec) -> Arc { + fixture_index_with_sources(vec![ + Arc::new(FixtureSource(sessions)) as Arc + ]) + .await + } + + async fn fixture_index_with_sources(sources: Vec>) -> Arc { + let index = Arc::new(SessionIndex::with_ttl_and_cache_path( + sources, + std::time::Duration::from_secs(3600), + None, + )); + index.warm().await; + index + } + + /// The Task-5 `FlakySource` shape (`directory_index.rs` tests): a + /// direct-listed opencode source whose `direct_list()` errs while + /// `broken` is true. The change token CHANGES every call so each sweep + /// re-queries — recovery is observable after `broken` flips to false. + struct FailingDirectSource { + broken: Arc, + counter: std::sync::atomic::AtomicI64, + } + + impl FailingDirectSource { + fn new(broken: Arc) -> Self { + Self { + broken, + counter: std::sync::atomic::AtomicI64::new(0), + } + } + } + + impl SessionSource for FailingDirectSource { + fn discover(&self) -> Vec { + Vec::new() + } + fn parse(&self, _path: &std::path::Path) -> Option { + None + } + fn provider_name(&self) -> Option<&'static str> { + Some("opencode") + } + fn direct_change_token(&self) -> Option { + Some( + self.counter + .fetch_add(1, std::sync::atomic::Ordering::SeqCst), + ) + } + fn direct_list(&self) -> Result, String> { + if self.broken.load(std::sync::atomic::Ordering::SeqCst) { + Err("unable to open database file".to_string()) + } else { + Ok(Vec::new()) + } + } + } + + /// Seed `/.freshell/config.json` with the WRAPPED settings document + /// (`SettingsStore` unwraps the top-level `settings` key — see + /// `load_full_settings` in `settings_store.rs`; a bare `codingCli` + /// object would be silently ignored, reading defaults). + fn seed_enabled_providers(dir: &std::path::Path, providers: &[&str]) { + let cfg_dir = dir.join(".freshell"); + std::fs::create_dir_all(&cfg_dir).expect("mkdir .freshell"); + std::fs::write( + cfg_dir.join("config.json"), + serde_json::json!({ + "version": 1, + "settings": { "codingCli": { "enabledProviders": providers } } + }) + .to_string(), + ) + .expect("seed config.json"); + } + + fn claude_fixture() -> IndexedSession { + IndexedSession { + session_id: CLAUDE_ID.to_string(), + provider: "claude".to_string(), + project_path: "/repo/alpha".to_string(), + title: Some("Fix the parser".to_string()), + title_provider_generated: false, + summary: None, + first_user_message: Some("fix the parser".to_string()), + last_activity_at: 400, + created_at: None, + cwd: Some("/repo/alpha".to_string()), + is_subagent: false, + is_non_interactive: false, + source_file: None, + } + } + + fn temp_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "frs-resolve-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("mkdir temp dir"); + dir + } + + fn state(dir: &std::path::Path, index: Option>) -> super::ResolveState { + super::ResolveState { + auth_token: Arc::new("tok".into()), + // Isolated home: overrides read/write under `/.freshell/`, + // never the developer's real config (same pattern as the + // session_directory router tests). All four Node providers are + // discovered, and the fresh-store default enables all four + // (Node's `DEFAULT_ENABLED_CLI_PROVIDERS`), so the baseline + // `unsearchedProviders` is `[]` — deterministic expectations. + settings: crate::settings_store::SettingsStore::load( + Some(dir), + vec![ + "claude".into(), + "codex".into(), + "opencode".into(), + "amplifier".into(), + ], + ), + session_index: index, + session_metadata: crate::session_metadata::SessionMetadataStore::new(dir), + opencode_session_by_id: None, + locate_claude_transcript: None, + home_dir: Some(Arc::new("/home/tester".to_string())), + resolve_deadline: super::RESOLVE_FALLBACK_DEADLINE, + resolve_permits: Arc::new(tokio::sync::Semaphore::new(super::RESOLVE_MAX_CONCURRENCY)), + } + } + + async fn post( + state: super::ResolveState, + body: serde_json::Value, + with_auth: bool, + ) -> (StatusCode, serde_json::Value) { + post_with_content_type(state, body, Some("application/json"), with_auth).await + } + + /// Like [`post`] but with an arbitrary (or absent) `Content-Type` header + /// — the Node reference (`express.json()` at `server/index.ts:185`) only + /// parses JSON-typed bodies, so the route tests must be able to send + /// non-JSON media types. + async fn post_with_content_type( + state: super::ResolveState, + body: serde_json::Value, + content_type: Option<&str>, + with_auth: bool, + ) -> (StatusCode, serde_json::Value) { + let app = super::router(state); + let mut builder = Request::builder() + .method("POST") + .uri("/api/sessions/resolve"); + if let Some(ct) = content_type { + builder = builder.header("content-type", ct); + } + if with_auth { + builder = builder.header("x-auth-token", "tok"); + } + let request = builder.body(Body::from(body.to_string())).unwrap(); + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + (status, value) + } + + /// The zod issue Node emits when `req.body` was never populated: + /// `express.json()` skipped the body (non-JSON or absent Content-Type), + /// body-parser 1.20.x leaves `req.body = {}`, and + /// `safeParse(req.body ?? {})` reports the missing `input`. + fn missing_input_details() -> serde_json::Value { + serde_json::json!([{ + "expected": "string", + "code": "invalid_type", + "path": ["input"], + "message": "Invalid input: expected string, received undefined" + }]) + } + + #[tokio::test] + async fn non_json_content_type_body_is_never_parsed() { + // Node's global `express.json()` (`server/index.ts:185`, default + // `type: 'application/json'`) skips non-matching media types — + // type-is requires the EXACT `application/json` media type, so + // `application/*+json` is skipped too — leaving `req.body = {}` and + // the route 400s with the missing-`input` issue + // (`sessions-router.ts:259-264`). A valid JSON object under + // `text/plain` must NOT resolve. + for ct in ["text/plain", "application/vnd.api+json"] { + let dir = temp_dir("ctgate"); + let (status, body) = post_with_content_type( + state(&dir, None), + serde_json::json!({ "input": CLAUDE_ID }), + Some(ct), + true, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "content-type {ct}"); + assert_eq!( + body, + serde_json::json!({ + "error": "Invalid resolve request", + "details": missing_input_details() + }), + "content-type {ct}" + ); + } + } + + #[tokio::test] + async fn json_content_type_with_parameters_or_case_still_parses() { + // type-is strips parameters and lowercases before matching, so + // `application/json; charset=utf-8` (and case variants) still parse. + for ct in ["application/json; charset=utf-8", "Application/JSON"] { + let dir = temp_dir("ctok"); + let (status, body) = post_with_content_type( + state(&dir, None), + serde_json::json!({ "input": CLAUDE_ID }), + Some(ct), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "content-type {ct}"); + assert_eq!(body["status"], "warming", "content-type {ct}"); // no index in this state + } + } + + #[tokio::test] + async fn missing_content_type_is_treated_as_an_unparsed_body() { + // No Content-Type header: type-is cannot match, `express.json()` + // skips, `req.body = {}` — same missing-`input` 400 as `text/plain`. + let dir = temp_dir("ctnone"); + let (status, body) = post_with_content_type( + state(&dir, None), + serde_json::json!({ "input": CLAUDE_ID }), + None, + true, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + body, + serde_json::json!({ + "error": "Invalid resolve request", + "details": missing_input_details() + }) + ); + } + + #[tokio::test] + async fn rejects_unauthenticated_requests() { + let dir = temp_dir("auth"); + let (status, body) = post( + state(&dir, None), + serde_json::json!({ "input": CLAUDE_ID }), + false, + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body, serde_json::json!({ "error": "Unauthorized" })); + } + + #[tokio::test] + async fn rejects_unknown_keys_with_the_zod_4_3_6_literal() { + // `input` valid, two unknown keys: exactly ONE issue, plural noun, + // double-quoted names, key order code/keys/path/message. + let dir = temp_dir("strict"); + let (status, body) = post( + state(&dir, None), + serde_json::json!({ "input": "x", "foo": 1, "bar": 2 }), + true, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"], "Invalid resolve request"); + assert_eq!( + body["details"], + serde_json::json!([{ + "code": "unrecognized_keys", + "keys": ["foo", "bar"], + "path": [], + "message": "Unrecognized keys: \"foo\", \"bar\"" + }]) + ); + } + + #[tokio::test] + async fn multi_issue_order_is_input_issue_then_unrecognized_keys() { + // Probed zod 4.3.6 behavior for `{foo:1}`: the `input` invalid_type + // issue comes FIRST, `unrecognized_keys` (singular form) SECOND. + let dir = temp_dir("multi"); + let (status, body) = post(state(&dir, None), serde_json::json!({ "foo": 1 }), true).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + body["details"], + serde_json::json!([ + { + "expected": "string", + "code": "invalid_type", + "path": ["input"], + "message": "Invalid input: expected string, received undefined" + }, + { + "code": "unrecognized_keys", + "keys": ["foo"], + "path": [], + "message": "Unrecognized key: \"foo\"" + } + ]) + ); + } + + #[tokio::test] + async fn zod_details_literals_match_zod_4_3_6_wire_output() { + // One case per failure class; expectations are the EXACT zod 4.3.6 + // `parsed.error.issues` output probed against the real schema. The + // scalar bodies (`null` here) are the recorded deviation: Express's + // strict body parser HTML-400s them before zod, Rust answers the + // zod-shaped issue for the parsed value instead. + let dir = temp_dir("bounds"); + let cases: Vec<(serde_json::Value, serde_json::Value)> = vec![ + ( + serde_json::json!({ "input": "" }), + serde_json::json!([{ + "origin": "string", + "code": "too_small", + "minimum": 1, + "inclusive": true, + "path": ["input"], + "message": "Too small: expected string to have >=1 characters" + }]), + ), + ( + serde_json::json!({}), + serde_json::json!([{ + "expected": "string", + "code": "invalid_type", + "path": ["input"], + "message": "Invalid input: expected string, received undefined" + }]), + ), + ( + serde_json::json!({ "input": 123 }), + serde_json::json!([{ + "expected": "string", + "code": "invalid_type", + "path": ["input"], + "message": "Invalid input: expected string, received number" + }]), + ), + ( + serde_json::json!({ "input": "x".repeat(20001) }), + serde_json::json!([{ + "origin": "string", + "code": "too_big", + "maximum": 20000, + "inclusive": true, + "path": ["input"], + "message": "Too big: expected string to have <=20000 characters" + }]), + ), + ( + serde_json::json!([1, 2]), + serde_json::json!([{ + "expected": "object", + "code": "invalid_type", + "path": [], + "message": "Invalid input: expected object, received array" + }]), + ), + ( + serde_json::json!(null), + serde_json::json!([{ + "expected": "object", + "code": "invalid_type", + "path": [], + "message": "Invalid input: expected object, received null" + }]), + ), + ]; + for (body, details) in cases { + let (status, response) = post(state(&dir, None), body.clone(), true).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "body {body}"); + assert_eq!(response["error"], "Invalid resolve request", "body {body}"); + assert_eq!(response["details"], details, "body {body}"); + } + // Key ORDER is part of the wire shape (zod v4 emits `expected` / + // `origin` BEFORE `code`). `Value` equality is order-insensitive, so + // pin one case as a serialized string — `preserve_order` makes the + // parsed order round-trip the wire order. + let (_, response) = + post(state(&dir, None), serde_json::json!({ "input": 123 }), true).await; + assert_eq!( + serde_json::to_string(&response["details"]).unwrap(), + r#"[{"expected":"string","code":"invalid_type","path":["input"],"message":"Invalid input: expected string, received number"}]"# + ); + } + + #[tokio::test] + async fn input_of_exactly_20000_chars_is_accepted() { + let dir = temp_dir("maxok"); + let (status, body) = post( + state(&dir, None), + serde_json::json!({ "input": "x".repeat(20000) }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "warming"); // no index in this state + } + + #[tokio::test] + async fn warming_with_hint_when_index_never_published() { + let dir = temp_dir("warming"); + let (status, body) = post( + state(&dir, None), + serde_json::json!({ "input": format!("claude --resume {CLAUDE_ID}") }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + body, + serde_json::json!({ + "status": "warming", + "matches": [], + "hint": { "provider": "claude", "source": "command" }, + "providerErrors": [], + "unsearchedProviders": [], + "homeDir": "/home/tester" + }) + ); + } + + #[tokio::test] + async fn exact_match_returns_full_metadata_via_the_index() { + let dir = temp_dir("exact"); + let index = fixture_index(vec![claude_fixture()]).await; + let (status, body) = post( + state(&dir, Some(index)), + serde_json::json!({ "input": CLAUDE_ID }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "ready"); + assert_eq!( + body["matches"], + serde_json::json!([{ + "provider": "claude", + "sessionId": CLAUDE_ID, + "cwd": "/repo/alpha", + // Hardened Node emits `sessionType ?? provider` — never absent. + "sessionType": "claude", + "title": "Fix the parser", + "firstUserMessage": "fix the parser", + "lastActivityAt": 400, + "matchKind": "exact" + }]) + ); + } + + #[tokio::test] + async fn session_type_overlays_from_the_metadata_store_file() { + let dir = temp_dir("stype"); + std::fs::write( + dir.join("session-metadata.json"), + serde_json::json!({ + "version": 1, + "sessions": { + "claude": { + CLAUDE_ID: { "sessionType": "freshclaude", "sessionTypeSource": "explicit" } + } + } + }) + .to_string(), + ) + .unwrap(); + let index = fixture_index(vec![claude_fixture()]).await; + let (_, body) = post( + state(&dir, Some(index)), + serde_json::json!({ "input": CLAUDE_ID }), + true, + ) + .await; + assert_eq!(body["matches"][0]["sessionType"], "freshclaude"); + } + + #[tokio::test] + async fn unknown_id_is_ready_empty_never_404() { + let dir = temp_dir("miss"); + let index = fixture_index(vec![claude_fixture()]).await; + let (status, body) = post( + state(&dir, Some(index)), + serde_json::json!({ "input": "019fffff-ffff-7fff-bfff-ffffffffffff" }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "ready"); + assert_eq!(body["matches"], serde_json::json!([])); + } + + #[tokio::test] + async fn opencode_fallback_answers_with_row_directory() { + let dir = temp_dir("ocfb"); + let index = fixture_index(vec![claude_fixture()]).await; + let unknown = "ses_child000000000000000000000"; + let mut st = state(&dir, Some(index)); + st.opencode_session_by_id = Some(Arc::new(|id: &str| { + Ok(Some(freshell_sessions::resume_resolve::OpencodeByIdHit { + session_id: id.to_string(), + cwd: Some("/repo/beta".to_string()), + title: Some("beta".to_string()), + last_activity_at: Some(1234), + })) + })); + let (status, body) = post(st, serde_json::json!({ "input": unknown }), true).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + body["matches"], + serde_json::json!([{ + "provider": "opencode", + "sessionId": unknown, + "cwd": "/repo/beta", + "sessionType": "opencode", + "title": "beta", + "lastActivityAt": 1234, + "matchKind": "exact" + }]) + ); + } + + #[tokio::test] + async fn claude_transcript_fallback_answers_on_index_miss() { + let dir = temp_dir("clfb"); + let index = fixture_index(vec![claude_fixture()]).await; + let unknown = "aaaaaaaa-1111-4222-8333-444444444444"; + let mut st = state(&dir, Some(index)); + st.locate_claude_transcript = Some(Arc::new(move |id: &str| { + Ok(Some( + freshell_sessions::resume_resolve::ClaudeTranscriptHit { + session_id: id.to_ascii_lowercase(), + cwd: Some("/repo/gamma".to_string()), + }, + )) + })); + let (status, body) = post(st, serde_json::json!({ "input": unknown }), true).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + body["matches"], + serde_json::json!([{ + "provider": "claude", + "sessionId": unknown, + "cwd": "/repo/gamma", + "sessionType": "claude", + "matchKind": "exact" + }]) + ); + } + + #[tokio::test] + async fn deleted_override_hides_the_session_from_resolve() { + // Node's resolve reads the post-deleted-filter project groups + // (`session-indexer.ts:209,1155-1156`) and the Rust sidebar filters + // the same way (`session_directory.rs::apply_session_overrides`) — + // the resolve read model must agree with both. Written through the + // REAL override write path (`patch_session_override`, the same call + // `PATCH /api/sessions/{id}` lands on). + let dir = temp_dir("deleted"); + let index = fixture_index(vec![claude_fixture()]).await; + let st = state(&dir, Some(index)); + st.settings + .patch_session_override( + &format!("claude:{CLAUDE_ID}"), + &[("deleted", Some(serde_json::json!(true)))], + ) + .await; + let (status, body) = post(st, serde_json::json!({ "input": CLAUDE_ID }), true).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "ready"); + assert_eq!(body["matches"], serde_json::json!([])); + } + + /// The pre-parsing-era transcript basename for the legacy-override tests: + /// differs from `CLAUDE_ID`, so Node's legacy branch + /// (`session-indexer.ts:1175-1186`) fires for it. + const LEGACY_BASENAME: &str = "11111111-2222-4333-8444-555555555555"; + + /// `claude_fixture()` whose transcript file basename differs from its + /// parsed session id — the shape that makes Node consult the legacy + /// `claude:{basename}` / bare-`{basename}` override keys. + fn claude_fixture_with_legacy_source() -> IndexedSession { + IndexedSession { + source_file: Some(std::path::PathBuf::from(format!( + "/home/tester/.claude/projects/-repo-alpha/{LEGACY_BASENAME}.jsonl" + ))), + ..claude_fixture() + } + } + + #[tokio::test] + async fn title_override_projects_onto_resolve_matches() { + // Node's resolve reads the POST-override projection + // (`resolve-session.ts:85` via `session-indexer.ts:1187,204-220`): + // a user rename (`titleOverride`) must be visible on the resolve + // wire, not the stale parsed title. + let dir = temp_dir("titleov"); + let index = fixture_index(vec![claude_fixture()]).await; + let st = state(&dir, Some(index)); + st.settings + .patch_session_override( + &format!("claude:{CLAUDE_ID}"), + &[ + ("titleOverride", Some(serde_json::json!("Renamed by user"))), + ("titleSource", Some(serde_json::json!("user"))), + ], + ) + .await; + let (status, body) = post(st, serde_json::json!({ "input": CLAUDE_ID }), true).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "ready"); + assert_eq!(body["matches"][0]["sessionId"], CLAUDE_ID); + assert_eq!(body["matches"][0]["title"], "Renamed by user"); + } + + /// `claude_fixture()` whose title the PROVIDER generated (Node's + /// parse-layer `titleSource: 'provider-generated'`, + /// `providers/claude.ts:505`). + fn claude_fixture_provider_titled() -> IndexedSession { + IndexedSession { + title: Some("Provider generated title".to_string()), + title_provider_generated: true, + ..claude_fixture() + } + } + + /// Shared body for the suppression pair: a session whose CURRENT title is + /// provider-generated, with a stored override recorded under + /// `titleSource: {override_source}` — Node's `applyOverride` + /// (`session-indexer.ts:210-211`) must PRESERVE the provider title. + async fn assert_provider_title_survives_override(tag: &str, override_source: &str) { + let dir = temp_dir(tag); + let index = fixture_index(vec![claude_fixture_provider_titled()]).await; + let st = state(&dir, Some(index)); + st.settings + .patch_session_override( + &format!("claude:{CLAUDE_ID}"), + &[ + ( + "titleOverride", + Some(serde_json::json!("stale placeholder")), + ), + ("titleSource", Some(serde_json::json!(override_source))), + ], + ) + .await; + let (status, body) = post(st, serde_json::json!({ "input": CLAUDE_ID }), true).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "ready"); + assert_eq!(body["matches"][0]["sessionId"], CLAUDE_ID); + assert_eq!( + body["matches"][0]["title"], "Provider generated title", + "a {override_source}-sourced override must not clobber a provider-generated title" + ); + } + + #[tokio::test] + async fn provider_generated_title_survives_dir_sourced_override() { + // Node's suppression branch (`session-indexer.ts:210-211`): a stale + // `dir`-sourced override never replaces a CURRENT provider-generated + // title on the projected (pre-matching) session view. + assert_provider_title_survives_override("provdir", "dir").await; + } + + #[tokio::test] + async fn provider_generated_title_survives_first_message_sourced_override() { + // Same branch, second suppressed source: `first-message`. + assert_provider_title_survives_override("provfirstmsg", "first-message").await; + } + + #[tokio::test] + async fn user_rename_still_overrides_a_provider_generated_title() { + // The suppression is SCOPED to `dir`/`first-message` override sources: + // an explicit user rename (`titleSource: 'user'`) substitutes even + // over a provider-generated title (`session-indexer.ts:210-211` — + // the negated conjunction only matches those two sources). + let dir = temp_dir("provuser"); + let index = fixture_index(vec![claude_fixture_provider_titled()]).await; + let st = state(&dir, Some(index)); + st.settings + .patch_session_override( + &format!("claude:{CLAUDE_ID}"), + &[ + ("titleOverride", Some(serde_json::json!("Renamed by user"))), + ("titleSource", Some(serde_json::json!("user"))), + ], + ) + .await; + let (status, body) = post(st, serde_json::json!({ "input": CLAUDE_ID }), true).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "ready"); + assert_eq!(body["matches"][0]["title"], "Renamed by user"); + } + + #[tokio::test] + async fn bare_session_id_deleted_override_hides_prefix_matches() { + // Node's override lookup falls back to the BARE session-id key + // (`session-indexer.ts:1174`); a session deleted under it must not + // resurface as a prefix match on the Rust side. + let dir = temp_dir("baredel"); + let index = fixture_index(vec![claude_fixture()]).await; + let st = state(&dir, Some(index)); + st.settings + .patch_session_override(CLAUDE_ID, &[("deleted", Some(serde_json::json!(true)))]) + .await; + let prefix = &CLAUDE_ID[..12]; + let (status, body) = post(st, serde_json::json!({ "input": prefix }), true).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "ready"); + assert_eq!(body["matches"], serde_json::json!([])); + } + + #[tokio::test] + async fn legacy_claude_composite_basename_deleted_override_hides_the_session() { + // Node's legacy branch (`session-indexer.ts:1175-1186`): when neither + // the composite nor bare current-id key hits and the claude + // transcript's file basename differs from its session id, the + // `claude:{basename}` key applies. + let dir = temp_dir("legacycomp"); + let index = fixture_index(vec![claude_fixture_with_legacy_source()]).await; + let st = state(&dir, Some(index)); + st.settings + .patch_session_override( + &format!("claude:{LEGACY_BASENAME}"), + &[("deleted", Some(serde_json::json!(true)))], + ) + .await; + let (status, body) = post(st, serde_json::json!({ "input": CLAUDE_ID }), true).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "ready"); + assert_eq!(body["matches"], serde_json::json!([])); + } + + #[tokio::test] + async fn legacy_claude_bare_basename_deleted_override_hides_the_session() { + // The deepest rung of Node's lookup chain + // (`session-indexer.ts:1179`): the bare transcript-basename key. + let dir = temp_dir("legacybare"); + let index = fixture_index(vec![claude_fixture_with_legacy_source()]).await; + let st = state(&dir, Some(index)); + st.settings + .patch_session_override( + LEGACY_BASENAME, + &[("deleted", Some(serde_json::json!(true)))], + ) + .await; + let (status, body) = post(st, serde_json::json!({ "input": CLAUDE_ID }), true).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "ready"); + assert_eq!(body["matches"], serde_json::json!([])); + } + + #[tokio::test] + async fn malformed_json_body_degrades_to_the_missing_input_400() { + // Express's strict body parser answers malformed JSON with an HTML + // 400 before zod runs; this port treats an unparseable body as `{}` + // (Node's absent-body `req.body ?? {}`) and answers the zod-shaped + // missing-`input` 400 — status parity only, a recorded deviation. + let dir = temp_dir("badjson"); + let app = super::router(state(&dir, None)); + let request = Request::builder() + .method("POST") + .uri("/api/sessions/resolve") + .header("content-type", "application/json") + .header("x-auth-token", "tok") + .body(Body::from("{not json")) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["error"], "Invalid resolve request"); + assert_eq!( + body["details"], + serde_json::json!([{ + "expected": "string", + "code": "invalid_type", + "path": ["input"], + "message": "Invalid input: expected string, received undefined" + }]) + ); + } + + // -- Task 6 (resolve parity): hardened wire surface --------------------- + + #[tokio::test] + async fn wire_shape_carries_the_hardened_provider_health_fields() { + let dir = temp_dir("wire"); + let index = fixture_index(vec![claude_fixture()]).await; + let (status, body) = post( + state(&dir, Some(index)), + serde_json::json!({ "input": CLAUDE_ID }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "ready"); + assert_eq!(body["providerErrors"], serde_json::json!([])); + assert!(body["unsearchedProviders"].is_array()); + // Baseline is [] — the fresh-store default enables all FOUR Node + // providers (`DEFAULT_ENABLED_CLI_PROVIDERS` incl. amplifier); a + // regression in that default must fail loudly here. + assert_eq!(body["unsearchedProviders"], serde_json::json!([])); + assert_eq!(body["homeDir"], "/home/tester"); + } + + #[tokio::test] + async fn broken_opencode_store_degrades_with_a_provider_error_never_silent_not_found() { + // THE acceptance test (context §4): an unreadable provider store yields + // degraded + providerErrors on the wire — matches stay empty, status is + // NOT "ready". + let dir = temp_dir("degraded"); + let index = fixture_index(vec![claude_fixture()]).await; + let mut st = state(&dir, Some(index)); + // Node production parity (`sessions-resolve-router.test.ts:308-320`): the + // opencode worker boundary strips `.code`, so the wire entry is + // message-only — `code` must be ABSENT, not null-with-key. The production + // closure (main.rs) maps OpencodeByIdError to code: None accordingly. + st.opencode_session_by_id = Some(Arc::new(|_id: &str| { + Err(freshell_sessions::resume_resolve::ProviderFailure { + code: None, + message: "unable to open database file".into(), + }) + })); + let (status, body) = post( + st, + serde_json::json!({ "input": "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa" }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "degraded"); + assert_eq!(body["matches"], serde_json::json!([])); + assert_eq!( + body["providerErrors"], + serde_json::json!([{ "provider": "opencode", "message": "unable to open database file" }]) + ); + } + + #[tokio::test] + async fn degraded_even_with_matches_when_a_higher_priority_fallback_failed() { + // ses_ fallback fails; the later hex token still prefix-matches the index + // — the response carries the match AND stays degraded (no auto-resume). + let dir = temp_dir("degmatch"); + let mut amp = claude_fixture(); + amp.provider = "amplifier".to_string(); + amp.session_id = "417e8345aaaa".to_string(); + let index = fixture_index(vec![amp]).await; + let mut st = state(&dir, Some(index)); + st.opencode_session_by_id = Some(Arc::new(|_id: &str| { + Err(freshell_sessions::resume_resolve::ProviderFailure { + code: None, + message: "locked".into(), + }) + })); + let (_, body) = post( + st, + serde_json::json!({ "input": "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa 417e8345" }), + true, + ) + .await; + assert_eq!(body["status"], "degraded"); + assert_eq!(body["matches"][0]["sessionId"], "417e8345aaaa"); + } + + #[tokio::test] + async fn a_provider_scan_failure_reports_degraded_with_the_scan_failed_literal() { + // Index whose direct-listed source errs → scan_failures ["opencode"] → + // degraded + {provider:"opencode", message:"session scan failed"} even + // though no fallback ran. + let dir = temp_dir("scanfail"); + let broken = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let index = fixture_index_with_sources(vec![ + Arc::new(FixtureSource(vec![claude_fixture()])) as Arc, + Arc::new(FailingDirectSource::new(broken)) as Arc, + ]) + .await; + let (status, body) = post( + state(&dir, Some(index)), + serde_json::json!({ "input": CLAUDE_ID }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "degraded"); + assert_eq!( + body["providerErrors"], + serde_json::json!([{ "provider": "opencode", "message": "session scan failed" }]) + ); + // degraded ≠ empty: the exact index hit still rides along. + assert_eq!(body["matches"][0]["sessionId"], CLAUDE_ID); + } + + #[tokio::test] + async fn disabled_providers_are_reported_unsearched_never_as_errors() { + // Settings with enabledProviders ["claude"]: unsearchedProviders lists + // the other three; a scan failure for DISABLED opencode is excluded + // from providerErrors and the response stays "ready" (a + // failed-then-disabled provider must not stick degraded forever). + let dir = temp_dir("disabled"); + seed_enabled_providers(&dir, &["claude"]); + let broken = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let index = fixture_index_with_sources(vec![ + Arc::new(FixtureSource(vec![claude_fixture()])) as Arc, + Arc::new(FailingDirectSource::new(broken)) as Arc, + ]) + .await; + let (status, body) = post( + state(&dir, Some(index)), + serde_json::json!({ "input": CLAUDE_ID }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "ready"); + assert_eq!(body["providerErrors"], serde_json::json!([])); + let unsearched = body["unsearchedProviders"].as_array().unwrap(); + for name in ["codex", "opencode", "amplifier"] { + assert!( + unsearched.iter().any(|v| v == name), + "{name} must be listed unsearched, got {unsearched:?}" + ); + } + } + + #[tokio::test] + async fn disabled_provider_indexed_sessions_do_not_resolve() { + // Node's INDEX excludes disabled providers (session-indexer.ts:1454-1467), + // so its resolution never sees their sessions (resolve-session.ts:85). + // Rust must filter the snapshot by the live enabled set BEFORE core + // resolution — a disabled provider's session resolving while that + // provider is listed in unsearchedProviders would be self-contradictory. + let dir = temp_dir("disidx"); + seed_enabled_providers(&dir, &["claude"]); + let codex_id = "0198c0de-aaaa-4bbb-8ccc-1234567890ab"; + let mut codex = claude_fixture(); + codex.provider = "codex".to_string(); + codex.session_id = codex_id.to_string(); + let index = fixture_index(vec![codex]).await; + let (status, body) = post( + state(&dir, Some(index)), + serde_json::json!({ "input": codex_id }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "ready"); + assert_eq!(body["matches"], serde_json::json!([])); + let unsearched = body["unsearchedProviders"].as_array().unwrap(); + assert!( + unsearched.iter().any(|v| v == "codex"), + "codex must be listed unsearched, got {unsearched:?}" + ); + } + + #[tokio::test] + async fn a_disabled_provider_exact_id_still_resolves_via_fallback_node_parity() { + // Node wires ALL FOUR providers' exact-id fallbacks unconditionally + // (server/index.ts wiring; resolve-session.ts:127-156 invokes them + // regardless of settings) — settings gate INDEXING only. A disabled + // opencode's exact ses_ id must therefore still resolve via the + // fallback, while "opencode" stays listed in unsearchedProviders. + const SES_ID: &str = "ses_bbbbbbbbbbbbbbbbbbbbbbbbbb"; + let dir = temp_dir("disfb"); + seed_enabled_providers(&dir, &["claude"]); + let index = fixture_index(Vec::new()).await; + let mut st = state(&dir, Some(index)); + st.opencode_session_by_id = Some(Arc::new(|id: &str| { + Ok(Some(freshell_sessions::resume_resolve::OpencodeByIdHit { + session_id: id.to_string(), + cwd: Some("/repo/delta".to_string()), + title: None, + last_activity_at: None, + })) + })); + let (status, body) = post(st, serde_json::json!({ "input": SES_ID }), true).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "ready"); + assert_eq!(body["matches"][0]["sessionId"], SES_ID); + let unsearched = body["unsearchedProviders"].as_array().unwrap(); + assert!( + unsearched.iter().any(|v| v == "opencode"), + "opencode must be listed unsearched, got {unsearched:?}" + ); + } + + #[tokio::test] + async fn degraded_response_schedules_a_refresh_and_retry_converges() { + // request_refresh() wiring proof END-TO-END (sessions-router.ts:293-305 + // parity): a degraded response fire-and-forgets a refresh, so once the + // provider recovers, a client Retry converges back to ready. Assert + // convergence by POLLING re-posts (each degraded response re-schedules + // a refresh) rather than sleeping once. + let dir = temp_dir("refresh"); + let broken = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let index = fixture_index_with_sources(vec![ + Arc::new(FixtureSource(vec![claude_fixture()])) as Arc, + Arc::new(FailingDirectSource::new(Arc::clone(&broken))) as Arc, + ]) + .await; + let st = state(&dir, Some(index)); + let (_, body) = post(st.clone(), serde_json::json!({ "input": CLAUDE_ID }), true).await; + assert_eq!(body["status"], "degraded", "first response: {body}"); + broken.store(false, std::sync::atomic::Ordering::SeqCst); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + let (_, body) = post(st.clone(), serde_json::json!({ "input": CLAUDE_ID }), true).await; + if body["status"] == "ready" && body["providerErrors"] == serde_json::json!([]) { + break; + } + assert!( + std::time::Instant::now() < deadline, + "must converge to ready within 2s; last body: {body}" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + } + + #[tokio::test] + async fn a_panicked_resolver_answers_500_never_a_fabricated_ready_empty() { + // RECORDED DEVIATION (module doc): Node has no defined behavior for a + // top-level resolver throw (unhandled rejection in the async Express 4 + // handler — no response at all); the explicit 500 is the honest port. + // The hardened contract forbids presenting an unsearchable state as a + // healthy "not found", so a JoinError must NEVER fabricate ready-empty. + let dir = temp_dir("panic"); + let index = fixture_index(vec![claude_fixture()]).await; + let mut st = state(&dir, Some(index)); + st.opencode_session_by_id = Some(Arc::new(|_id: &str| panic!("resolver crashed"))); + let (status, body) = post( + st, + serde_json::json!({ "input": "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa" }), + true, + ) + .await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(body, serde_json::json!({ "error": "Resolve failed" })); + } + + #[tokio::test] + async fn a_stalled_fallback_answers_degraded_at_the_fallback_deadline_never_hangs() { + // Node bounds every by-id worker with a hard 15 s timeout + // (`opencode-by-id-runner.ts` DEFAULT_TIMEOUT_MS); the rejection is + // caught per-fallback and surfaces as a providerError on a degraded + // 200. The Rust deadline is scoped the SAME way: it bounds the + // individual blocking fallback dispatch, never the in-memory + // resolver around it. The deadline is injected small so the test + // never waits 15 s; the stalled closure keeps sleeping well past + // it. Timeliness proof: without the deadline the sleeping closure + // returns Ok(None) (a clean miss) and the response would be `ready` + // — asserting `degraded` + the timeout providerError proves the + // deadline path answered, not the stalled fallback. + let dir = temp_dir("stall"); + let index = fixture_index(vec![claude_fixture()]).await; + let mut st = state(&dir, Some(index)); + st.resolve_deadline = std::time::Duration::from_millis(50); + st.opencode_session_by_id = Some(Arc::new(|_id: &str| { + std::thread::sleep(std::time::Duration::from_millis(400)); + Ok(None) + })); + let (status, body) = post( + st, + serde_json::json!({ "input": "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa" }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + body, + serde_json::json!({ + "status": "degraded", + "matches": [], + // The hint still rides along (Node's timed-out fallback path + // carries it too — resolution there finishes via `finish()`). + "hint": { "provider": "opencode", "source": "id-shape" }, + // Node's timeout wraps ONE provider's worker; its rejection + // blames THAT provider alone. Only the opencode fallback was + // dispatched here — claude/codex/amplifier finished their + // (index-only) search normally and must NOT be blamed. + "providerErrors": [ + { "provider": "opencode", "message": "resolve timed out after 50ms" } + ], + "unsearchedProviders": [], + "homeDir": "/home/tester" + }) + ); + } + + #[tokio::test] + async fn a_fallback_timeout_blames_only_the_attempted_provider() { + // Blame-attribution pin (Node parity): a stalled opencode by-id + // dispatch must blame opencode ALONE — not claude (wired, but + // shape-gated out of a `ses_` token so never consulted) and not + // codex/amplifier (enabled, but they have no exact-id fallback and + // their index search completed normally). + let dir = temp_dir("blame"); + let index = fixture_index(vec![claude_fixture()]).await; + let claude_invoked = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut st = state(&dir, Some(index)); + st.resolve_deadline = std::time::Duration::from_millis(50); + st.opencode_session_by_id = Some(Arc::new(|_id: &str| { + std::thread::sleep(std::time::Duration::from_millis(400)); + Ok(None) + })); + st.locate_claude_transcript = Some({ + let claude_invoked = Arc::clone(&claude_invoked); + Arc::new(move |_id: &str| { + claude_invoked.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(None) + }) + }); + let (status, body) = post( + st, + serde_json::json!({ "input": "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa" }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "degraded", "response: {body}"); + assert_eq!( + body["providerErrors"], + serde_json::json!([ + { "provider": "opencode", "message": "resolve timed out after 50ms" } + ]), + "only the attempted provider may be blamed: {body}" + ); + assert_eq!( + claude_invoked.load(std::sync::atomic::Ordering::SeqCst), + 0, + "the claude fallback is shape-gated out of a ses_ token" + ); + } + + #[tokio::test] + async fn an_opencode_timeout_never_skips_or_blames_a_healthy_claude_fallback() { + // Cross-provider containment (Node parity): Node bounds each by-id + // worker INDIVIDUALLY (`opencode-by-id-runner.ts` DEFAULT_TIMEOUT_MS), + // records the rejection for THAT provider, and CONTINUES through + // later candidates/providers (`resolve-session.ts:133-156` — the + // catch records per-provider and the entry loop keeps going). Input + // = an opencode `ses_` id followed by a claude UUID: the opencode + // dispatch stalls past its own deadline, but the claude lookup is + // healthy and MUST still run — returning its match — while ONLY + // opencode is blamed for the timeout. + let dir = temp_dir("cross-provider"); + let index = fixture_index(Vec::new()).await; + let mut st = state(&dir, Some(index)); + st.resolve_deadline = std::time::Duration::from_millis(50); + st.opencode_session_by_id = Some(Arc::new(|_id: &str| { + std::thread::sleep(std::time::Duration::from_millis(250)); + Ok(None) + })); + st.locate_claude_transcript = Some(Arc::new(|id: &str| { + Ok(Some( + freshell_sessions::resume_resolve::ClaudeTranscriptHit { + session_id: id.to_ascii_lowercase(), + cwd: Some("/home/tester/project".to_string()), + }, + )) + })); + let (status, body) = post( + st, + serde_json::json!({ + "input": format!("ses_aaaaaaaaaaaaaaaaaaaaaaaaaa {CLAUDE_ID}") + }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "degraded", "response: {body}"); + assert_eq!( + body["matches"], + serde_json::json!([{ + "provider": "claude", + "sessionId": CLAUDE_ID, + "cwd": "/home/tester/project", + "sessionType": "claude", + "matchKind": "exact" + }]), + "the healthy claude fallback match must be returned: {body}" + ); + assert_eq!( + body["providerErrors"], + serde_json::json!([ + { "provider": "opencode", "message": "resolve timed out after 50ms" } + ]), + "only the provider whose dispatch timed out may be blamed: {body}" + ); + } + + #[tokio::test] + async fn an_exact_index_hit_succeeds_while_the_permit_pool_is_saturated() { + // CORRECTED SCOPING (Node parity): admission + deadline bound ONLY + // the blocking provider-fallback dispatch. Node's cheap paths — + // input parsing, in-memory index matching — never wait on + // fallback-worker availability, so an exact index hit must answer + // `ready` immediately even when every permit is held by stalled + // fallbacks (simulated with a zero-permit semaphore). Fallback + // closures are wired with a counter to prove no dispatch was even + // attempted. + let dir = temp_dir("sat-exact"); + let index = fixture_index(vec![claude_fixture()]).await; + let counter = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut st = state(&dir, Some(index)); + st.resolve_deadline = std::time::Duration::from_millis(150); + st.resolve_permits = Arc::new(tokio::sync::Semaphore::new(0)); + st.opencode_session_by_id = Some({ + let counter = Arc::clone(&counter); + Arc::new(move |_id: &str| { + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(None) + }) + }); + st.locate_claude_transcript = Some({ + let counter = Arc::clone(&counter); + Arc::new(move |_id: &str| { + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(None) + }) + }); + let (status, body) = post(st, serde_json::json!({ "input": CLAUDE_ID }), true).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + body["status"], "ready", + "an exact index hit must bypass fallback admission: {body}" + ); + assert_eq!(body["matches"][0]["sessionId"], CLAUDE_ID); + assert_eq!(body["providerErrors"], serde_json::json!([])); + assert_eq!( + counter.load(std::sync::atomic::Ordering::SeqCst), + 0, + "no fallback dispatch may run for an exact index hit" + ); + } + + #[tokio::test] + async fn garbage_input_with_no_candidates_answers_ready_empty_under_full_saturation() { + // Garbage input yields no candidate tokens: Node answers ready-empty + // from pure parsing without ever touching a fallback worker. The + // same request must not queue for (or degrade on) fallback admission. + let dir = temp_dir("sat-garbage"); + let index = fixture_index(vec![claude_fixture()]).await; + let mut st = state(&dir, Some(index)); + st.resolve_deadline = std::time::Duration::from_millis(150); + st.resolve_permits = Arc::new(tokio::sync::Semaphore::new(0)); + let (status, body) = post( + st, + serde_json::json!({ "input": "??? total garbage input ???" }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + body, + serde_json::json!({ + "status": "ready", + "matches": [], + "hint": null, + "providerErrors": [], + "unsearchedProviders": [], + "homeDir": "/home/tester" + }) + ); + } + + #[tokio::test] + async fn a_warming_response_is_unaffected_by_permit_saturation() { + // A never-published index answers `warming` from a pure readiness + // check (Node's `isIndexReady()`); no fallback is consulted, so a + // saturated permit pool must not turn warming into a fabricated + // degraded timeout. + let dir = temp_dir("sat-warming"); + let mut st = state(&dir, None); + st.resolve_deadline = std::time::Duration::from_millis(150); + st.resolve_permits = Arc::new(tokio::sync::Semaphore::new(0)); + let (status, body) = post( + st, + serde_json::json!({ "input": format!("claude --resume {CLAUDE_ID}") }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + body, + serde_json::json!({ + "status": "warming", + "matches": [], + "hint": { "provider": "claude", "source": "command" }, + "providerErrors": [], + "unsearchedProviders": [], + "homeDir": "/home/tester" + }) + ); + } + + /// Blocks until `release` flips true (or a 5 s safety valve elapses so a + /// broken test can never hang the suite), then answers a clean miss. + fn stalled_until( + release: &std::sync::atomic::AtomicBool, + ) -> Result< + Option, + freshell_sessions::resume_resolve::ProviderFailure, + > { + let start = std::time::Instant::now(); + while !release.load(std::sync::atomic::Ordering::SeqCst) + && start.elapsed() < std::time::Duration::from_secs(5) + { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Ok(None) + } + + #[tokio::test] + async fn saturated_permits_degrade_the_next_request_without_spawning_another_resolver() { + // Admission-control proof, scoped to the FALLBACK phase: an + // ABANDONED fallback task keeps its permit until its underlying op + // returns, and a fallback dispatch that cannot get a permit FAILS + // FAST — synchronously, WITHOUT spawning an (N+1)th blocking + // fallback task and WITHOUT queueing toward its deadline. With + // permits = 1: request A's fallback stalls (holding the permit + // past its own deadline), request B — another FALLBACK-REQUIRING + // request — must answer the degraded concurrency-limit shape + // promptly while the injected fallback body was invoked exactly + // ONCE — the invocation count staying at 1 IS the proof that no + // second blocking fallback ran. Request C — an exact in-memory + // index hit — must answer `ready` while the permit is STILL held: + // only fallback-requiring requests degrade under saturation + // (Node's cheap paths never queue behind a worker). + let dir = temp_dir("permits"); + let index = fixture_index(vec![claude_fixture()]).await; + let counter = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let release = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let permits = Arc::new(tokio::sync::Semaphore::new(1)); + let mut st = state(&dir, Some(index)); + st.resolve_deadline = std::time::Duration::from_millis(100); + st.resolve_permits = Arc::clone(&permits); + st.opencode_session_by_id = Some({ + let counter = Arc::clone(&counter); + let release = Arc::clone(&release); + Arc::new(move |_id: &str| { + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + stalled_until(&release) + }) + }); + + let body = serde_json::json!({ "input": "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa" }); + let (status_a, body_a) = post(st.clone(), body.clone(), true).await; + assert_eq!(status_a, StatusCode::OK); + assert_eq!(body_a["status"], "degraded", "request A: {body_a}"); + assert_eq!( + counter.load(std::sync::atomic::Ordering::SeqCst), + 1, + "request A's fallback must have been invoked once" + ); + + // The abandoned task still holds the ONLY permit. Request B must + // fail admission FAST — synchronously, before any fallback task + // exists — never queue toward its deadline. Its deadline is set + // GENEROUS (10 s) precisely so the strict elapsed bound below + // FAILS an implementation that queues for the deadline (reviewer + // finding, iteration 5: the previous 5 s allowance against a + // 100 ms deadline let a full-deadline queuer pass). + let mut st_b = st.clone(); + st_b.resolve_deadline = std::time::Duration::from_secs(10); + let started = std::time::Instant::now(); + let (status_b, body_b) = post(st_b, body, true).await; + let waited = started.elapsed(); + assert_eq!(status_b, StatusCode::OK); + assert_eq!(body_b["status"], "degraded", "request B: {body_b}"); + assert_eq!( + body_b["providerErrors"], + serde_json::json!([ + { "provider": "opencode", "message": "resolve concurrency limit reached" } + ]), + "permit starvation must answer the degraded provider-error shape, \ + blaming ONLY the provider whose fallback was attempted: {body_b}" + ); + assert!( + waited < std::time::Duration::from_secs(2), + "request B must fail admission fast, never queue toward its \ + 10 s deadline behind the stalled permit (took {waited:?})" + ); + assert_eq!( + counter.load(std::sync::atomic::Ordering::SeqCst), + 1, + "request B must NOT have spawned an (N+1)th blocking fallback" + ); + + // Request C — an exact in-memory index hit — must succeed NORMALLY + // while the stalled fallback still holds the only permit: cheap + // index-only resolution never queues for fallback admission. + let (status_c, body_c) = + post(st.clone(), serde_json::json!({ "input": CLAUDE_ID }), true).await; + assert_eq!(status_c, StatusCode::OK); + assert_eq!(body_c["status"], "ready", "request C: {body_c}"); + assert_eq!(body_c["matches"][0]["sessionId"], CLAUDE_ID); + assert_eq!( + counter.load(std::sync::atomic::Ordering::SeqCst), + 1, + "request C must not have dispatched any fallback" + ); + + // Cleanup: release the stalled op; the abandoned task finishes and + // returns its permit — proving the permit's lifetime tracked the + // UNDERLYING op, not the abandoned request. + release.store(true, std::sync::atomic::Ordering::SeqCst); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while permits.available_permits() == 0 { + assert!( + std::time::Instant::now() < deadline, + "the abandoned resolver must return its permit once its op completes" + ); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + } + + #[tokio::test] + async fn permit_starvation_fails_fast_without_pinning_an_outer_worker_for_the_deadline() { + // Reviewer finding (iteration 5): admission used to run INSIDE the + // outer resolver `spawn_blocking` via `block_on(acquire_owned())`, + // so under full permit saturation each fallback dispatch pinned + // that unbounded outer blocking worker for its ENTIRE deadline — + // enough crafted requests could exhaust Tokio's blocking pool, the + // exact failure the semaphore exists to prevent. Corrected: + // admission is a synchronous `try_acquire_owned()` BEFORE any + // fallback task exists, so a starved dispatch degrades + // immediately. The deadline here is GENEROUS (5 s) precisely so an + // implementation that queues for the deadline visibly FAILS the + // strict elapsed bound below. + let dir = temp_dir("starve-fast"); + let index = fixture_index(Vec::new()).await; + let counter = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut st = state(&dir, Some(index)); + st.resolve_deadline = std::time::Duration::from_secs(5); + st.resolve_permits = Arc::new(tokio::sync::Semaphore::new(0)); + st.opencode_session_by_id = Some({ + let counter = Arc::clone(&counter); + Arc::new(move |_id: &str| { + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(None) + }) + }); + let started = std::time::Instant::now(); + let (status, body) = post( + st, + serde_json::json!({ "input": "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa" }), + true, + ) + .await; + let waited = started.elapsed(); + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "degraded", "response: {body}"); + assert_eq!( + body["providerErrors"], + serde_json::json!([ + { "provider": "opencode", "message": "resolve concurrency limit reached" } + ]), + "a starved dispatch blames ONLY the provider it would have run: {body}" + ); + assert!( + waited < std::time::Duration::from_secs(2), + "a permit-starved fallback dispatch must fail fast, never occupy \ + an outer blocking worker while queueing toward its 5 s deadline \ + (took {waited:?})" + ); + assert_eq!( + counter.load(std::sync::atomic::Ordering::SeqCst), + 0, + "the fallback body must never run without a permit" + ); + } + + #[tokio::test] + async fn two_exact_ses_tokens_invoke_the_opencode_fallback_twice_when_not_abandoned() { + // CONTROL for the per-dispatch containment test below: this + // two-token input drives TWO opencode fallback invocations (budget + // is 2 per provider) when nothing stalls — so the stalled variant's + // count of TWO proves the budget/input shape allows both calls and + // continuation after a timeout is real, not an artifact. + let dir = temp_dir("cancel-control"); + let index = fixture_index(Vec::new()).await; + let counter = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut st = state(&dir, Some(index)); + st.opencode_session_by_id = Some({ + let counter = Arc::clone(&counter); + Arc::new(move |_id: &str| { + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(None) + }) + }); + let (status, body) = post( + st, + serde_json::json!({ + "input": "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa ses_bbbbbbbbbbbbbbbbbbbbbbbbbb" + }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "ready", "control response: {body}"); + assert_eq!( + counter.load(std::sync::atomic::Ordering::SeqCst), + 2, + "the two-token input must consume both opencode fallback calls" + ); + } + + #[tokio::test] + async fn a_timed_out_fallback_never_skips_the_next_candidates_dispatch() { + // Per-dispatch containment (Node parity): after the first + // candidate's dispatch times out (its blocking task abandoned, + // holding a permit), the SECOND candidate's fallback must still be + // dispatched with its OWN deadline — Node records the rejection for + // that provider and continues through later candidates/providers + // (`resolve-session.ts:133-156`). ERRATA: ffa4aac1a's shared cancel + // flag skipped it (and fabricated timeouts for providers never + // dispatched). Abandonment stays bounded WITHOUT the flag: the + // per-provider budget (2) caps the dispatches one request can + // produce. Same two-token input as the control above (which proves + // exactly TWO invocations happen when nothing stalls), so a count + // of 2 here proves continuation — while first-error-wins dedupe + // keeps ONE opencode error on the wire. + let dir = temp_dir("per-dispatch"); + let index = fixture_index(Vec::new()).await; + let invoked = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut st = state(&dir, Some(index)); + st.resolve_deadline = std::time::Duration::from_millis(50); + st.opencode_session_by_id = Some({ + let invoked = Arc::clone(&invoked); + Arc::new(move |_id: &str| { + invoked.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + // Stall well past the 50 ms deadline, then answer a miss. + std::thread::sleep(std::time::Duration::from_millis(250)); + Ok(None) + }) + }); + let (status, body) = post( + st, + serde_json::json!({ + "input": "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa ses_bbbbbbbbbbbbbbbbbbbbbbbbbb" + }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "degraded", "response: {body}"); + assert_eq!( + body["providerErrors"], + serde_json::json!([ + { "provider": "opencode", "message": "resolve timed out after 50ms" } + ]), + "first error per provider wins — two timeouts, ONE entry: {body}" + ); + assert_eq!( + invoked.load(std::sync::atomic::Ordering::SeqCst), + 2, + "the second candidate's dispatch must still run with its own deadline" + ); + } +} diff --git a/crates/freshell-server/src/session_directory.rs b/crates/freshell-server/src/session_directory.rs index e8706fabd..240aae318 100644 --- a/crates/freshell-server/src/session_directory.rs +++ b/crates/freshell-server/src/session_directory.rs @@ -433,11 +433,60 @@ async fn session_directory( /// launch that set `FRESHELL_HOME` to a temp dir (while leaving `HOME` as /// the real user home) made claude/codex sessions invisible -- they were /// looked up under `/.claude` / `.codex`, which don't exist. +/// +/// Windows/Tauri parity: Node derives these via `os.homedir()` (libuv +/// `uv_os_homedir`), whose PLATFORM semantics matter -- production Tauri +/// deliberately inherits the desktop environment WITHOUT setting `HOME` +/// (`freshell-tauri/src/lib.rs`, `home: None`). On Windows, `os.homedir()` +/// reads `USERPROFILE` (HOME is NEVER consulted); on POSIX it reads `HOME` +/// when set and non-empty, else the effective user's passwd-entry home +/// (`getpwuid_r`) -- so the POSIX result is Some even with HOME unset, and +/// `main.rs` still constructs a real session index (no permanent `warming`). +/// Rust's `std::env::home_dir()` (un-deprecated since 1.87, MSRV here is +/// 1.96) implements exactly these platform rules -- USERPROFILE-else-profile +/// API on Windows, non-empty-HOME-else-`getpwuid_r` on unix, never an empty +/// path -- so this delegates to it. An earlier interim version approximated +/// this as HOME-then-USERPROFILE on ALL platforms; that both consulted +/// USERPROFILE on POSIX (Node never does) and preferred HOME on Windows +/// (Node never reads it). pub(crate) fn provider_home() -> Option { - std::env::var("HOME") - .ok() - .filter(|value| !value.is_empty()) - .map(PathBuf::from) + std::env::home_dir() +} + +/// Serializes tests (crate-wide) that mutate the process-global +/// `HOME`/`USERPROFILE`/`CLAUDE_HOME`/`FRESHELL_HOME` env vars: cargo runs +/// tests in parallel THREADS within one process, so two tests racing to +/// mutate the SAME vars would otherwise flake (one test's assertion +/// observing the OTHER test's in-flight env state). Shared `pub(crate)` so +/// `main.rs`'s resolve-wiring tests serialize with this module's +/// `provider_home()` tests. +#[cfg(test)] +pub(crate) static HOME_ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Test-only oracle for the effective user's passwd-entry home directory +/// (`getpwuid_r`) — the Node `os.homedir()` POSIX fallback when `HOME` is +/// unset or empty. `pub(crate)` so `main.rs`'s resolve-wiring tests assert +/// against the SAME fallback value [`provider_home`] must produce. +#[cfg(all(test, unix))] +pub(crate) fn passwd_entry_home() -> PathBuf { + use std::os::unix::ffi::OsStrExt; + let uid = unsafe { libc::geteuid() }; + let mut pwd: libc::passwd = unsafe { std::mem::zeroed() }; + let mut buf = vec![0u8; 16 * 1024]; + let mut result: *mut libc::passwd = std::ptr::null_mut(); + let rc = unsafe { + libc::getpwuid_r( + uid, + &mut pwd, + buf.as_mut_ptr().cast::(), + buf.len(), + &mut result, + ) + }; + assert_eq!(rc, 0, "getpwuid_r must succeed for the effective uid"); + assert!(!result.is_null(), "effective uid must have a passwd entry"); + let dir = unsafe { std::ffi::CStr::from_ptr(pwd.pw_dir) }; + PathBuf::from(std::ffi::OsStr::from_bytes(dir.to_bytes())) } /// `getClaudeHome()` (`server/claude-home.ts:4-7`): `CLAUDE_HOME` env else @@ -1277,9 +1326,12 @@ mod tests { // `PROVIDER_HOME_ENV_LOCK` because cargo runs tests in parallel THREADS // within one process: two tests racing to mutate the SAME process-global // `HOME`/`FRESHELL_HOME` vars would otherwise flake (one test's assertion - // observing the OTHER test's in-flight env state). - static PROVIDER_HOME_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + // observing the OTHER test's in-flight env state). The lock itself is the + // crate-wide `HOME_ENV_TEST_LOCK` (module level, above) so `main.rs`'s + // resolve-wiring tests serialize with these. + use super::HOME_ENV_TEST_LOCK as PROVIDER_HOME_ENV_LOCK; + #[cfg(unix)] #[test] fn provider_home_ignores_freshell_home_uses_real_home() { let _guard = PROVIDER_HOME_ENV_LOCK.lock().unwrap(); @@ -1305,16 +1357,34 @@ mod tests { } } + // Node `os.homedir()` platform semantics (libuv `uv_os_homedir`): on + // POSIX an unset (or empty) HOME falls back to the EFFECTIVE USER'S + // passwd-entry home (`getpwuid_r`) — USERPROFILE is NEVER consulted, and + // the result is still Some, so `main.rs` still constructs a real session + // index instead of `session_index: None` (permanent `warming`). On + // Windows only USERPROFILE is read (HOME is never consulted). + #[cfg(unix)] #[test] - fn provider_home_none_when_home_unset() { + fn provider_home_unix_uses_passwd_entry_when_home_and_userprofile_unset() { let _guard = PROVIDER_HOME_ENV_LOCK.lock().unwrap(); let saved_freshell_home = std::env::var("FRESHELL_HOME").ok(); let saved_home = std::env::var("HOME").ok(); + let saved_userprofile = std::env::var("USERPROFILE").ok(); std::env::set_var("FRESHELL_HOME", "/tmp/freshell-isolated-config-root-2"); std::env::remove_var("HOME"); + std::env::remove_var("USERPROFILE"); - assert_eq!(provider_home(), None); + let resolved = provider_home(); + assert!( + resolved.is_some(), + "POSIX must still resolve a home with HOME unset (passwd-entry fallback)" + ); + assert_eq!( + resolved, + Some(super::passwd_entry_home()), + "an unset HOME must fall back to the passwd-entry home (Node os.homedir() POSIX semantics)" + ); match saved_freshell_home { Some(v) => std::env::set_var("FRESHELL_HOME", v), @@ -1324,6 +1394,127 @@ mod tests { Some(v) => std::env::set_var("HOME", v), None => std::env::remove_var("HOME"), } + match saved_userprofile { + Some(v) => std::env::set_var("USERPROFILE", v), + None => std::env::remove_var("USERPROFILE"), + } + } + + #[cfg(unix)] + #[test] + fn provider_home_unix_ignores_userprofile_when_home_unset() { + let _guard = PROVIDER_HOME_ENV_LOCK.lock().unwrap(); + let saved_home = std::env::var("HOME").ok(); + let saved_userprofile = std::env::var("USERPROFILE").ok(); + + std::env::remove_var("HOME"); + std::env::set_var("USERPROFILE", "/Users/win-fixture"); + + let resolved = provider_home(); + assert_ne!( + resolved, + Some(PathBuf::from("/Users/win-fixture")), + "POSIX must NEVER consult USERPROFILE (Node os.homedir() reads it on Windows only)" + ); + assert_eq!( + resolved, + Some(super::passwd_entry_home()), + "with HOME unset, POSIX must resolve the passwd-entry home" + ); + + match saved_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + match saved_userprofile { + Some(v) => std::env::set_var("USERPROFILE", v), + None => std::env::remove_var("USERPROFILE"), + } + } + + #[cfg(unix)] + #[test] + fn provider_home_unix_treats_empty_home_as_unset_using_passwd_entry() { + // A lingering `HOME=""` (empty, not unset) must behave exactly like + // an unset HOME — Node's `os.homedir()` never returns an empty + // string; on POSIX it falls back to the passwd-entry home, never + // USERPROFILE. + let _guard = PROVIDER_HOME_ENV_LOCK.lock().unwrap(); + let saved_home = std::env::var("HOME").ok(); + let saved_userprofile = std::env::var("USERPROFILE").ok(); + + std::env::set_var("HOME", ""); + std::env::set_var("USERPROFILE", "/Users/win-fixture-empty"); + + assert_eq!( + provider_home(), + Some(super::passwd_entry_home()), + "an EMPTY HOME must behave like unset HOME: passwd-entry fallback, never USERPROFILE" + ); + + match saved_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + match saved_userprofile { + Some(v) => std::env::set_var("USERPROFILE", v), + None => std::env::remove_var("USERPROFILE"), + } + } + + #[cfg(unix)] + #[test] + fn provider_home_unix_prefers_home_and_never_consults_userprofile() { + let _guard = PROVIDER_HOME_ENV_LOCK.lock().unwrap(); + let saved_home = std::env::var("HOME").ok(); + let saved_userprofile = std::env::var("USERPROFILE").ok(); + + std::env::set_var("HOME", "/home/real-user-fixture"); + std::env::set_var("USERPROFILE", "/Users/win-fixture"); + + assert_eq!( + provider_home(), + Some(PathBuf::from("/home/real-user-fixture")), + "a set, non-empty HOME must win on POSIX (USERPROFILE is never consulted)" + ); + + match saved_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + match saved_userprofile { + Some(v) => std::env::set_var("USERPROFILE", v), + None => std::env::remove_var("USERPROFILE"), + } + } + + // Native Windows/Tauri parity: `os.homedir()` reads USERPROFILE on + // Windows and NEVER consults HOME — a process with both variables set + // must index against USERPROFILE, not HOME. + #[cfg(windows)] + #[test] + fn provider_home_windows_uses_userprofile_never_home() { + let _guard = PROVIDER_HOME_ENV_LOCK.lock().unwrap(); + let saved_home = std::env::var("HOME").ok(); + let saved_userprofile = std::env::var("USERPROFILE").ok(); + + std::env::set_var("HOME", "C:\\never-consulted"); + std::env::set_var("USERPROFILE", "C:\\Users\\win-fixture"); + + assert_eq!( + provider_home(), + Some(PathBuf::from("C:\\Users\\win-fixture")), + "Windows must read USERPROFILE and never consult HOME (Node os.homedir() parity)" + ); + + match saved_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + match saved_userprofile { + Some(v) => std::env::set_var("USERPROFILE", v), + None => std::env::remove_var("USERPROFILE"), + } } #[test] diff --git a/crates/freshell-server/src/session_metadata.rs b/crates/freshell-server/src/session_metadata.rs index 1cc1e6c24..d9da0d7f7 100644 --- a/crates/freshell-server/src/session_metadata.rs +++ b/crates/freshell-server/src/session_metadata.rs @@ -19,7 +19,7 @@ //! { "version": 1, "sessions": { "": { "": { "sessionType": "...", "sessionTypeSource": "explicit" } } } } //! ``` //! -//! `get_all()`/`get()` are provided for future read-surfaces (test-only until the session-indexer read path is ported) (the sidebar directory listing +//! `get_all()` is a production read (resolve endpoint's sessionType overlay, SYNC-06); `get()` remains test-gated until a production caller lands (the sidebar directory listing //! embeds `sessionType` inline via `codingCliIndexer` server-side in the reference; this //! port's `crates/freshell-sessions` directory index is a SEPARATE crate this module does //! not reach into — wiring metadata into the directory listing is out of THIS module's @@ -27,7 +27,6 @@ //! `GET /api/session-metadata` route either (confirmed by exhaustive grep of //! `server/sessions-router.ts` and `server/index.ts` — only the `POST` exists). -#[cfg(test)] use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -133,8 +132,9 @@ impl SessionMetadataStore { /// `getAll()` (`session-metadata-store.ts:113-122`): flattened `provider:sessionId` → /// entry map. /// - /// Test-only today — see `get` above. - #[cfg(test)] + /// Production read (SYNC-06): the resolve endpoint overlays match + /// `sessionType` from this store, mirroring Node's + /// `session-indexer.ts:1159-1161` overlay. Keyed `"{provider}:{session_id}"`. pub async fn get_all(&self) -> HashMap { let mut guard = self.inner.lock().await; let data = Self::load_locked(&mut guard, &self.path).await; diff --git a/crates/freshell-server/src/settings.rs b/crates/freshell-server/src/settings.rs index 3dff4c8da..385f80d34 100644 --- a/crates/freshell-server/src/settings.rs +++ b/crates/freshell-server/src/settings.rs @@ -36,10 +36,14 @@ pub fn default_server_settings() -> ServerSettings { title_prompt: None, }, coding_cli: SettingsCodingCli { + // Node's `DEFAULT_ENABLED_CLI_PROVIDERS` + // (`shared/coding-cli-defaults.ts:3`) — four providers, + // including `amplifier`. enabled_providers: vec![ "claude".to_string(), "codex".to_string(), "opencode".to_string(), + "amplifier".to_string(), ], mcp_server: true, providers: json!({ "claude": { "permissionMode": "default" }, "codex": {} }), diff --git a/crates/freshell-server/src/settings_store.rs b/crates/freshell-server/src/settings_store.rs index 94f019497..7eba2748a 100644 --- a/crates/freshell-server/src/settings_store.rs +++ b/crates/freshell-server/src/settings_store.rs @@ -181,7 +181,12 @@ impl SettingsStore { let mut migrated_legacy = false; { const LEGACY: [&str; 2] = ["claude", "codex"]; - const DEFAULTS: [&str; 3] = ["claude", "codex", "opencode"]; + // Node's four-provider `DEFAULT_ENABLED_CLI_PROVIDERS` + // (`shared/coding-cli-defaults.ts:3`) — a persisted legacy list + // must gain `amplifier` here exactly as it does on Node + // (`server/settings-migrate.ts:35-46`), or the provider stays + // unsearched and its indexed sessions filtered out. + const DEFAULTS: [&str; 4] = ["claude", "codex", "opencode", "amplifier"]; let enabled_norm = normalize_trimmed_string_list(&settings.coding_cli.enabled_providers); let legacy_match = enabled_norm.len() == LEGACY.len() @@ -280,6 +285,14 @@ impl SettingsStore { self.inner.read().await.clone() } + /// The enabled coding-CLI provider names (`settings.codingCli.enabledProviders`) + /// — the resolve route's unsearched-provider computation and snapshot + /// provider gate read this (`resolve.rs`). Async because the settings + /// tree is behind a tokio RwLock (same as `get()`). + pub async fn coding_cli_enabled_providers(&self) -> Vec { + self.inner.read().await.coding_cli.enabled_providers.clone() + } + /// GAP1 (CFG-03 checklist follow-up): the boot-time `config.fallback` /// notice, if the primary configuration needed to fall back (corrupt /// primary -> backup restore or defaults) at boot. `None` for a healthy @@ -1987,6 +2000,80 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + /// Task 5 (resolve parity): `coding_cli_enabled_providers()` reads the + /// persisted `settings.codingCli.enabledProviders` list as-is (the resolve + /// route's unsearched-provider computation consumes it, + /// `server/sessions-router.ts:255-316`). + #[tokio::test] + async fn coding_cli_enabled_providers_returns_persisted_list() { + let dir = std::env::temp_dir().join(format!("frs-settings-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + std::fs::write( + dir.join(".freshell").join("config.json"), + r#"{"version":1,"settings":{"codingCli":{"enabledProviders":["claude","opencode"]}}}"#, + ) + .unwrap(); + let store = store_at(&dir); + assert_eq!( + store.coding_cli_enabled_providers().await, + vec!["claude".to_string(), "opencode".to_string()] + ); + std::fs::remove_dir_all(&dir).ok(); + } + + /// Task 5 (resolve parity): a FRESH home's default enabled-provider list + /// must be Node's authoritative four-provider + /// `DEFAULT_ENABLED_CLI_PROVIDERS` (`shared/coding-cli-defaults.ts:3`) — + /// including `amplifier`. Pins the live parity defect where the Rust + /// default omitted `amplifier`, leaving that provider unsearched and its + /// indexed sessions filtered out. + #[tokio::test] + async fn coding_cli_enabled_providers_defaults_match_node_default_set() { + let dir = std::env::temp_dir().join(format!("frs-settings-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + let store = store_at(&dir); // no config file — pure defaults + assert_eq!( + store.coding_cli_enabled_providers().await, + vec![ + "claude".to_string(), + "codex".to_string(), + "opencode".to_string(), + "amplifier".to_string(), + ] + ); + std::fs::remove_dir_all(&dir).ok(); + } + + /// Task 5 (resolve parity): the legacy-default migration mirrors Node's + /// FOUR-provider `DEFAULT_ENABLED_CLI_PROVIDERS` + /// (`server/settings-migrate.ts:35-46`) — a persisted legacy + /// `["claude","codex"]` gains `amplifier` under exactly the same + /// availability gating it already gains `opencode` (only when + /// discovered). + #[tokio::test] + async fn legacy_default_enabled_providers_migration_includes_amplifier() { + let dir = std::env::temp_dir().join(format!("frs-settings-{}", uuid_like())); + std::fs::create_dir_all(dir.join(".freshell")).unwrap(); + std::fs::write( + dir.join(".freshell").join("config.json"), + r#"{"version":1,"settings":{"codingCli":{"enabledProviders":["claude","codex"],"providers":{},"mcpServer":true}}}"#, + ) + .unwrap(); + let discovered: Vec = + ["claude", "codex", "gemini", "kimi", "opencode", "amplifier"] + .iter() + .map(|s| s.to_string()) + .collect(); + let store = SettingsStore::load(Some(&dir), discovered.clone()); + let s = store.get().await; + assert_eq!( + s.coding_cli.enabled_providers, + vec!["claude", "codex", "opencode", "amplifier"] + ); + assert_eq!(s.coding_cli.known_providers, Some(discovered)); + std::fs::remove_dir_all(&dir).ok(); + } + #[tokio::test] async fn patch_write_through_reaches_get_and_config_json_and_restart() { let dir = std::env::temp_dir().join(format!("frs-settings-{}", uuid_like())); diff --git a/crates/freshell-sessions/Cargo.toml b/crates/freshell-sessions/Cargo.toml index 7cde7790f..a9d10cae0 100644 --- a/crates/freshell-sessions/Cargo.toml +++ b/crates/freshell-sessions/Cargo.toml @@ -22,6 +22,11 @@ notify = "6" # compiles the SQLite amalgamation so the parser is not coupled to the host's # libsqlite3 version (deterministic across dev/CI/Windows). rusqlite = { version = "0.31", features = ["bundled"] } +# SYNC-06 resume-resolve parity: the `shared/resume-input-parser.ts` port's +# candidate-extraction regexes + hint tables (`resume_input.rs`). `(?-u:\b)` +# keeps JS's ASCII \b word-boundary semantics. Same major already a direct +# dep of freshell-server (logging.rs redaction scrub). +regex = "1" # Batch B (`directory_index`): the refreshable session-directory cache. `sync` # for the refresh-serializing async Mutex, `rt`/`rt-multi-thread` for # `spawn_blocking` (the full-sweep scan runs off the async executor thread), diff --git a/crates/freshell-sessions/src/amplifier.rs b/crates/freshell-sessions/src/amplifier.rs index 7353b09a2..d64a10c8f 100644 --- a/crates/freshell-sessions/src/amplifier.rs +++ b/crates/freshell-sessions/src/amplifier.rs @@ -85,15 +85,56 @@ impl AmplifierSource { impl SessionSource for AmplifierSource { fn discover(&self) -> Vec { - let projects_dir = self.amplifier_home.join("projects"); - let mut stats = Vec::new(); - walk_metadata_files(&projects_dir, &projects_dir, &mut stats); - stats + discover_amplifier_metadata(&self.amplifier_home).unwrap_or_default() } fn parse(&self, path: &Path) -> Option { parse_amplifier_file(path) } + + fn provider_name(&self) -> Option<&'static str> { + Some("amplifier") + } + + /// Root-listing failure propagation: an unlistable + /// `/projects` (EACCES/EIO — not a merely-absent one) is + /// a scan failure, never a silent empty listing. SINGLE-PASS: the same + /// `read_dir` handle that fails here is the one the traversal consumes + /// (no disposable preflight, no TOCTOU window), and root iterator-entry + /// errors propagate too. Nested-directory errors stay tolerant. + fn discover_checked(&self) -> Result, std::io::Error> { + discover_amplifier_metadata(&self.amplifier_home) + } +} + +/// Stat every qualifying `metadata.json` under `/projects`. +/// +/// SINGLE-PASS root traversal: the ONE `read_dir` opened by +/// [`crate::directory_index::open_root_dir`] is the one iterated, and +/// per-entry iterator errors propagate (`?`) — a root that fails to open OR +/// fails mid-iteration is an `Err` (recorded as a scan failure by the sweep), +/// never a silent `Ok(empty)`. A MISSING root is a genuine empty. NESTED +/// directories stay tolerant via [`walk_metadata_files`]. +fn discover_amplifier_metadata(amplifier_home: &Path) -> Result, std::io::Error> { + let projects_dir = amplifier_home.join("projects"); + let Some(entries) = crate::directory_index::open_root_dir(&projects_dir)? else { + return Ok(Vec::new()); + }; + let mut paths: Vec = Vec::new(); + for entry in entries { + paths.push(entry?.path()); + } + paths.sort(); // determinism (readdir order is filesystem-dependent) + let mut stats = Vec::new(); + for path in paths { + if path.is_dir() { + walk_metadata_files(&projects_dir, &path, &mut stats); + } + // A top-level `metadata.json` directly in `projects/` can never have + // a `sessions` path segment in its relative path, so it is excluded + // by construction — same as the recursive walk's filter. + } + Ok(stats) } /// Recursively find every `metadata.json` under `dir` (never @@ -218,6 +259,10 @@ pub fn parse_amplifier_metadata(content: &str) -> ParsedSessionMeta { .map(str::to_string), created_at, last_activity_at, + // Node: `titleSource: name ? 'provider-generated' : undefined` + // (`providers/amplifier.ts:93`) — the amplifier `name` IS + // provider-generated whenever present. + title_provider_generated: title.is_some(), title, summary, is_subagent: Some(is_subagent), @@ -303,6 +348,7 @@ fn indexed_from_meta( // is not currently ported for any provider). project_path: meta.cwd.clone().unwrap_or_else(|| "unknown".to_string()), title: meta.title.clone(), + title_provider_generated: meta.title_provider_generated, summary: meta.summary.clone(), first_user_message: meta.first_user_message.clone(), last_activity_at, diff --git a/crates/freshell-sessions/src/directory_index.rs b/crates/freshell-sessions/src/directory_index.rs index 825e68736..304a9801d 100644 --- a/crates/freshell-sessions/src/directory_index.rs +++ b/crates/freshell-sessions/src/directory_index.rs @@ -33,7 +33,7 @@ //! `IndexedSession` / `SessionSource` / `ClaudeSource` / `SessionIndex` existed, //! so this test module failed to compile. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex as StdMutex}; use std::time::{Duration, Instant}; @@ -60,6 +60,15 @@ pub struct IndexedSession { pub provider: String, pub project_path: String, pub title: Option, + /// Node's cached parse-layer `titleSource` (`ParsedSessionTitleSource`, + /// `server/coding-cli/types.ts:100` — sole variant `'provider-generated'`, + /// so a bool is the faithful shape; carried per cache entry at + /// `session-indexer.ts:418` and fed into `applyOverride` at `:1186`): + /// `true` iff [`Self::title`] was generated by the provider itself. + /// `#[serde(default)]` so a persisted parse-cache written before this + /// field existed still deserializes (as `false`, the pre-existing view). + #[serde(default)] + pub title_provider_generated: bool, pub summary: Option, pub first_user_message: Option, pub last_activity_at: i64, @@ -149,6 +158,64 @@ pub trait SessionSource: Send + Sync { fn direct_list(&self) -> Result, String> { Ok(Vec::new()) } + + /// Per-sweep health probe for a direct-listed source, consulted on EVERY + /// sweep — including when [`Self::direct_change_token`] is UNCHANGED. + /// Node parity: `refreshDirectProvider()` runs on every full scan and + /// records/clears `scanFailures` per attempt (`session-indexer.ts:1457`, + /// `:1070-1082`), so its failure evidence is never older than one scan. + /// The mtime change-token gates only the expensive re-QUERY (the session + /// DATA cache) — it must never gate the HEALTH signal, because a database + /// can become locked or unreadable (e.g. `chmod`) without either mtime + /// moving. `Err` records a scan failure (cached sessions preserved); `Ok` + /// clears it. Default `Ok` for file-based sources (never called). + fn direct_health_check(&self) -> Result<(), String> { + Ok(()) + } + + /// Provider identity for scan-failure reporting (`getScanFailures` parity). + /// `None` (default) = this source does not participate in failure tracking. + fn provider_name(&self) -> Option<&'static str> { + None + } + + /// Discovery with ROOT-listing failure propagation (Node parity: a + /// throwing `listSessionFiles()` is RECORDED in scanFailures — + /// `session-indexer.ts:1250-1262` — never silently treated as empty). + /// Default wraps the infallible `discover()` for test sources. + fn discover_checked(&self) -> Result, std::io::Error> { + Ok(self.discover()) + } +} + +/// Open a provider ROOT directory for the SINGLE-PASS checked traversals +/// below: a MISSING root (`NotFound`/`NotADirectory`) is a genuine empty +/// (an absent provider — matches Node's ENOENT tolerance, `Ok(None)`), while +/// any other open failure (EACCES/EIO/...) PROPAGATES so the sweep records +/// it as a scan failure instead of silently serving an empty listing. +/// +/// This replaces the earlier disposable `read_dir` PREFLIGHT +/// (`ensure_root_listable`) that was followed by a second, error-swallowing +/// `read_dir` for the actual traversal — a TOCTOU window where a failure +/// between the two calls (or a per-entry iterator error during the second) +/// came back as `Ok(empty)`, CLEARING the provider's scan failure and +/// pruning its cached sessions from a sweep that never actually listed +/// anything. The traversals now consume THIS handle directly (one +/// `read_dir`, no window) and propagate per-entry iterator errors with `?`. +/// `pub(crate)` so `amplifier.rs`'s source can reuse it. +pub(crate) fn open_root_dir(root: &Path) -> Result, std::io::Error> { + match std::fs::read_dir(root) { + Ok(entries) => Ok(Some(entries)), + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) => + { + Ok(None) + } + Err(e) => Err(e), + } } /// Claude source: walks `/projects/*/…*.jsonl` (top-level = @@ -181,6 +248,20 @@ impl ClaudeSource { impl SessionSource for ClaudeSource { fn discover(&self) -> Vec { + discover_claude_home(&self.claude_home).unwrap_or_default() + } + + fn provider_name(&self) -> Option<&'static str> { + Some("claude") + } + + /// Root-listing failure propagation: an unlistable `/projects` + /// (EACCES/EIO — not a merely-absent one) is a scan failure, never a + /// silent empty listing. SINGLE-PASS: the same `read_dir` handle that + /// fails here is the one the traversal consumes (no disposable preflight, + /// no TOCTOU window), and root iterator-entry errors propagate too. + /// Per-project/nested errors stay tolerant. + fn discover_checked(&self) -> Result, std::io::Error> { discover_claude_home(&self.claude_home) } @@ -201,17 +282,23 @@ impl SessionSource for ClaudeSource { /// Stat (not parse) every `/projects/*/…*.jsonl` file, in the /// same discovery order `scan_claude_home` used to walk them (sorted /// directory entries — determinism; readdir order is filesystem-dependent). -fn discover_claude_home(claude_home: &Path) -> Vec { +/// +/// SINGLE-PASS root traversal: the ONE `read_dir` opened by [`open_root_dir`] +/// is the one iterated, and per-entry iterator errors propagate (`?`) — a +/// root that fails to open OR fails mid-iteration is an `Err` (recorded as a +/// scan failure by the sweep), never a silent `Ok(empty)`. A MISSING root is +/// a genuine empty. Per-project/nested errors stay tolerant (Node parity). +fn discover_claude_home(claude_home: &Path) -> Result, std::io::Error> { let projects_dir = claude_home.join("projects"); - let Ok(project_entries) = std::fs::read_dir(&projects_dir) else { - return Vec::new(); + let Some(project_entries) = open_root_dir(&projects_dir)? else { + return Ok(Vec::new()); }; let mut stats = Vec::new(); - let mut project_dirs: Vec = project_entries - .filter_map(|e| e.ok()) - .map(|e| e.path()) - .collect(); + let mut project_dirs: Vec = Vec::new(); + for entry in project_entries { + project_dirs.push(entry?.path()); + } project_dirs.sort(); // determinism (readdir order is filesystem-dependent) for project_dir in project_dirs { @@ -249,7 +336,7 @@ fn discover_claude_home(claude_home: &Path) -> Vec { } } } - stats + Ok(stats) } /// `fs::metadata` a single file into a [`FileStat`]. `None` on any stat @@ -313,6 +400,7 @@ fn item_from_meta( provider: provider.to_string(), project_path: meta.cwd.clone().unwrap_or_else(|| "unknown".to_string()), title: meta.title.clone(), + title_provider_generated: meta.title_provider_generated, summary: meta.summary.clone(), first_user_message: meta.first_user_message.clone(), last_activity_at: meta.last_activity_at.unwrap_or(0).max(0), @@ -356,9 +444,21 @@ impl CodexSource { impl SessionSource for CodexSource { fn discover(&self) -> Vec { - let mut stats = Vec::new(); - walk_jsonl_recursive(&self.codex_home.join("sessions"), &mut stats); - stats + discover_codex_sessions(&self.codex_home).unwrap_or_default() + } + + fn provider_name(&self) -> Option<&'static str> { + Some("codex") + } + + /// Root-listing failure propagation: an unlistable `/sessions` + /// (EACCES/EIO — not a merely-absent one) is a scan failure, never a + /// silent empty listing. SINGLE-PASS: the same `read_dir` handle that + /// fails here is the one the traversal consumes (no disposable preflight, + /// no TOCTOU window), and root iterator-entry errors propagate too. + /// Nested-directory errors stay tolerant. + fn discover_checked(&self) -> Result, std::io::Error> { + discover_codex_sessions(&self.codex_home) } fn parse(&self, path: &Path) -> Option { @@ -366,11 +466,43 @@ impl SessionSource for CodexSource { } } +/// Stat every `.jsonl` under `/sessions`, recursively. +/// +/// SINGLE-PASS root traversal: the ONE `read_dir` opened by [`open_root_dir`] +/// is the one iterated, and per-entry iterator errors propagate (`?`) — a +/// root that fails to open OR fails mid-iteration is an `Err` (recorded as a +/// scan failure by the sweep), never a silent `Ok(empty)`. A MISSING root is +/// a genuine empty. NESTED directories stay tolerant via +/// [`walk_jsonl_recursive`] (Node parity: `walkJsonlFiles`). +fn discover_codex_sessions(codex_home: &Path) -> Result, std::io::Error> { + let root = codex_home.join("sessions"); + let Some(entries) = open_root_dir(&root)? else { + return Ok(Vec::new()); + }; + let mut paths: Vec = Vec::new(); + for entry in entries { + paths.push(entry?.path()); + } + paths.sort(); // determinism (readdir order is filesystem-dependent) + let mut stats = Vec::new(); + for path in paths { + if path.is_dir() { + walk_jsonl_recursive(&path, &mut stats); + } else if path.extension().and_then(|s| s.to_str()) == Some("jsonl") { + if let Some(stat) = stat_file(&path) { + stats.push(stat); + } + } + } + Ok(stats) +} + /// Recursively stat every `.jsonl` under `dir`, sorted (per directory level) /// for determinism — readdir order is filesystem-dependent. Mirrors /// `walkJsonlFiles` (`providers/codex.ts:423-436`): unbounded recursion, /// corruption-tolerant (an unreadable directory yields fewer entries, never -/// panics). +/// panics). NESTED levels only — the root level is +/// [`discover_codex_sessions`]'s single-pass checked traversal. fn walk_jsonl_recursive(dir: &Path, out: &mut Vec) { let Ok(entries) = std::fs::read_dir(dir) else { return; @@ -483,6 +615,10 @@ impl SessionSource for OpencodeSource { None } + fn provider_name(&self) -> Option<&'static str> { + Some("opencode") + } + fn direct_change_token(&self) -> Option { // The WAL wrinkle is load-bearing: sqlite in WAL mode (opencode's // default) can satisfy a write by appending to `opencode.db-wal` @@ -506,6 +642,15 @@ impl SessionSource for OpencodeSource { .map(opencode_session_to_indexed) .collect()) } + + /// Per-sweep health probe (see the trait doc): the db must still be + /// OPENABLE and its schema page READABLE through the exact open path + /// `direct_list` uses -- a locked/`chmod`ed/corrupted db fails here even + /// when its mtime (the change token) never moved. A MISSING db stays + /// healthy-absent, matching `list_sessions`'s `MissingDb` tolerance. + fn direct_health_check(&self) -> Result<(), String> { + self.provider.health_check().map_err(|e| e.to_string()) + } } fn opencode_session_to_indexed(s: crate::parse::OpencodeSession) -> IndexedSession { @@ -514,6 +659,9 @@ fn opencode_session_to_indexed(s: crate::parse::OpencodeSession) -> IndexedSessi provider: "opencode".to_string(), project_path: s.project_path, title: s.title, + // Node's opencode provider never marks a title provider-generated + // (no `titleSource` write anywhere in `providers/opencode.ts`). + title_provider_generated: false, // The opencode direct-lister never populates a summary or // first-user-message tier (`listSessionsDirect` doesn't read // `message`/`part` content for these fields) — faithful, not a gap. @@ -626,9 +774,21 @@ pub struct SessionIndex { persist_state: Arc>, } +/// One published sweep GENERATION: the snapshot items AND the scan-failure +/// set that same sweep produced, behind ONE lock. Publishing them as a unit +/// (and reading them through [`SessionIndex::snapshot_with_failures`]) is +/// what makes a coherent read possible — a consumer can never pair a +/// failed-scan empty snapshot with a subsequently cleared failure set (a +/// healthy-looking `ready + matches: []` lie), nor a recovered snapshot +/// with stale failures. The lock is only ever held for field reads/writes, +/// never across a sweep or an await point. struct CachedSnapshot { items: Arc>, fetched_at: Instant, + /// Providers whose listing attempt FAILED during the sweep that + /// published THIS generation (`getScanFailures` parity — see + /// [`SessionIndex::scan_failures`]). + scan_failures: HashSet, } /// Bookkeeping for the persistent parse-cache's opportunistic-save gating @@ -693,6 +853,42 @@ impl SessionIndex { } } + /// Providers whose MOST RECENT listing attempt failed (unsearchable, not + /// empty) — `codingCliIndexer.getScanFailures()` parity. Sorted, deduped. + /// Read from the published [`CachedSnapshot`] generation (one short + /// lock); a cold index (nothing published) has no failures yet. + /// + /// COHERENCE NOTE: this standalone accessor is a point-in-time read of + /// the CURRENT generation. A consumer that needs the snapshot AND its + /// failures to come from the SAME generation (the resolve route) must + /// use [`Self::snapshot_with_failures`] instead of pairing `snapshot()` + /// with a later `scan_failures()` call. + /// + /// NODE PARITY NOTE: Node behaves exactly like `refresh_snapshot` here — + /// a throwing `listSessionFiles()` also yields an empty file list and + /// lets the full-scan prune drop that provider's cached entries + /// (`session-indexer.ts:1467-1475`, `:1499-1504`); what makes the outage + /// VISIBLE is the recorded scan failure, which the route merges into + /// `providerErrors` and marks the response `degraded` — never a silent + /// healthy `ready + matches: []`. Both direct-listed (opencode) and + /// file-backed (claude/codex/amplifier) outages are therefore recorded. + pub fn scan_failures(&self) -> Vec { + let guard = self.snapshot.lock().unwrap(); + guard + .as_ref() + .map(|c| sorted_names(&c.scan_failures)) + .unwrap_or_default() + } + + /// Fire-and-forget refresh (`requestRefresh` parity): gives a degraded + /// response's Retry a chance to converge once a failed provider recovers. + /// No-op if a sweep is already running. + pub fn request_refresh(&self) { + if let Ok(guard) = Arc::clone(&self.refresh_lock).try_lock_owned() { + self.spawn_background_refresh(guard); + } + } + /// Return a snapshot, pre-sorted `lastActivityAt` DESC then `key()` DESC /// (`projection.ts:51-62`'s comparator, applied once here instead of /// once per request). @@ -706,8 +902,18 @@ impl SessionIndex { /// serve. Either way, the actual rebuild is incremental (see /// [`refresh_snapshot`]), not a full re-parse of unchanged files. pub async fn snapshot(&self) -> Arc> { - if let Some(items) = self.fresh_cached() { - return items; + self.snapshot_with_failures().await.0 + } + + /// [`Self::snapshot`] plus the scan failures of the SAME published + /// generation, read under ONE lock acquisition — the coherent read model + /// for consumers (the resolve route) that must never pair a failed-scan + /// snapshot with a cleared failure set, or a recovered snapshot with + /// stale failures. Same stale-while-revalidate semantics as + /// [`Self::snapshot`]. + pub async fn snapshot_with_failures(&self) -> (Arc>, Vec) { + if let Some(pair) = self.cached_pair(true) { + return pair; } // Stale or absent. Try to become this round's sweeper WITHOUT // blocking -- `try_lock_owned` never waits, so a caller that @@ -715,7 +921,7 @@ impl SessionIndex { // in-flight sweep. match Arc::clone(&self.refresh_lock).try_lock_owned() { Ok(guard) => { - if let Some(stale) = self.any_cached() { + if let Some(stale) = self.cached_pair(false) { // Someone must read fresh data eventually, but not THIS // caller, and not by blocking anyone else either. self.spawn_background_refresh(guard); @@ -726,7 +932,7 @@ impl SessionIndex { } Err(_) => { // Another caller is already sweeping this round. - if let Some(stale) = self.any_cached() { + if let Some(stale) = self.cached_pair(false) { return stale; } // Truly cold AND racing another cold-start caller: wait for @@ -734,8 +940,8 @@ impl SessionIndex { // B-T5's "N concurrent misses -> 1 sweep" guarantee for the // cold-cache case). let _guard = self.refresh_lock.lock().await; - self.fresh_cached() - .or_else(|| self.any_cached()) + self.cached_pair(true) + .or_else(|| self.cached_pair(false)) .unwrap_or_default() } } @@ -761,18 +967,27 @@ impl SessionIndex { /// The cached snapshot, if present and within the TTL window. A brief, /// non-async lock: never held across an await point. fn fresh_cached(&self) -> Option>> { - let guard = self.snapshot.lock().unwrap(); - match guard.as_ref() { - Some(c) if c.fetched_at.elapsed() < self.ttl => Some(Arc::clone(&c.items)), - _ => None, - } + self.cached_pair(true).map(|(items, _)| items) } /// The cached snapshot, if present, regardless of TTL freshness -- the /// stale-while-revalidate read: "is there ANYTHING to serve right now." fn any_cached(&self) -> Option>> { + self.cached_pair(false).map(|(items, _)| items) + } + + /// The cached `(snapshot, scan_failures)` pair of the SAME published + /// generation, read under ONE lock acquisition (never held across an + /// await point). `require_fresh` applies the TTL window; `false` is the + /// stale-while-revalidate read. + fn cached_pair(&self, require_fresh: bool) -> Option<(Arc>, Vec)> { let guard = self.snapshot.lock().unwrap(); - guard.as_ref().map(|c| Arc::clone(&c.items)) + match guard.as_ref() { + Some(c) if !require_fresh || c.fetched_at.elapsed() < self.ttl => { + Some((Arc::clone(&c.items), sorted_names(&c.scan_failures))) + } + _ => None, + } } /// Cold-start path: run the sweep and wait for it (there is nothing else @@ -782,8 +997,8 @@ impl SessionIndex { async fn run_refresh_inline( &self, guard: tokio::sync::OwnedMutexGuard<()>, - ) -> Arc> { - let items = Self::perform_refresh( + ) -> (Arc>, Vec) { + let pair = Self::perform_refresh( self.sources.clone(), Arc::clone(&self.file_cache), Arc::clone(&self.direct_cache), @@ -793,7 +1008,7 @@ impl SessionIndex { ) .await; drop(guard); - items + pair } /// Warm-cache path: run the sweep DETACHED, so the caller that triggered @@ -835,18 +1050,38 @@ impl SessionIndex { snapshot: Arc>>, persist_path: Option, persist_state: Arc>, - ) -> Arc> { + ) -> (Arc>, Vec) { let sweep_result = tokio::task::spawn_blocking({ let file_cache = Arc::clone(&file_cache); let direct_cache = Arc::clone(&direct_cache); + let snapshot = Arc::clone(&snapshot); move || { let mut cache = file_cache.lock().unwrap(); let mut direct = direct_cache.lock().unwrap(); - refresh_snapshot(&sources, &mut cache, &mut direct) + // Seed the failure set from the last PUBLISHED generation in + // a SHORT lock and mutate the LOCAL copy during the sweep — + // `scan_failures()`/`cached_pair()` are sync accessors + // called from async route handlers right after a + // stale-while-revalidate `snapshot()`, so holding the + // snapshot mutex across a multi-second sweep would block a + // runtime thread exactly when degraded responses are being + // served. The updated set is published below IN THE SAME + // lock write as the new snapshot (one atomic generation). + // Lost updates are impossible: `refresh_lock` guarantees at + // most one sweep at a time. + let mut failures = snapshot + .lock() + .unwrap() + .as_ref() + .map(|c| c.scan_failures.clone()) + .unwrap_or_default(); + let (items, changed) = + refresh_snapshot(&sources, &mut cache, &mut direct, &mut failures); + (items, changed, failures) } }) .await; - let (items, changed) = match sweep_result { + let (items, changed, failures) = match sweep_result { Ok(result) => result, Err(join_err) => { // `discover`/`parse` are documented never-panic (every @@ -856,9 +1091,10 @@ impl SessionIndex { // `unwrap_or_default()` behavior) would silently overwrite a // good, previously-published snapshot with an empty one // marked FRESH. Preserve whatever's already published - // instead, mirroring `refresh_snapshot`'s own - // `direct_list`-error handling a few lines above - // (preserve-cached + log) rather than dropping data. + // instead (items AND failures — the whole generation), + // mirroring `refresh_snapshot`'s own `direct_list`-error + // handling a few lines above (preserve-cached + log) rather + // than dropping data. eprintln!( "session-directory: refresh sweep panicked (preserving cached \ snapshot): {join_err}" @@ -867,16 +1103,22 @@ impl SessionIndex { .lock() .unwrap() .as_ref() - .map(|c| Arc::clone(&c.items)) + .map(|c| (Arc::clone(&c.items), sorted_names(&c.scan_failures))) .unwrap_or_default(); } }; let items = Arc::new(items); + let failure_names = sorted_names(&failures); { + // ONE lock write publishes the snapshot AND its scan failures as + // a single generation — a reader (`cached_pair`) can never + // observe a failed-scan snapshot paired with a cleared failure + // set, nor a recovered snapshot paired with stale failures. let mut guard = snapshot.lock().unwrap(); *guard = Some(CachedSnapshot { items: Arc::clone(&items), fetched_at: Instant::now(), + scan_failures: failures, }); } // guard dropped here — never held across an .await. // Opportunistic persistence: gated (threshold/debounce) and, when @@ -905,7 +1147,7 @@ impl SessionIndex { } }); } - items + (items, failure_names) } /// Populate the cache once, eagerly. Call from `main.rs` via @@ -958,6 +1200,15 @@ fn take_pending_save_from_parts( Some((path, cache_snapshot)) } +/// Sorted, deduped provider names from a scan-failure set — the wire shape +/// [`SessionIndex::scan_failures`]/[`SessionIndex::snapshot_with_failures`] +/// expose (`getScanFailures` parity: stable order for assertions/dedupe). +fn sorted_names(failures: &HashSet) -> Vec { + let mut names: Vec = failures.iter().cloned().collect(); + names.sort(); + names +} + /// One cached direct-listed source's last successful listing, keyed by /// source index in [`SessionIndex::direct_cache`]. Mirrors [`FileEntry`]'s /// role for file-based sources, but keyed by change-token instead of @@ -995,6 +1246,7 @@ fn refresh_snapshot( sources: &[Arc], cache: &mut HashMap, direct_cache: &mut HashMap, + scan_failures: &mut HashSet, ) -> (Vec, usize) { let mut discovered: std::collections::HashSet = std::collections::HashSet::new(); // Count of files re-parsed + direct-listed sources re-queried this @@ -1006,13 +1258,48 @@ fn refresh_snapshot( for (idx, source) in sources.iter().enumerate() { if let Some(token) = source.direct_change_token() { let unchanged = direct_cache.get(&idx).is_some_and(|e| e.token == token); - if !unchanged { + if unchanged { + // HEALTH evidence must be refreshed EVERY sweep (Node parity: + // `refreshDirectProvider()` runs on every full scan and + // records/clears `scanFailures` per attempt, + // `session-indexer.ts:1457`, `:1070-1082`). The unchanged + // mtime token gates only the expensive re-QUERY of session + // DATA -- a database can become locked or unreadable (e.g. + // `chmod`) without either watched mtime moving, so reporting + // `ready` from the stale cache here would violate the "never + // present unsearchable as healthy" contract. + match source.direct_health_check() { + Ok(()) => { + if let Some(name) = source.provider_name() { + scan_failures.remove(name); + } + } + Err(err) => { + if let Some(name) = source.provider_name() { + scan_failures.insert(name.to_string()); + } + eprintln!( + "session-directory: direct-listed source #{idx} health check \ + failed (preserving cached sessions): {err}" + ); + } + } + } else { match source.direct_list() { Ok(items) => { + if let Some(name) = source.provider_name() { + scan_failures.remove(name); + } direct_cache.insert(idx, DirectEntry { token, items }); changed += 1; } Err(err) => { + // Record the outage (`getScanFailures` parity) so the + // resolve route can surface it as a degraded + // providerError instead of a silent healthy empty. + if let Some(name) = source.provider_name() { + scan_failures.insert(name.to_string()); + } // Preserve whatever was cached from the last // successful listing (e.g. a locked/mid-write // sqlite db) -- never drop this provider's sessions @@ -1028,7 +1315,31 @@ fn refresh_snapshot( continue; } - for stat in source.discover() { + // File-backed: a ROOT-listing failure is recorded, then treated as an + // empty listing for this sweep (Node parity -- a throwing + // `listSessionFiles()` also yields an empty list and lets the + // full-scan prune drop that provider's cached entries, + // `session-indexer.ts:1467-1475`, `:1499-1504`; the recorded scan + // failure is what keeps the outage visible). + let stats = match source.discover_checked() { + Ok(stats) => { + if let Some(name) = source.provider_name() { + scan_failures.remove(name); + } + stats + } + Err(err) => { + if let Some(name) = source.provider_name() { + scan_failures.insert(name.to_string()); + } + eprintln!( + "session-directory: source #{idx} root listing failed \ + (treating as empty this sweep): {err}" + ); + Vec::new() + } + }; + for stat in stats { let unchanged = cache .get(&stat.path) .is_some_and(|entry| entry.mtime_ms == stat.mtime_ms && entry.size == stat.size); @@ -1399,6 +1710,7 @@ mod tests { provider: provider.to_string(), project_path: "/p".to_string(), title: Some(format!("t-{session_id}")), + title_provider_generated: false, summary: None, first_user_message: None, last_activity_at, @@ -2022,6 +2334,7 @@ mod tests { let snapshot = Arc::new(StdMutex::new(Some(CachedSnapshot { items: Arc::clone(&good_snapshot), fetched_at: Instant::now(), + scan_failures: HashSet::new(), }))); let file_cache = Arc::new(StdMutex::new(HashMap::new())); let direct_cache = Arc::new(StdMutex::new(HashMap::new())); @@ -2029,7 +2342,7 @@ mod tests { let panicking_source: Arc = Arc::new(PanicSource); - let result = SessionIndex::perform_refresh( + let (result, result_failures) = SessionIndex::perform_refresh( vec![panicking_source], Arc::clone(&file_cache), Arc::clone(&direct_cache), @@ -2045,6 +2358,10 @@ mod tests { "a panicked blocking sweep must return the last-known-good snapshot, \ not an empty one" ); + assert!( + result_failures.is_empty(), + "the preserved generation's failure set rides along unchanged" + ); let published = snapshot.lock().unwrap(); assert_eq!( @@ -3040,9 +3357,12 @@ mod tests { /// neither is set -- disabling persistence rather than erroring. #[test] fn default_persist_path_resolves_freshell_home_then_home_then_none() { - // Serialize env-var mutation: this is the ONLY test in this crate - // that touches FRESHELL_HOME/HOME, so no cross-test interference is - // possible even under the default parallel test harness. + // Serialize env-var mutation on the crate-wide lock: + // `parse::opencode`'s `home_dir()` tests also mutate HOME, and cargo + // runs tests in parallel THREADS within one process. + let _lock = crate::HOME_ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let prior_freshell_home = std::env::var("FRESHELL_HOME").ok(); let prior_home = std::env::var("HOME").ok(); @@ -3079,4 +3399,492 @@ mod tests { None => std::env::remove_var("HOME"), } } + + // -- Task 5 (resolve parity): scan-failure channel ---------------------- + + #[tokio::test] + async fn a_failing_direct_list_records_a_scan_failure_and_recovery_clears_it() { + // A direct-listed source with a CONSTANT change token whose + // direct_list()/direct_health_check() can be toggled to Err. The + // constant token is load-bearing (review fix): a previous version of + // this test used an artificial always-changing token, which forced a + // re-query every sweep and thereby MASKED the production defect that + // health evidence was only refreshed when the mtime token moved + // (a locked/chmod'd db changes neither mtime). + struct FlakySource(std::sync::Arc); + impl SessionSource for FlakySource { + fn discover(&self) -> Vec { + Vec::new() + } + fn parse(&self, _p: &Path) -> Option { + None + } + fn provider_name(&self) -> Option<&'static str> { + Some("opencode") + } + // CONSTANT token: the underlying mtimes never move in this test. + fn direct_change_token(&self) -> Option { + Some(42) + } + fn direct_list(&self) -> Result, String> { + if self.0.load(std::sync::atomic::Ordering::SeqCst) { + Err("unable to open database file".to_string()) + } else { + Ok(Vec::new()) + } + } + fn direct_health_check(&self) -> Result<(), String> { + if self.0.load(std::sync::atomic::Ordering::SeqCst) { + Err("unable to open database file".to_string()) + } else { + Ok(()) + } + } + } + let broken = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + let index = SessionIndex::with_ttl_and_cache_path( + vec![std::sync::Arc::new(FlakySource(std::sync::Arc::clone(&broken))) as _], + std::time::Duration::ZERO, // every snapshot() sweeps + None, + ); + // Phase 1 — COLD cache, broken source: the first snapshot() sweeps + // INLINE (deterministic assert). No DirectEntry is cached yet, so + // this exercises the direct_list() error path. + let _ = index.snapshot().await; + assert_eq!(index.scan_failures(), vec!["opencode".to_string()]); + // Phase 2 — recovery: a failed listing never cached a token, so the + // next sweep re-queries and clears the failure. WARM-but-stale cache: + // snapshot() refreshes DETACHED (stale-while-revalidate) — observe by + // POLLING via the module's existing `wait_until` helper. + broken.store(false, std::sync::atomic::Ordering::SeqCst); + let _ = index.snapshot().await; + assert!( + wait_until(std::time::Duration::from_secs(2), || index + .scan_failures() + .is_empty()) + .await, + "scan failure must clear once the source recovers" + ); + // Phase 3 — the review-fix case: a DirectEntry is now cached under + // token 42 and the token NEVER changes again, yet the source becomes + // unhealthy. The per-sweep health probe alone must re-record the + // failure — a sweep must never report `ready` from stale evidence. + broken.store(true, std::sync::atomic::Ordering::SeqCst); + let _ = index.snapshot().await; + assert!( + wait_until(std::time::Duration::from_secs(2), || index.scan_failures() + == vec!["opencode".to_string()]) + .await, + "a newly-failing source with an UNCHANGED change token must still \ + record a scan failure (health is re-evidenced every sweep)" + ); + } + + /// Finding-1 coverage through the REAL `OpencodeSource` (no injected + /// token, no test double): a seeded sqlite db that becomes unreadable + /// via `chmod 000` — which moves NEITHER `opencode.db` nor + /// `opencode.db-wal` mtime, so the change token is provably unchanged — + /// must record a scan failure while preserving the cached sessions, and + /// clear it again once the db is readable (mtime STILL unchanged, so + /// recovery also flows through the health probe, not a re-query). + #[cfg(unix)] + #[tokio::test] + async fn opencode_db_unreadable_with_unchanged_mtime_records_a_scan_failure() { + use std::os::unix::fs::PermissionsExt; + let data_home = opencode_data_home_with_sessions( + "opencodesrc-health", + &[("ses_a", "/repo/a", "Session A", 1000, 5000)], + ); + let db = data_home.join("opencode.db"); + let source = OpencodeSource::new(data_home.clone()); + let index = SessionIndex::with_ttl_and_cache_path( + vec![Arc::new(source) as _], + Duration::ZERO, + None, + ); + let snap = index.snapshot().await; + assert_eq!(snap.len(), 1, "sanity: the readable db lists one session"); + assert!(index.scan_failures().is_empty()); + + let mtime_before = std::fs::metadata(&db).unwrap().modified().unwrap(); + std::fs::set_permissions(&db, std::fs::Permissions::from_mode(0o000)).unwrap(); + assert_eq!( + std::fs::metadata(&db).unwrap().modified().unwrap(), + mtime_before, + "chmod must not move the db mtime — the change token is unchanged" + ); + if std::fs::File::open(&db).is_ok() { + // Permission bits are not enforced for this euid (e.g. root in a + // CI container) — the probe cannot fail. Restore and bail rather + // than assert something the environment cannot produce. + eprintln!("skipping unreadable-db assertions: euid can read a 0o000 file"); + std::fs::set_permissions(&db, std::fs::Permissions::from_mode(0o644)).unwrap(); + std::fs::remove_dir_all(&data_home).ok(); + return; + } + + let _ = index.snapshot().await; // stale-while-revalidate: sweep is detached + assert!( + wait_until(Duration::from_secs(2), || index.scan_failures() + == vec!["opencode".to_string()]) + .await, + "an unreadable db with an unchanged mtime must record a scan failure" + ); + assert_eq!( + index.snapshot().await.len(), + 1, + "cached sessions are preserved through the outage (data cache intact)" + ); + + std::fs::set_permissions(&db, std::fs::Permissions::from_mode(0o644)).unwrap(); + let _ = index.snapshot().await; + assert!( + wait_until(Duration::from_secs(2), || index.scan_failures().is_empty()).await, + "the failure must clear once the db is readable again (mtime still unchanged)" + ); + std::fs::remove_dir_all(&data_home).ok(); + } + + #[tokio::test] + async fn a_failing_file_backed_root_listing_records_a_scan_failure_too() { + // FILE-BACKED parity (Node records listSessionFiles() throws for claude/ + // codex/amplifier in scanFailures — session-indexer.ts:1250-1262 — and the + // route turns them into degraded providerErrors): a source whose + // discover_checked() errs must be recorded, NOT silently treated as an + // empty listing. + struct FlakyFileSource(std::sync::Arc); + impl SessionSource for FlakyFileSource { + fn discover(&self) -> Vec { + Vec::new() + } + fn discover_checked(&self) -> Result, std::io::Error> { + if self.0.load(std::sync::atomic::Ordering::SeqCst) { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "denied", + )) + } else { + Ok(Vec::new()) + } + } + fn parse(&self, _p: &Path) -> Option { + None + } + fn provider_name(&self) -> Option<&'static str> { + Some("claude") + } + } + let broken = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + let index = SessionIndex::with_ttl_and_cache_path( + vec![std::sync::Arc::new(FlakyFileSource(std::sync::Arc::clone(&broken))) as _], + std::time::Duration::ZERO, + None, + ); + let _ = index.snapshot().await; // cold sweep is INLINE — deterministic + assert_eq!(index.scan_failures(), vec!["claude".to_string()]); + broken.store(false, std::sync::atomic::Ordering::SeqCst); + let _ = index.snapshot().await; // stale-while-revalidate: poll for recovery + assert!( + wait_until(std::time::Duration::from_secs(2), || index + .scan_failures() + .is_empty()) + .await, + "file-backed scan failure must clear once the root is listable again" + ); + } + + /// Finding-2 coverage through the REAL `ClaudeSource` (no injected + /// `discover_checked` double): a seeded home whose `projects/` root + /// becomes unlistable (`chmod 000`) must record a scan failure — the + /// single-pass checked traversal propagates the root `read_dir` error + /// from the SAME handle the sweep consumes (no disposable preflight, no + /// TOCTOU window in which a failure could come back as `Ok(empty)` and + /// clear the outage) — and recovery must clear it. + #[cfg(unix)] + #[tokio::test] + async fn real_claude_source_unlistable_projects_root_records_a_scan_failure() { + use std::os::unix::fs::PermissionsExt; + let home = unique_temp_dir("claude-unlistable"); + let project = home.join("projects").join("proj-a"); + std::fs::create_dir_all(&project).unwrap(); + let sid = synthetic_session_id(1); + write_session_file( + &project, + "s.jsonl", + &sid, + "/repo/a", + "2024-01-01T00:00:00Z", + "hello", + ); + let index = SessionIndex::with_ttl_and_cache_path( + vec![Arc::new(ClaudeSource::new(home.clone())) as _], + Duration::ZERO, + None, + ); + assert_eq!( + index.snapshot().await.len(), + 1, + "sanity: one session listed" + ); + assert!(index.scan_failures().is_empty()); + + let projects = home.join("projects"); + std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o000)).unwrap(); + if std::fs::read_dir(&projects).is_ok() { + eprintln!("skipping unlistable-root assertions: euid can list a 0o000 dir"); + std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::remove_dir_all(&home).ok(); + return; + } + + let _ = index.snapshot().await; // stale-while-revalidate: sweep is detached + assert!( + wait_until(Duration::from_secs(2), || index.scan_failures() + == vec!["claude".to_string()]) + .await, + "an unlistable projects root must record a scan failure for the REAL source" + ); + + std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o755)).unwrap(); + let _ = index.snapshot().await; + assert!( + wait_until(Duration::from_secs(2), || index.scan_failures().is_empty()).await, + "the failure must clear once the root is listable again" + ); + assert!( + wait_until(Duration::from_secs(2), || { + index.peek().map(|s| s.len()) == Some(1) + }) + .await, + "the session must be re-discovered after recovery" + ); + std::fs::remove_dir_all(&home).ok(); + } + + /// Finding-2 coverage for the REAL `CodexSource` and `AmplifierSource` + /// checked traversals, at the lowest real layer (`discover_checked` + /// itself): an unlistable root is `Err` (never a silent `Ok(empty)`), + /// a listable one enumerates, and a MISSING root is a genuine empty. + #[cfg(unix)] + #[test] + fn real_codex_and_amplifier_unlistable_roots_propagate_from_discover_checked() { + use std::os::unix::fs::PermissionsExt; + + // Codex: /sessions////*.jsonl + let codex_home = unique_temp_dir("codex-unlistable"); + let day = codex_home + .join("sessions") + .join("2024") + .join("01") + .join("01"); + std::fs::create_dir_all(&day).unwrap(); + std::fs::write(day.join("rollout-x.jsonl"), b"").unwrap(); + let codex = CodexSource::new(codex_home.clone()); + assert_eq!( + codex.discover_checked().expect("listable root").len(), + 1, + "sanity: one rollout discovered" + ); + let codex_root = codex_home.join("sessions"); + std::fs::set_permissions(&codex_root, std::fs::Permissions::from_mode(0o000)).unwrap(); + let enforced = std::fs::read_dir(&codex_root).is_err(); + if enforced { + assert!( + codex.discover_checked().is_err(), + "an unlistable codex sessions root must be Err, never Ok(empty)" + ); + } else { + eprintln!("skipping unlistable-root assertions: euid can list a 0o000 dir"); + } + std::fs::set_permissions(&codex_root, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::remove_dir_all(&codex_home).ok(); + + // Amplifier: /projects//sessions//metadata.json + let amp_home = unique_temp_dir("amplifier-unlistable"); + let session_dir = amp_home + .join("projects") + .join("-repo-a") + .join("sessions") + .join("sess-1"); + std::fs::create_dir_all(&session_dir).unwrap(); + std::fs::write(session_dir.join("metadata.json"), b"{}").unwrap(); + let amp = crate::amplifier::AmplifierSource::new(amp_home.clone()); + assert_eq!( + amp.discover_checked().expect("listable root").len(), + 1, + "sanity: one metadata.json discovered" + ); + let amp_root = amp_home.join("projects"); + std::fs::set_permissions(&_root, std::fs::Permissions::from_mode(0o000)).unwrap(); + if enforced { + assert!( + amp.discover_checked().is_err(), + "an unlistable amplifier projects root must be Err, never Ok(empty)" + ); + } + std::fs::set_permissions(&_root, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::remove_dir_all(&_home).ok(); + + // MISSING roots stay a genuine empty (absent provider), for both. + let ghost = unique_temp_dir("ghost-home"); + assert_eq!( + CodexSource::new(ghost.clone()).discover_checked().unwrap(), + Vec::new() + ); + assert_eq!( + crate::amplifier::AmplifierSource::new(ghost) + .discover_checked() + .unwrap(), + Vec::new() + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn scan_failures_is_readable_while_a_sweep_is_in_flight() { + // Regression test for the sweep-long mutex hold: `perform_refresh` + // must NOT hold the `scan_failures` mutex across the whole + // discovery+parse sweep, or the sync accessor `scan_failures()` + // (called from async route handlers right after a stale-while- + // revalidate `snapshot()`) blocks a runtime thread for the full + // sweep duration — multi-second on a large home ("5s problem"). + // + // A source whose discover_checked() BLOCKS until released simulates + // a slow sweep; scan_failures() must still return promptly mid-sweep. + struct BlockingSource { + entered: std::sync::Arc, + release: std::sync::Arc<(std::sync::Mutex, std::sync::Condvar)>, + } + impl SessionSource for BlockingSource { + fn discover(&self) -> Vec { + Vec::new() + } + fn discover_checked(&self) -> Result, std::io::Error> { + self.entered + .store(true, std::sync::atomic::Ordering::SeqCst); + let (lock, cvar) = &*self.release; + let mut released = lock.lock().unwrap(); + while !*released { + released = cvar.wait(released).unwrap(); + } + Ok(Vec::new()) + } + fn parse(&self, _p: &Path) -> Option { + None + } + fn provider_name(&self) -> Option<&'static str> { + Some("claude") + } + } + let entered = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let release = + std::sync::Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new())); + let index = std::sync::Arc::new(SessionIndex::with_ttl_and_cache_path( + vec![std::sync::Arc::new(BlockingSource { + entered: std::sync::Arc::clone(&entered), + release: std::sync::Arc::clone(&release), + }) as _], + std::time::Duration::ZERO, + None, + )); + // Detached sweep (never awaited by any caller) — exactly the + // stale-while-revalidate shape the resolve route sees. + index.request_refresh(); + assert!( + wait_until(std::time::Duration::from_secs(2), || entered + .load(std::sync::atomic::Ordering::SeqCst)) + .await, + "sweep must have entered discover_checked" + ); + // Mid-sweep read, off the runtime so a regression blocks THIS task, + // not the whole test executor. Must complete well within the timeout. + let reader = { + let index = std::sync::Arc::clone(&index); + tokio::task::spawn_blocking(move || index.scan_failures()) + }; + let read_result = tokio::time::timeout(std::time::Duration::from_millis(500), reader).await; + // ALWAYS release the sweep (and the possibly-still-blocked reader) + // before asserting, so a failure never leaks a stuck thread. + { + let (lock, cvar) = &*release; + *lock.lock().unwrap() = true; + cvar.notify_all(); + } + let failures = read_result + .expect("scan_failures() must not block while a sweep is in flight") + .expect("reader task must not panic"); + assert!( + failures.is_empty(), + "no failure recorded yet — the sweep hasn't published anything" + ); + } + + /// Finding-3 pin: the snapshot and its scan failures are ONE atomic + /// generation. A source that strictly alternates between a failing sweep + /// (empty listing + failure recorded) and a healthy sweep (one session + + /// failure cleared) can only ever be observed in one of those two + /// coherent states through the combined accessor — never a failed-scan + /// empty snapshot paired with a cleared failure set (a healthy-looking + /// `ready + matches: []` lie), and never a recovered snapshot paired + /// with stale failures. Structural with the single-lock publish; the + /// old split-lock model had a window between the two publishes where + /// both incoherent mixes were observable. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn snapshot_and_scan_failures_publish_as_one_atomic_generation() { + struct AlternatingSource { + item: IndexedSession, + sweeps: Arc, + } + impl SessionSource for AlternatingSource { + fn discover(&self) -> Vec { + Vec::new() + } + fn discover_checked(&self) -> Result, std::io::Error> { + if self.sweeps.fetch_add(1, Ordering::SeqCst).is_multiple_of(2) { + Ok(vec![FileStat { + path: PathBuf::from("/fake/alternating.jsonl"), + mtime_ms: 0, + size: 0, + }]) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "denied", + )) + } + } + fn parse(&self, _p: &Path) -> Option { + Some(self.item.clone()) + } + fn provider_name(&self) -> Option<&'static str> { + Some("claude") + } + } + let index = SessionIndex::with_ttl_and_cache_path( + vec![Arc::new(AlternatingSource { + item: mk("s1", "claude", 1), + sweeps: Arc::new(AtomicUsize::new(0)), + }) as _], + Duration::ZERO, // every snapshot() is stale -> continuous sweeps + None, + ); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + let (mut seen_healthy, mut seen_failed) = (false, false); + while std::time::Instant::now() < deadline && !(seen_healthy && seen_failed) { + let (items, failures) = index.snapshot_with_failures().await; + match (items.len(), failures.as_slice()) { + (1, []) => seen_healthy = true, + (0, [name]) if name == "claude" => seen_failed = true, + (len, _) => panic!( + "incoherent generation observed: a failed-scan snapshot must never \ + pair with a cleared failure set (items={len}, failures={failures:?})" + ), + } + tokio::time::sleep(Duration::from_millis(2)).await; + } + assert!( + seen_healthy && seen_failed, + "both generations must be observed for the pin to be meaningful \ + (healthy={seen_healthy}, failed={seen_failed})" + ); + } } diff --git a/crates/freshell-sessions/src/lib.rs b/crates/freshell-sessions/src/lib.rs index 16d1aa179..19d73ebc4 100644 --- a/crates/freshell-sessions/src/lib.rs +++ b/crates/freshell-sessions/src/lib.rs @@ -22,10 +22,21 @@ pub mod indexer; pub mod meta; pub mod opencode_locator; pub mod parse; +pub mod resume_input; +pub mod resume_resolve; pub mod search; pub mod text; pub mod time; +/// Serializes tests (crate-wide) that mutate the process-global +/// `HOME`/`USERPROFILE`/`FRESHELL_HOME` env vars: cargo runs tests in +/// parallel THREADS within one process, so two tests racing to mutate the +/// SAME vars would otherwise flake (one test's assertion observing the +/// OTHER test's in-flight env state). Shared by `directory_index`'s +/// persist-path test and `parse::opencode`'s `home_dir()` tests. +#[cfg(test)] +pub(crate) static HOME_ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + pub use meta::{CodexTaskEventSnapshot, ParsedSessionMeta, TokenSummary}; pub use parse::{parse_codex_session_content, parse_session_content, ParseSessionOptions}; pub use search::{extract_snippet, search_session_file, FileSearchMatch, FileSearchTier}; diff --git a/crates/freshell-sessions/src/meta.rs b/crates/freshell-sessions/src/meta.rs index 5658e312d..d2cb98876 100644 --- a/crates/freshell-sessions/src/meta.rs +++ b/crates/freshell-sessions/src/meta.rs @@ -50,6 +50,16 @@ pub struct ParsedSessionMeta { pub created_at: Option, pub last_activity_at: Option, pub title: Option, + /// Node's parse-layer `titleSource` (`ParsedSessionTitleSource`, + /// `server/coding-cli/types.ts:100` — its ONLY variant is + /// `'provider-generated'`, so a bool is the faithful shape): `true` iff + /// the parsed `title` was generated by the provider itself (claude + /// custom-title/agent-name records, `providers/claude.ts:505`; amplifier + /// `name`, `providers/amplifier.ts:93`). Consumed by the override + /// projection (`applyOverride`, `session-indexer.ts:210-211`): a stored + /// `dir`/`first-message`-sourced override never clobbers a current + /// provider-generated title. + pub title_provider_generated: bool, pub summary: Option, pub first_user_message: Option, pub message_count: i64, diff --git a/crates/freshell-sessions/src/parse/claude.rs b/crates/freshell-sessions/src/parse/claude.rs index f99256b74..56fbaf67a 100644 --- a/crates/freshell-sessions/src/parse/claude.rs +++ b/crates/freshell-sessions/src/parse/claude.rs @@ -503,12 +503,20 @@ pub fn parse_session_content(content: &str, options: &ParseSessionOptions) -> Pa } }); + // Node's parse-layer title provenance (`providers/claude.ts:505`): + // `titleSource = 'provider-generated'` iff a custom-title / agent-name / + // generated-summary-title record was seen. Node's third input + // (`extractClaudeGeneratedTitleFromJsonlObject`) has no ported extraction + // here, so no Rust-parsed title can originate from it. + let title_provider_generated = custom_title.is_some() || agent_name.is_some(); + ParsedSessionMeta { session_id, cwd, created_at, last_activity_at, title: custom_title.or(agent_name).or(title), + title_provider_generated, summary, first_user_message, message_count: lines.len() as i64, diff --git a/crates/freshell-sessions/src/parse/codex.rs b/crates/freshell-sessions/src/parse/codex.rs index f597d2b68..655117144 100644 --- a/crates/freshell-sessions/src/parse/codex.rs +++ b/crates/freshell-sessions/src/parse/codex.rs @@ -485,6 +485,9 @@ pub fn parse_codex_session_content(content: &str) -> ParsedSessionMeta { created_at, last_activity_at, title, + // Node's codex provider never marks a parsed title provider-generated + // (no `titleSource` write anywhere in `providers/codex.ts`). + title_provider_generated: false, summary, first_user_message, message_count: lines.len() as i64, diff --git a/crates/freshell-sessions/src/parse/mod.rs b/crates/freshell-sessions/src/parse/mod.rs index 43f280143..33ec37a9b 100644 --- a/crates/freshell-sessions/src/parse/mod.rs +++ b/crates/freshell-sessions/src/parse/mod.rs @@ -11,7 +11,8 @@ pub mod opencode; pub use claude::{parse_session_content, ParseSessionOptions}; pub use codex::parse_codex_session_content; pub use opencode::{ - default_opencode_data_home, run_opencode_listing_query, session_exists_by_id, OpencodeDegrade, - OpencodeListing, OpencodeListingResult, OpencodeProvider, OpencodeReadError, OpencodeSession, + default_opencode_data_home, opencode_session_row_by_id, run_opencode_listing_query, + session_exists_by_id, OpencodeByIdError, OpencodeByIdRow, OpencodeDegrade, OpencodeListing, + OpencodeListingResult, OpencodeProvider, OpencodeReadError, OpencodeSession, OpencodeSessionRow, THREE_VIEWS_MARKER_SQL_PATTERN, }; diff --git a/crates/freshell-sessions/src/parse/opencode.rs b/crates/freshell-sessions/src/parse/opencode.rs index 7625c6fc4..2c889829d 100644 --- a/crates/freshell-sessions/src/parse/opencode.rs +++ b/crates/freshell-sessions/src/parse/opencode.rs @@ -281,6 +281,25 @@ impl OpencodeProvider { /// row-mapping skips rows without a cwd, and a query failure surfaces as `Err` /// (re-throw / preserve-cached semantics). `now_ms` is the injected clock the /// reference reads from `Date.now()`. + /// Cheap per-sweep health probe: is the database still OPENABLE and its + /// schema page READABLE through the exact open path [`Self::list_sessions`] + /// uses? A missing db is healthy-absent (matching `list_sessions`'s + /// `MissingDb` tolerance); a locked, `chmod`ed, or corrupted db errors. + /// One `sqlite_master` count (a single page read) — never a full listing. + pub fn health_check(&self) -> Result<(), OpencodeReadError> { + let db_path = self.database_path(); + if !db_path.exists() { + return Ok(()); + } + let conn = Connection::open_with_flags( + &db_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, + ) + .map_err(|e| OpencodeReadError(e.to_string()))?; + conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())) + .map_err(|e| OpencodeReadError(e.to_string())) + } + pub fn list_sessions(&self, now_ms: i64) -> Result { let db_path = self.database_path(); let mut degrade = Vec::new(); @@ -424,6 +443,124 @@ pub fn session_exists_by_id(data_home: &Path, session_id: &str) -> Result, + pub message: String, +} + +/// Map a rusqlite error to the Node-style `SQLITE_*` code name via +/// `rusqlite::Error::sqlite_error_code()` (available in the pinned 0.31.0). +fn by_id_err(e: rusqlite::Error) -> OpencodeByIdError { + use rusqlite::ffi::ErrorCode as C; + let code = e.sqlite_error_code().and_then(|c| match c { + C::CannotOpen => Some("SQLITE_CANTOPEN"), + C::DatabaseBusy => Some("SQLITE_BUSY"), + C::DatabaseLocked => Some("SQLITE_LOCKED"), + C::NotADatabase => Some("SQLITE_NOTADB"), + C::PermissionDenied => Some("SQLITE_PERM"), + C::ReadOnly => Some("SQLITE_READONLY"), + _ => None, + }); + OpencodeByIdError { + code: code.map(str::to_string), + message: e.to_string(), + } +} + +/// The hardened exact-id row (`OpencodeSessionRow` subset the by-id query +/// selects). `last_activity_at` floored to integer ms (REAL columns possible). +#[derive(Debug, Clone, PartialEq)] +pub struct OpencodeByIdRow { + pub session_id: String, + pub cwd: Option, + pub title: Option, + pub created_at: Option, + pub last_activity_at: Option, + pub project_path: Option, +} + +/// Hardened (#586) exact-id lookup — 1:1 port of +/// `runOpencodeSessionByIdQuery` (`opencode-by-id-query.ts`). Deliberately +/// includes ARCHIVED and CHILD sessions: an exact id pasted by the user must +/// resolve even when the listing hides it. Errors PROPAGATE (a missing or +/// unreadable DB file is `Err`, matching Node's throwing `DatabaseSync` +/// open — provider unavailable ≠ not found). +pub fn opencode_session_row_by_id( + data_home: &Path, + session_id: &str, +) -> Result, OpencodeByIdError> { + let db_path = data_home.join("opencode.db"); + let conn = Connection::open_with_flags( + &db_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, + ) + .map_err(by_id_err)?; + conn.busy_timeout(std::time::Duration::from_millis( + OPENCODE_BYID_BUSY_TIMEOUT_MS, + )) + .map_err(by_id_err)?; + + let table_names: std::collections::HashSet = { + let mut stmt = conn + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .map_err(by_id_err)?; + let rows = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map_err(by_id_err)?; + let mut set = std::collections::HashSet::new(); + for r in rows { + set.insert(r.map_err(by_id_err)?); + } + set + }; + if !table_names.contains("session") { + return Ok(None); + } + let has_project = table_names.contains("project"); + let project_select = if has_project { "p.worktree" } else { "NULL" }; + let project_join = if has_project { + "LEFT JOIN project p ON p.id = s.project_id" + } else { + "" + }; + let sql = format!( + "SELECT s.id, s.directory, s.title, s.time_created, s.time_updated, \ + {project_select} FROM session s {project_join} WHERE s.id = ?1 LIMIT 1" + ); + match conn.query_row(&sql, rusqlite::params![session_id], |row| { + Ok(OpencodeByIdRow { + session_id: match row.get::<_, SqlValue>(0)? { + SqlValue::Text(s) => s, + other => to_opt_string(&other).unwrap_or_default(), + }, + cwd: to_opt_string(&row.get::<_, SqlValue>(1)?), + title: to_opt_string(&row.get::<_, SqlValue>(2)?), + created_at: to_opt_i64(&row.get::<_, SqlValue>(3)?), + last_activity_at: to_opt_i64(&row.get::<_, SqlValue>(4)?), + project_path: to_opt_string(&row.get::<_, SqlValue>(5)?), + }) + }) { + Ok(row) => Ok(Some(row)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(by_id_err(e)), + } +} + /// `defaultOpencodeDataHome` — `$XDG_DATA_HOME/opencode` -> win `LOCALAPPDATA/opencode` /// -> `~/.local/share/opencode`. pub fn default_opencode_data_home() -> PathBuf { @@ -450,8 +587,151 @@ pub fn default_opencode_data_home() -> PathBuf { .join("opencode") } +/// Node `os.homedir()` platform semantics (libuv `uv_os_homedir`): Windows +/// reads `USERPROFILE` (HOME is NEVER consulted); POSIX reads `HOME` when +/// set and non-empty, else the effective user's passwd-entry home +/// (`getpwuid_r`). Rust's `std::env::home_dir()` (un-deprecated since 1.87, +/// MSRV here is 1.96) implements exactly these platform rules, so this +/// delegates to it — same contract as `session_directory::provider_home()` +/// in `freshell-server`. An earlier interim version approximated this as +/// HOME-then-USERPROFILE on ALL platforms. fn home_dir() -> Option { - std::env::var_os("HOME") - .map(PathBuf::from) - .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from)) + std::env::home_dir() +} + +#[cfg(test)] +mod home_dir_tests { + use super::home_dir; + use crate::HOME_ENV_TEST_LOCK; + use std::path::PathBuf; + + // This helper feeds `default_opencode_data_home()`, which the resolve + // route's opencode exact-id fallback resolves PER CALL — the same Node + // `os.homedir()` platform contract as + // `session_directory::provider_home()` in `freshell-server`: Windows + // reads USERPROFILE (HOME never consulted); POSIX reads HOME when set + // and non-empty, else the passwd-entry home (USERPROFILE never + // consulted). Tests mutate real process env, so they serialize on the + // crate-wide `HOME_ENV_TEST_LOCK` and save/restore each var. + + /// Save-and-restore guard for one env var; restores on drop, panic + /// included (same shape as `main.rs`'s `EnvVarGuard` in + /// `freshell-server`). + struct EnvVarGuard { + name: &'static str, + saved: Option, + } + + impl EnvVarGuard { + fn unset(name: &'static str) -> Self { + let saved = std::env::var_os(name); + std::env::remove_var(name); + Self { name, saved } + } + + fn set(name: &'static str, value: &str) -> Self { + let saved = std::env::var_os(name); + std::env::set_var(name, value); + Self { name, saved } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match self.saved.take() { + Some(v) => std::env::set_var(self.name, v), + None => std::env::remove_var(self.name), + } + } + } + + /// The effective user's passwd-entry home (`getpwuid_r`) — the Node + /// `os.homedir()` POSIX fallback when `HOME` is unset or empty. + #[cfg(unix)] + fn passwd_entry_home() -> PathBuf { + use std::os::unix::ffi::OsStrExt; + let uid = unsafe { libc::geteuid() }; + let mut pwd: libc::passwd = unsafe { std::mem::zeroed() }; + let mut buf = vec![0u8; 16 * 1024]; + let mut result: *mut libc::passwd = std::ptr::null_mut(); + let rc = unsafe { + libc::getpwuid_r( + uid, + &mut pwd, + buf.as_mut_ptr().cast::(), + buf.len(), + &mut result, + ) + }; + assert_eq!(rc, 0, "getpwuid_r must succeed for the effective uid"); + assert!(!result.is_null(), "effective uid must have a passwd entry"); + let dir = unsafe { std::ffi::CStr::from_ptr(pwd.pw_dir) }; + PathBuf::from(std::ffi::OsStr::from_bytes(dir.to_bytes())) + } + + #[cfg(unix)] + #[test] + fn unix_empty_home_uses_passwd_entry_never_userprofile() { + let _lock = HOME_ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _home = EnvVarGuard::set("HOME", ""); + let _userprofile = EnvVarGuard::set("USERPROFILE", "/Users/win-fixture"); + assert_eq!( + home_dir(), + Some(passwd_entry_home()), + "an EMPTY HOME must behave like unset HOME: passwd-entry fallback, never USERPROFILE" + ); + } + + #[cfg(unix)] + #[test] + fn unix_unset_home_ignores_userprofile_using_passwd_entry() { + let _lock = HOME_ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _home = EnvVarGuard::unset("HOME"); + let _userprofile = EnvVarGuard::set("USERPROFILE", "/Users/win-fixture"); + let resolved = home_dir(); + assert_ne!( + resolved, + Some(PathBuf::from("/Users/win-fixture")), + "POSIX must NEVER consult USERPROFILE (Node os.homedir() reads it on Windows only)" + ); + assert_eq!( + resolved, + Some(passwd_entry_home()), + "with HOME unset, POSIX must resolve the passwd-entry home" + ); + } + + #[cfg(unix)] + #[test] + fn unix_home_wins_when_both_are_set() { + let _lock = HOME_ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _home = EnvVarGuard::set("HOME", "/home/real"); + let _userprofile = EnvVarGuard::set("USERPROFILE", "/Users/win-fixture"); + assert_eq!( + home_dir(), + Some(PathBuf::from("/home/real")), + "a set, non-empty HOME must win on POSIX (USERPROFILE is never consulted)" + ); + } + + #[cfg(windows)] + #[test] + fn windows_uses_userprofile_never_home() { + let _lock = HOME_ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _home = EnvVarGuard::set("HOME", "C:\\never-consulted"); + let _userprofile = EnvVarGuard::set("USERPROFILE", "C:\\Users\\win-fixture"); + assert_eq!( + home_dir(), + Some(PathBuf::from("C:\\Users\\win-fixture")), + "Windows must read USERPROFILE and never consult HOME (Node os.homedir() parity)" + ); + } } diff --git a/crates/freshell-sessions/src/resume_input.rs b/crates/freshell-sessions/src/resume_input.rs new file mode 100644 index 000000000..6aba0e534 --- /dev/null +++ b/crates/freshell-sessions/src/resume_input.rs @@ -0,0 +1,294 @@ +//! Rust port of `shared/resume-input-parser.ts` — a pure, dependency-free +//! parser that extracts candidate session ids and an advisory provider hint +//! from arbitrary pasted text. Hints only assist the UI — session-store +//! evidence decides the provider. +//! +//! PARITY-PINNED: both this port and the TS original are driven by the shared +//! fixture `test/fixtures/resume-input/parser-cases.json` +//! (`tests/resume_input_parser_parity.rs` here, +//! `test/unit/shared/resume-input-parser.test.ts` there). Behavior changes go +//! through the fixture first. +//! +//! Port notes (things that look odd but are load-bearing): +//! - `(?-u:\b)` everywhere a JS `\b` appears: JS word boundaries are ASCII +//! (`[A-Za-z0-9_]`); Rust's default `\b` is Unicode-aware and would diverge +//! on inputs like `é417e8345`. +//! - The ANSI CSI strip replaces each escape with ONE space (length-changing); +//! hint derivation reads that `sanitized` text, so earliest-match indices +//! shift with it. Do not "fix" this to a length-preserving mask. +//! - Extraction masks each match with `' '.repeat(len)` (length-preserving) +//! so UUID hex groups never re-match as hex prefixes. All matched chars are +//! ASCII, so byte length == char length. +//! - Hex tokens sort by length DESC with a STABLE sort (JS `Array.sort` is +//! stable): equal-length tokens keep extraction (text) order. + +use std::collections::HashSet; +use std::sync::LazyLock; + +use regex::Regex; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum ResumeCandidateKind { + #[serde(rename = "prefixed-id")] + PrefixedId, + #[serde(rename = "uuid")] + Uuid, + #[serde(rename = "hex-prefix")] + HexPrefix, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct ResumeCandidate { + pub token: String, + pub kind: ResumeCandidateKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ResumeHintProvider { + Claude, + Codex, + Opencode, + Amplifier, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +pub enum ResumeHintSource { + #[serde(rename = "command")] + Command, + #[serde(rename = "word")] + Word, + #[serde(rename = "id-shape")] + IdShape, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct ResumeHint { + pub provider: ResumeHintProvider, + pub source: ResumeHintSource, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResumeInputParse { + /// Candidate tokens in resolution-priority order, capped at + /// [`MAX_RESUME_CANDIDATES`]. + pub candidates: Vec, + pub hint: Option, +} + +/// Work budget: candidates are capped so one pasted blob can never trigger +/// unbounded server-side scans/DB lookups in the resolve endpoint. +/// (`MAX_RESUME_CANDIDATES`, `shared/resume-input-parser.ts`.) +pub const MAX_RESUME_CANDIDATES: usize = 8; + +static ANSI_ESCAPE_RE: LazyLock = + LazyLock::new(|| Regex::new(r"\x1b\[[0-9;?]*[0-9A-Za-z]").expect("static regex")); +static UUID_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") + .expect("static regex") +}); +// Known xxx_-prefixed id families only (ses_ + 26 base62 is opencode's, +// first-class). Arbitrary snake_case identifiers must NOT match: they would +// rank FIRST and waste resolver passes on non-ids. +static PREFIXED_ID_RE: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?-u:\b)(?:ses|sess|session|thread|thr|run|msg|task|amp)_[0-9A-Za-z]{8,64}(?-u:\b)", + ) + .expect("static regex") +}); +// >=8 hex chars, <=32; must contain a digit (filters decade/facade/deadbeef). +static HEX_PREFIX_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?-u:\b)[0-9a-fA-F]{8,32}(?-u:\b)").expect("static regex")); + +static COMMAND_HINTS: LazyLock> = LazyLock::new(|| { + vec![ + ( + Regex::new(r"(?i)(?-u:\b)claude\s+(?:--resume|-r)(?-u:\b)").expect("static regex"), + ResumeHintProvider::Claude, + ), + ( + Regex::new(r"(?i)(?-u:\b)codex\s+resume(?-u:\b)").expect("static regex"), + ResumeHintProvider::Codex, + ), + ( + Regex::new(r"(?i)(?-u:\b)opencode\s+--session(?-u:\b)").expect("static regex"), + ResumeHintProvider::Opencode, + ), + ( + Regex::new(r"(?i)(?-u:\b)amplifier\s+(?:--resume|resume)(?-u:\b)") + .expect("static regex"), + ResumeHintProvider::Amplifier, + ), + ] +}); + +static WORD_HINTS: LazyLock> = LazyLock::new(|| { + vec![ + ( + Regex::new(r"(?i)(?-u:\b)claude(?-u:\b)").expect("static regex"), + ResumeHintProvider::Claude, + ), + ( + Regex::new(r"(?i)(?-u:\b)codex(?-u:\b)").expect("static regex"), + ResumeHintProvider::Codex, + ), + ( + Regex::new(r"(?i)(?-u:\b)opencode(?-u:\b)").expect("static regex"), + ResumeHintProvider::Opencode, + ), + ( + Regex::new(r"(?i)(?-u:\b)amplifier(?-u:\b)").expect("static regex"), + ResumeHintProvider::Amplifier, + ), + ] +}); + +/// `extractAndMask`: push every match, replace it with a same-length run of +/// spaces so later passes cannot re-match inside it. +fn extract_and_mask(text: &str, re: &Regex, out: &mut Vec) -> String { + re.replace_all(text, |caps: ®ex::Captures<'_>| { + let m = caps.get(0).expect("group 0 always present").as_str(); + out.push(m.to_string()); + " ".repeat(m.len()) + }) + .into_owned() +} + +/// `earliestHint`: run every regex, keep the provider with the smallest match +/// start. Ties break by table order (strict `<`, first entry wins) — same as +/// the TS original. Byte offsets vs UTF-16 offsets order matches identically +/// (the mapping is monotonic). +fn earliest_hint(text: &str, table: &[(Regex, ResumeHintProvider)]) -> Option { + let mut best: Option = None; + let mut best_index = usize::MAX; + for (re, provider) in table { + if let Some(m) = re.find(text) { + if m.start() < best_index { + best_index = m.start(); + best = Some(*provider); + } + } + } + best +} + +fn derive_hint(text: &str, candidates: &[ResumeCandidate]) -> Option { + if let Some(provider) = earliest_hint(text, &COMMAND_HINTS) { + return Some(ResumeHint { + provider, + source: ResumeHintSource::Command, + }); + } + if let Some(provider) = earliest_hint(text, &WORD_HINTS) { + return Some(ResumeHint { + provider, + source: ResumeHintSource::Word, + }); + } + let top = candidates.first()?; + match top.kind { + ResumeCandidateKind::PrefixedId => { + if top.token.starts_with("ses_") { + Some(ResumeHint { + provider: ResumeHintProvider::Opencode, + source: ResumeHintSource::IdShape, + }) + } else { + None + } + } + // charAt(14) is the uuid version nibble (0-based). Real-store caveat: + // amplifier TOP-LEVEL session ids are also UUIDv4, so v4 => claude is + // a heuristic, not an invariant — acceptable because hints are + // advisory only. + ResumeCandidateKind::Uuid => match top.token.as_bytes().get(14) { + Some(b'7') => Some(ResumeHint { + provider: ResumeHintProvider::Codex, + source: ResumeHintSource::IdShape, + }), + Some(b'4') => Some(ResumeHint { + provider: ResumeHintProvider::Claude, + source: ResumeHintSource::IdShape, + }), + _ => None, + }, + ResumeCandidateKind::HexPrefix => Some(ResumeHint { + provider: ResumeHintProvider::Amplifier, + source: ResumeHintSource::IdShape, + }), + } +} + +fn push_candidate( + token: &str, + kind: ResumeCandidateKind, + seen: &mut HashSet, + out: &mut Vec, +) { + // Dedup key: prefixed ids verbatim (case-sensitive); uuid/hex lowercased. + // All token classes are ASCII by construction, so to_ascii_lowercase() + // is equivalent to JS toLowerCase() here. + let key = match kind { + ResumeCandidateKind::PrefixedId => token.to_string(), + _ => token.to_ascii_lowercase(), + }; + if !seen.insert(key) { + return; + } + out.push(ResumeCandidate { + token: token.to_string(), + kind, + }); +} + +pub fn parse_resume_input(text: &str) -> ResumeInputParse { + // Each CSI escape collapses to ONE space (length-changing, matches TS). + let sanitized = ANSI_ESCAPE_RE.replace_all(text, " ").into_owned(); + + let mut uuids: Vec = Vec::new(); + let mut prefixed: Vec = Vec::new(); + let mut raw_hex: Vec = Vec::new(); + + // Mask each class as it is extracted so uuid segments never re-match as hex. + let masked = extract_and_mask(&sanitized, &UUID_RE, &mut uuids); + let masked = extract_and_mask(&masked, &PREFIXED_ID_RE, &mut prefixed); + extract_and_mask(&masked, &HEX_PREFIX_RE, &mut raw_hex); + + let mut hex_tokens: Vec = raw_hex + .into_iter() + .filter(|token| token.bytes().any(|b| b.is_ascii_digit())) + .collect(); + // STABLE sort (like JS Array.sort): equal lengths keep text order. + // NOTE: sort_by_key(Reverse(len)) — not sort_by(|a, b| b.len().cmp(&a.len())), + // which trips clippy's warn-by-default `unnecessary_sort_by` under the + // -D warnings gate. Vec::sort_by_key is equally stable; behavior identical. + hex_tokens.sort_by_key(|t| std::cmp::Reverse(t.len())); + + let mut seen: HashSet = HashSet::new(); + let mut candidates: Vec = Vec::new(); + for token in &prefixed { + push_candidate( + token, + ResumeCandidateKind::PrefixedId, + &mut seen, + &mut candidates, + ); + } + for token in &uuids { + push_candidate(token, ResumeCandidateKind::Uuid, &mut seen, &mut candidates); + } + for token in &hex_tokens { + push_candidate( + token, + ResumeCandidateKind::HexPrefix, + &mut seen, + &mut candidates, + ); + } + + // Cap = work budget: bounds resolver scans + exact-id fallback lookups per + // request. The hint reads the CAPPED list (mirrors the TS call shape). + candidates.truncate(MAX_RESUME_CANDIDATES); + let hint = derive_hint(&sanitized, &candidates); + ResumeInputParse { candidates, hint } +} diff --git a/crates/freshell-sessions/src/resume_resolve.rs b/crates/freshell-sessions/src/resume_resolve.rs new file mode 100644 index 000000000..38381f460 --- /dev/null +++ b/crates/freshell-sessions/src/resume_resolve.rs @@ -0,0 +1,443 @@ +//! Rust port of the HARDENED (#586) `server/coding-cli/resolve-session.ts` + +//! the shape-gate/budget logic of `resolve-fallbacks.ts` — the resume-by-id +//! resolve core. Pure and synchronous: the HTTP layer +//! (`crates/freshell-server/src/resolve.rs`) supplies the index snapshot, the +//! sessionType overlay map, and the two exact-id fallback closures, then +//! merges router-level fields (scan failures, unsearchedProviders, homeDir) +//! and serializes. +//! +//! DIVERGENCE LEDGER — this is the in-code record a follow-up implementer +//! relies on (history in +//! `docs/plans/2026-07-30-rust-resolve-parity-hardened.md`). The +//! `sessionResolve` capability flag is declared `true` +//! (`crates/freshell-server/src/main.rs`, `build_platform_payload`). The +//! CORE below is at parity with the hardened Node core (matching order, case +//! rules, subagent gating, sessionType overlay+default, provider-error +//! channel, shape gates, budgets). The wire surface +//! (`providerErrors`/`unsearchedProviders`/`homeDir`, scan-failure merge, +//! degraded fire-and-forget refresh) and the failure-REPORTING production +//! fallbacks (checked claude locator `locate_transcript_checked`, +//! error-propagating opencode by-id query) landed in plan Task 6 +//! (`resolve.rs` + `main.rs`), and the resume-button e2e matrix ran green +//! against the route (plan Task 7). No known unported divergences remain +//! beyond the RECORDED DEVIATIONS documented in the HTTP layer's module doc +//! (`resolve.rs`): an explicit 500 on a resolver panic (Node has no defined +//! behavior there), and `homeDir` omitted when the server has no resolvable +//! home (Node `os.homedir()` platform semantics: USERPROFILE on Windows; +//! HOME else the passwd-entry home on POSIX). +//! +//! Wire parity notes: +//! - Field ORDER in `ResumeResolveMatch` matches the Node object literals — +//! `serde_json` `preserve_order` + struct field order drive output order. +//! - Optional match fields are OMITTED when `None` (Node drops `undefined`); +//! `hint` is `null` when absent (zod `.nullable()`), so NOT skip-serialized +//! by the HTTP layer. +//! - Per-token resolution order (resolve-session.ts:56-70): exact index hits +//! (ALL sessions, subagents included) → exact-id fallbacks → prefix +//! discovery (top-level only). A prefix match must NEVER outrank any exact +//! resolution of the same or a higher-priority token. +//! - UUID/hex-family tokens (hex digits + dashes only) match +//! case-INSENSITIVELY; everything else — notably ses_ base62 ids — matches +//! case-SENSITIVELY (base62 case-folding could resolve the WRONG session). +//! - Provider failure ≠ not found: a failing fallback records a per-provider +//! error and the result becomes `degraded` — never a silent empty miss. + +use std::collections::{HashMap, HashSet}; +use std::sync::LazyLock; + +use regex::Regex; + +use crate::directory_index::IndexedSession; +use crate::resume_input::{parse_resume_input, ResumeHint}; + +/// `RESOLVE_MATCH_CAP` (`resolve-session.ts:12`). +pub const RESOLVE_MATCH_CAP: usize = 20; + +/// `FALLBACK_BUDGET_PER_REQUEST` (`resolve-fallbacks.ts:34`): each fallback +/// may do REAL work at most this many times per request; beyond that it +/// reports a miss without doing work. Shape gates run FIRST and consume no +/// budget (`resolve-fallbacks.ts:46-48` — order is load-bearing). +pub const FALLBACK_BUDGET_PER_REQUEST: usize = 2; + +/// `FALLBACK_ID_SHAPES` (`resolve-fallbacks.ts:22-25`): FULL-id gates. A +/// wrong-shape token is a free no-op miss that must neither do work nor +/// consume budget (otherwise earlier `ses_` tokens could exhaust the claude +/// budget before a valid later claude UUID — false negative). The opencode +/// gate is load-bearing on legacy-schema DBs, where the by-id lookup answers +/// a universal HIT for any id it is actually asked about. +static CLAUDE_FALLBACK_ID_SHAPE: LazyLock = LazyLock::new(|| { + Regex::new(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + .expect("static regex") +}); +static OPENCODE_FALLBACK_ID_SHAPE: LazyLock = + LazyLock::new(|| Regex::new(r"^ses_[0-9a-zA-Z]{26}$").expect("static regex")); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ResumeResolveStatus { + Ready, + Warming, + Degraded, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ResumeMatchKind { + Exact, + Prefix, +} + +/// One resolve match (`ResumeResolveMatchSchema`). Field order = Node's +/// `toMatch` / fallback literals. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResumeResolveMatch { + pub provider: String, + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub first_user_message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_activity_at: Option, + pub match_kind: ResumeMatchKind, +} + +/// `ResumeResolveProviderErrorSchema`: a provider that could not be searched +/// is 'degraded' — NEVER "not found". Node builds `{provider, ...code, message}`. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResumeResolveProviderError { + pub provider: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +/// A fallback failure as reported by the closure (the Rust analog of a Node +/// fallback rejection; typed locator errors carry an errno-ish `code`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderFailure { + pub code: Option, + pub message: String, +} + +/// The claude transcript fallback's answer. `session_id` is the LOWERCASED +/// id (the locator lowercases before scanning, Node parity). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaudeTranscriptHit { + pub session_id: String, + pub cwd: Option, +} + +/// The opencode by-id fallback's answer (hardened Node: the full sqlite row +/// from `opencode-by-id-query.ts`, archived + child sessions included). +/// `last_activity_at` is already floored to integer ms by the producer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpencodeByIdHit { + pub session_id: String, + pub cwd: Option, + pub title: Option, + pub last_activity_at: Option, +} + +/// Dependencies for one resolve call (`ResolveResumeDeps`). Fallbacks return +/// `Err(ProviderFailure)` when the provider store could not be searched — +/// the core records it and continues (provider unavailable ≠ not found). +pub struct ResolveDeps<'a> { + /// Deleted-filtered index snapshot (Node reads the post-filter project + /// groups, `session-indexer.ts:209,1155-1156`; the Rust HTTP layer drops + /// `deleted: true` overrides before calling in — see `resolve.rs`). + /// `None` = never published ⇒ warming. + pub sessions: Option<&'a [IndexedSession]>, + /// sessionType overlay keyed `"{provider}:{session_id}"` (Node: + /// `session-indexer.ts:1159-1161` overlays the SessionMetadataStore). + pub session_types: &'a HashMap, + /// claude transcript exact-id fallback (`locateClaudeTranscript`). + #[allow(clippy::type_complexity)] + pub locate_claude_transcript: Option< + &'a (dyn Fn(&str) -> Result, ProviderFailure> + Send + Sync), + >, + /// opencode `ses_*` exact-id fallback (hardened by-id row query). + #[allow(clippy::type_complexity)] + pub opencode_session_by_id: Option< + &'a (dyn Fn(&str) -> Result, ProviderFailure> + Send + Sync), + >, +} + +/// Core result (`ResolveResumeResult` in `resolve-session.ts:31-36`). +/// `provider_errors` carries FALLBACK failures only; the HTTP layer +/// (`crates/freshell-server/src/resolve.rs`) merges in index scan failures +/// and adds `unsearchedProviders`/`homeDir`. +#[derive(Debug, Clone, PartialEq)] +pub struct ResumeResolveOutcome { + pub status: ResumeResolveStatus, + pub matches: Vec, + pub hint: Option, + pub provider_errors: Vec, +} + +/// `isCaseInsensitiveToken` (`resolve-session.ts:51-53`): UUID/hex-family +/// tokens (hex digits + dashes only) match case-insensitively. Everything +/// else — notably `ses_` + base62 ids — matches case-SENSITIVELY: base62 +/// upper/lower case are distinct values, so case-folding could resolve the +/// WRONG session. +fn is_case_insensitive_token(token: &str) -> bool { + !token.is_empty() && token.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-') +} + +/// `resolveResumeInput` (`resolve-session.ts:72-170`), step for step. +/// Candidate tokens are tried in priority order; PER TOKEN the resolution +/// order is: +/// +/// 1. exact index hits (ALL sessions, including subagent children — an +/// exact pasted id must resolve even for hidden child sessions), +/// 2. exact-id fallbacks for sessions the index cannot see (opencode child +/// sessions; cwd-less claude transcripts skipped on cold start), +/// 3. and only then prefix matches (top-level sessions only — surfacing +/// hidden subagent children for partial ids would flood disambiguation +/// with noise). +/// +/// A prefix match must NEVER outrank any exact resolution of the same or a +/// higher-priority token: an unindexed session whose id EQUALS the token +/// beats any indexed session whose id merely begins with it, or the wrong +/// session gets resumed. +pub fn resolve_resume_input(input: &str, deps: &ResolveDeps<'_>) -> ResumeResolveOutcome { + // Parse BEFORE the warming gate: the warming response still carries the hint. + let parsed = parse_resume_input(input); + let hint = parsed.hint; + + let Some(sessions) = deps.sessions else { + return ResumeResolveOutcome { + status: ResumeResolveStatus::Warming, + matches: Vec::new(), + hint, + provider_errors: Vec::new(), + }; + }; + if parsed.candidates.is_empty() { + return ResumeResolveOutcome { + status: ResumeResolveStatus::Ready, + matches: Vec::new(), + hint, + provider_errors: Vec::new(), + }; + } + + // First-error-per-provider, insertion order (Node's Map semantics). + // Provider failure ≠ not found: a failing fallback records a per-provider + // error summary while resolution CONTINUES (prefix/later tokens). Any + // entry here makes the result 'degraded' — even with matches. + let mut errors: Vec = Vec::new(); + // Per-REQUEST budgets (`withRequestBudget` wraps once, before the loop): + // ONE counter PER FALLBACK — two opencode lookups must never exhaust the + // claude budget, or vice versa. + let mut claude_used = 0usize; + let mut opencode_used = 0usize; + + for candidate in &parsed.candidates { + let ci = is_case_insensitive_token(&candidate.token); + let norm = |value: &str| { + if ci { + value.to_ascii_lowercase() + } else { + value.to_string() + } + }; + let target = norm(&candidate.token); + + // 1. Exact index hits — scan ALL sessions, subagent children included. + let exact: Vec = sessions + .iter() + .filter(|session| norm(&session.session_id) == target) + .map(|session| to_match(session, ResumeMatchKind::Exact, deps.session_types)) + .collect(); + if !exact.is_empty() { + return finish(exact, hint, errors); + } + + // 2. Exact-id fallbacks BEFORE prefix matching. Shape FIRST, budget + // SECOND (wrong-shape tokens are free no-ops); iterated claude-then- + // opencode (Node's entry order) so a failure is attributed to the + // RIGHT provider — identity travels with the entry, never its + // position. The budget is consumed by the real invocation itself, + // hit or miss. + let mut hits: Vec = Vec::new(); + if let Some(locate) = deps.locate_claude_transcript { + if CLAUDE_FALLBACK_ID_SHAPE.is_match(&candidate.token) + && claude_used < FALLBACK_BUDGET_PER_REQUEST + { + claude_used += 1; + match locate(&candidate.token) { + Ok(Some(hit)) => hits.push(ResumeResolveMatch { + provider: "claude".to_string(), + session_id: hit.session_id.clone(), + // cwd may legitimately be missing — the CLIENT then + // asks for a working directory instead of auto-opening. + cwd: hit.cwd, + session_type: Some(overlay_or( + deps.session_types, + "claude", + &hit.session_id, + )), + title: None, + first_user_message: None, + last_activity_at: None, + match_kind: ResumeMatchKind::Exact, + }), + Ok(None) => {} + Err(failure) => record_error("claude", failure, &mut errors), + } + } + } + if let Some(lookup) = deps.opencode_session_by_id { + if OPENCODE_FALLBACK_ID_SHAPE.is_match(&candidate.token) + && opencode_used < FALLBACK_BUDGET_PER_REQUEST + { + opencode_used += 1; + match lookup(&candidate.token) { + Ok(Some(hit)) => hits.push(ResumeResolveMatch { + provider: "opencode".to_string(), + session_id: hit.session_id.clone(), + // opencode resumes in the SPAWN cwd (the row's own + // `directory`); empty ⇒ omitted (Node `row.cwd || undefined`). + cwd: hit.cwd.filter(|c| !c.is_empty()), + session_type: Some(overlay_or( + deps.session_types, + "opencode", + &hit.session_id, + )), + title: hit.title.filter(|t| !t.is_empty()), + first_user_message: None, + last_activity_at: hit.last_activity_at, + match_kind: ResumeMatchKind::Exact, + }), + Ok(None) => {} + Err(failure) => record_error("opencode", failure, &mut errors), + } + } + } + if !hits.is_empty() { + return finish(hits, hint, errors); + } + + // 3. Prefix DISCOVERY — top-level sessions only; exact ids above + // still reach subagent children. + let prefix: Vec = sessions + .iter() + .filter(|session| { + !session.is_subagent && norm(&session.session_id).starts_with(&target) + }) + .map(|session| to_match(session, ResumeMatchKind::Prefix, deps.session_types)) + .collect(); + if !prefix.is_empty() { + return finish(prefix, hint, errors); + } + } + + finish(Vec::new(), hint, errors) +} + +/// Node's `finish` closure (`resolve-session.ts:100-109`): sort most-recent +/// first (stable, like JS; missing lastActivityAt sorts as 0), dedupe keeping +/// the survivor with the most recent activity, cap, and derive degraded-ness +/// from recorded errors. +fn finish( + mut matches: Vec, + hint: Option, + errors: Vec, +) -> ResumeResolveOutcome { + matches.sort_by(|a, b| { + b.last_activity_at + .unwrap_or(0) + .cmp(&a.last_activity_at.unwrap_or(0)) + }); + let matches: Vec = dedupe(matches) + .into_iter() + .take(RESOLVE_MATCH_CAP) + .collect(); + ResumeResolveOutcome { + status: if errors.is_empty() { + ResumeResolveStatus::Ready + } else { + // Even with matches: a failed HIGHER-priority exact search may + // have hidden the right session — the client must not auto-resume. + ResumeResolveStatus::Degraded + }, + matches, + hint, + provider_errors: errors, + } +} + +/// First error per provider wins (Node: `if (!errorsByProvider.has(provider))`). +fn record_error( + provider: &str, + failure: ProviderFailure, + errors: &mut Vec, +) { + if errors.iter().any(|e| e.provider == provider) { + return; + } + errors.push(ResumeResolveProviderError { + provider: provider.to_string(), + code: failure.code, + message: Some(failure.message), + }); +} + +/// sessionType resolution shared by index and fallback matches: overlay map +/// (keyed `"{provider}:{id}"`, `IndexedSession::key()`'s format) → +/// provider-name default (`toMatch`'s `session.sessionType ?? session.provider` +/// and `resolve-fallbacks.ts`'s `sessionTypeFor`). +fn overlay_or(session_types: &HashMap, provider: &str, id: &str) -> String { + session_types + .get(&format!("{provider}:{id}")) + .cloned() + .unwrap_or_else(|| provider.to_string()) +} + +/// `toMatch` (`resolve-session.ts:172-183`): `cwd: session.cwd ?? projectPath`; +/// `sessionType` is the metadata-map overlay when present, defaulting to the +/// provider — the hardened Node `toMatch` emits `sessionType ?? provider`, +/// never absent. +fn to_match( + session: &IndexedSession, + match_kind: ResumeMatchKind, + session_types: &HashMap, +) -> ResumeResolveMatch { + ResumeResolveMatch { + provider: session.provider.clone(), + session_id: session.session_id.clone(), + cwd: Some( + session + .cwd + .clone() + .unwrap_or_else(|| session.project_path.clone()), + ), + session_type: Some(overlay_or( + session_types, + &session.provider, + &session.session_id, + )), + title: session.title.clone(), + first_user_message: session.first_user_message.clone(), + last_activity_at: Some(session.last_activity_at), + match_kind, + } +} + +/// `dedupe` (`resolve-session.ts:189-197`): first `provider:sessionId` wins — +/// which, post-sort, is the most recent entry. +fn dedupe(matches: Vec) -> Vec { + let mut seen: HashSet = HashSet::new(); + matches + .into_iter() + .filter(|m| seen.insert(format!("{}:{}", m.provider, m.session_id))) + .collect() +} diff --git a/crates/freshell-sessions/tests/opencode_row_by_id.rs b/crates/freshell-sessions/tests/opencode_row_by_id.rs new file mode 100644 index 000000000..940c11105 --- /dev/null +++ b/crates/freshell-sessions/tests/opencode_row_by_id.rs @@ -0,0 +1,275 @@ +//! Hardened (#586) opencode exact-id lookup parity: mirrors +//! `server/coding-cli/providers/opencode-by-id-query.ts` — a DIRECT by-id +//! row query. Unlike the #583 parent-walk it includes ARCHIVED and CHILD +//! sessions, returns the full row (title/timestamps), and PROPAGATES read +//! errors (provider unavailable ≠ not found). + +use freshell_sessions::parse::{opencode_session_row_by_id, OpencodeByIdRow}; + +fn temp_data_home(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "freshell-row-by-id-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("mkdir temp data home"); + dir +} + +/// The modern opencode schema the by-id query reads: `session` with title + +/// timestamps, plus the `project` table its LEFT JOIN pulls `worktree` from. +fn create_db(data_home: &std::path::Path) -> rusqlite::Connection { + let conn = rusqlite::Connection::open(data_home.join("opencode.db")).expect("open fixture db"); + conn.execute_batch( + "CREATE TABLE session ( + id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT, + title TEXT, project_id TEXT, time_created INTEGER, + time_updated INTEGER, time_archived INTEGER + ); + CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT);", + ) + .expect("create schema"); + conn +} + +#[allow(clippy::too_many_arguments)] +fn insert_session( + conn: &rusqlite::Connection, + id: &str, + parent_id: Option<&str>, + directory: Option<&str>, + title: Option<&str>, + project_id: Option<&str>, + time_updated: Option, + time_archived: Option, +) { + conn.execute( + "INSERT INTO session (id, parent_id, directory, title, project_id, time_updated, time_archived) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![id, parent_id, directory, title, project_id, time_updated, time_archived], + ) + .expect("insert session row"); +} + +fn insert_project(conn: &rusqlite::Connection, id: &str, worktree: &str) { + conn.execute( + "INSERT INTO project (id, worktree) VALUES (?1, ?2)", + rusqlite::params![id, worktree], + ) + .expect("insert project row"); +} + +#[test] +fn resolves_a_root_row_with_full_metadata() { + let home = temp_data_home("root"); + let conn = create_db(&home); + insert_project(&conn, "prj_beta", "/repo"); + insert_session( + &conn, + "ses_beta0000000000000000000000", + None, + Some("/repo/beta"), + Some("beta"), + Some("prj_beta"), + Some(1234), + None, + ); + let row = + opencode_session_row_by_id(&home, "ses_beta0000000000000000000000").expect("query ok"); + assert_eq!( + row, + Some(OpencodeByIdRow { + session_id: "ses_beta0000000000000000000000".to_string(), + cwd: Some("/repo/beta".to_string()), + title: Some("beta".to_string()), + created_at: None, + last_activity_at: Some(1234), + project_path: Some("/repo".to_string()), + }) + ); +} + +#[test] +fn resolves_a_child_row_the_listing_hides() { + let home = temp_data_home("child"); + let conn = create_db(&home); + insert_session( + &conn, + "ses_root0000000000000000000000", + None, + Some("/repo/root"), + None, + None, + None, + None, + ); + insert_session( + &conn, + "ses_child000000000000000000000", + Some("ses_root0000000000000000000000"), + Some("/repo/child"), + None, + None, + None, + None, + ); + // Direct row query — the CHILD id resolves with its OWN row, NO parent + // walk (Node's query has no parent_id filter and never chases the chain). + let row = + opencode_session_row_by_id(&home, "ses_child000000000000000000000").expect("query ok"); + let row = row.expect("child row resolves"); + assert_eq!(row.session_id, "ses_child000000000000000000000"); + assert_eq!(row.cwd, Some("/repo/child".to_string())); +} + +#[test] +fn resolves_an_archived_row() { + let home = temp_data_home("archived"); + let conn = create_db(&home); + insert_session( + &conn, + "ses_arch0000000000000000000000", + None, + Some("/repo/old"), + None, + None, + None, + Some(123), + ); + // time_archived NOT NULL still resolves: the query matches the attach + // arm, which has no archived filter. + let row = + opencode_session_row_by_id(&home, "ses_arch0000000000000000000000").expect("query ok"); + let row = row.expect("archived row resolves"); + assert_eq!(row.cwd, Some("/repo/old".to_string())); +} + +#[test] +fn missing_row_is_ok_none() { + let home = temp_data_home("missing"); + let _conn = create_db(&home); + let row = + opencode_session_row_by_id(&home, "ses_missing0000000000000000000").expect("query ok"); + assert_eq!(row, None); +} + +#[test] +fn db_without_a_session_table_is_ok_none() { + let home = temp_data_home("nosessiontable"); + let conn = rusqlite::Connection::open(home.join("opencode.db")).expect("open fixture db"); + conn.execute_batch("CREATE TABLE unrelated (id TEXT PRIMARY KEY);") + .expect("create unrelated table"); + // Node: `if (!tableNames.has('session')) return null`. + let row = + opencode_session_row_by_id(&home, "ses_any00000000000000000000000").expect("query ok"); + assert_eq!(row, None); +} + +#[test] +fn db_without_a_project_table_still_resolves_with_null_project_path() { + let home = temp_data_home("noproject"); + let conn = rusqlite::Connection::open(home.join("opencode.db")).expect("open fixture db"); + conn.execute_batch( + "CREATE TABLE session ( + id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT, + title TEXT, project_id TEXT, time_created INTEGER, + time_updated INTEGER, time_archived INTEGER + );", + ) + .expect("create session-only schema"); + insert_session( + &conn, + "ses_solo0000000000000000000000", + None, + Some("/repo/solo"), + None, + None, + None, + None, + ); + // Node: `projectSelect = 'NULL'`, no JOIN — the row still resolves. + let row = + opencode_session_row_by_id(&home, "ses_solo0000000000000000000000").expect("query ok"); + let row = row.expect("row resolves without a project table"); + assert_eq!(row.project_path, None); + assert_eq!(row.cwd, Some("/repo/solo".to_string())); +} + +#[test] +fn missing_db_file_is_an_error_not_a_silent_miss() { + let home = temp_data_home("nodb"); + // Node's DatabaseSync open throws SQLITE_CANTOPEN: the provider is + // present-but-unreadable, and silence here is the incident class. The + // code is INTERNAL — kept for structured logs and message fidelity; the + // wire deliberately omits it for opencode (Node's worker boundary strips + // `.code` before the wire — see Task 6). + let err = opencode_session_row_by_id(&home, "ses_any00000000000000000000000") + .expect_err("missing db file must be an error"); + assert_eq!(err.code.as_deref(), Some("SQLITE_CANTOPEN")); +} + +#[test] +fn corrupt_db_file_is_an_error() { + let home = temp_data_home("corrupt"); + std::fs::write(home.join("opencode.db"), [0xABu8; 64]).expect("write garbage"); + let err = opencode_session_row_by_id(&home, "ses_any00000000000000000000000") + .expect_err("corrupt db file must be an error"); + assert_eq!(err.code.as_deref(), Some("SQLITE_NOTADB")); +} + +#[test] +fn locked_db_is_an_error_after_the_busy_timeout() { + // REAL contention proof for the load-bearing 500 ms busy timeout: a + // second connection holds `BEGIN EXCLUSIVE` so the read-only open cannot + // acquire the shared lock; the busy error surfaces as OpencodeByIdError + // once the timeout expires. + let home = temp_data_home("locked"); + let conn = create_db(&home); + insert_session( + &conn, + "ses_lock0000000000000000000000", + None, + Some("/repo/lock"), + None, + None, + None, + None, + ); + drop(conn); + let writer = rusqlite::Connection::open(home.join("opencode.db")).expect("open writer"); + writer + .execute_batch("BEGIN EXCLUSIVE") + .expect("acquire exclusive lock"); + + let started = std::time::Instant::now(); + let err = opencode_session_row_by_id(&home, "ses_lock0000000000000000000000") + .expect_err("locked db must be an error"); + let elapsed = started.elapsed(); + assert_eq!(err.code.as_deref(), Some("SQLITE_BUSY")); + // The timeout, not an instant failure: the busy handler retried for + // ~500 ms before giving up. + assert!( + elapsed >= std::time::Duration::from_millis(400), + "expected the ~500 ms busy timeout to elapse, took {elapsed:?}" + ); + + writer.execute_batch("ROLLBACK").expect("release lock"); +} + +#[test] +fn real_time_updated_is_floored_to_integer_ms() { + let home = temp_data_home("realms"); + let conn = create_db(&home); + conn.execute( + "INSERT INTO session (id, time_updated) VALUES (?1, ?2)", + rusqlite::params!["ses_real0000000000000000000000", 1234.9_f64], + ) + .expect("insert REAL time_updated"); + let row = + opencode_session_row_by_id(&home, "ses_real0000000000000000000000").expect("query ok"); + let row = row.expect("row resolves"); + assert_eq!(row.last_activity_at, Some(1234)); +} diff --git a/crates/freshell-sessions/tests/resume_input_parser_parity.rs b/crates/freshell-sessions/tests/resume_input_parser_parity.rs new file mode 100644 index 000000000..66bf0c4a5 --- /dev/null +++ b/crates/freshell-sessions/tests/resume_input_parser_parity.rs @@ -0,0 +1,47 @@ +//! SYNC-06 cross-language parser parity: the SAME fixture table that pins +//! `shared/resume-input-parser.ts` (via `test/unit/shared/resume-input-parser.test.ts`) +//! pins this port. If either implementation changes behavior, exactly one of +//! the two suites goes red — silent drift is impossible. + +use freshell_sessions::resume_input::parse_resume_input; + +#[derive(serde::Deserialize)] +struct Fixture { + cases: Vec, +} + +#[derive(serde::Deserialize)] +struct Case { + name: String, + input: String, + candidates: serde_json::Value, + hint: serde_json::Value, +} + +#[test] +fn parser_matches_every_shared_fixture_case() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test/fixtures/resume-input/parser-cases.json"); + let raw = std::fs::read_to_string(&path).expect("read shared parser fixture"); + let fixture: Fixture = serde_json::from_str(&raw).expect("parse fixture json"); + assert!( + fixture.cases.len() >= 32, + "shared fixture unexpectedly small: {}", + fixture.cases.len() + ); + for case in &fixture.cases { + let parsed = parse_resume_input(&case.input); + let candidates = serde_json::to_value(&parsed.candidates).expect("serialize candidates"); + let hint = serde_json::to_value(&parsed.hint).expect("serialize hint"); + assert_eq!( + candidates, case.candidates, + "candidates mismatch for case '{}' (input {:?})", + case.name, case.input + ); + assert_eq!( + hint, case.hint, + "hint mismatch for case '{}' (input {:?})", + case.name, case.input + ); + } +} diff --git a/crates/freshell-sessions/tests/resume_resolve.rs b/crates/freshell-sessions/tests/resume_resolve.rs new file mode 100644 index 000000000..63e12141a --- /dev/null +++ b/crates/freshell-sessions/tests/resume_resolve.rs @@ -0,0 +1,995 @@ +//! SYNC-06 logic-parity mirror of the HARDENED Node core suite +//! `test/unit/server/coding-cli/resolve-session.test.ts` (post-#586), +//! test-for-test. The HTTP wire (auth/validation/router merge) is pinned in +//! `crates/freshell-server/src/resolve.rs`. +//! +//! Layout: +//! 1. The 23-test mirror, in Node file order. +//! 2. Rust-only shape-gate/budget pins with no Node twin in the core suite. +//! 3. Supplementary pins beyond the Node core suite (resolve-fallbacks.test.ts +//! parity, wire-shape pins, richer variants) carried over from the +//! pre-hardening Rust suite so coverage never shrinks. + +use std::collections::HashMap; + +use freshell_sessions::directory_index::IndexedSession; +use freshell_sessions::resume_input::{ResumeHint, ResumeHintProvider, ResumeHintSource}; +use freshell_sessions::resume_resolve::{ + resolve_resume_input, ClaudeTranscriptHit, OpencodeByIdHit, ProviderFailure, ResolveDeps, + ResumeMatchKind, ResumeResolveOutcome, ResumeResolveProviderError, ResumeResolveStatus, + RESOLVE_MATCH_CAP, +}; + +const CLAUDE_ID: &str = "ed2afda6-a340-443e-ba60-024a1b3554b4"; +const OTHER_UUID: &str = "aaaaaaaa-1111-4222-8333-444444444444"; +const SES_ID: &str = "ses_root0000000000000000000000"; +const CODEX_ID: &str = "019fac27-69d7-78a0-b972-b339d551042e"; +const AMPLIFIER_FULL: &str = "417e8345-90ab-4cde-8f01-234567890abc"; + +fn session(provider: &str, id: &str, last: i64) -> IndexedSession { + IndexedSession { + session_id: id.to_string(), + provider: provider.to_string(), + project_path: format!("/repo/{provider}"), + title: Some(format!("{provider} title")), + title_provider_generated: false, + summary: None, + first_user_message: Some("hello".to_string()), + last_activity_at: last, + created_at: None, + cwd: Some(format!("/repo/{provider}")), + is_subagent: false, + is_non_interactive: false, + source_file: None, + } +} + +#[allow(clippy::type_complexity)] +fn resolve( + input: &str, + sessions: Option<&[IndexedSession]>, + claude: Option< + &(dyn Fn(&str) -> Result, ProviderFailure> + Send + Sync), + >, + opencode: Option< + &(dyn Fn(&str) -> Result, ProviderFailure> + Send + Sync), + >, +) -> ResumeResolveOutcome { + let session_types: HashMap = HashMap::new(); + resolve_resume_input( + input, + &ResolveDeps { + sessions, + session_types: &session_types, + locate_claude_transcript: claude, + opencode_session_by_id: opencode, + }, + ) +} + +/// Node's `fourProviderSnapshot`. +fn four_provider_snapshot() -> Vec { + vec![ + session("claude", CLAUDE_ID, 100), + session("codex", CODEX_ID, 100), + session("opencode", SES_ID, 100), + session("amplifier", AMPLIFIER_FULL, 100), + ] +} + +/// Wire-shape helper: `ResumeResolveMatch` is the serialized surface (the +/// outcome envelope itself is re-wrapped by the HTTP layer, Task 6). +fn matches_json(out: &ResumeResolveOutcome) -> serde_json::Value { + serde_json::to_value(&out.matches).expect("serialize matches") +} + +// ========================================================================= +// 1. The 23-test mirror of `resolve-session.test.ts`, in Node file order. +// ========================================================================= + +// Node #1: `exact match wins across all providers at once (claude UUID, no +// hint needed)` — "no hint needed" means no explicit command hint is required +// in the INPUT; a bare v4 UUID still derives the claude id-shape hint on both +// parsers (`shared/resume-input-parser.ts:88-94`, `resume_input.rs:194-202`). +#[test] +fn exact_match_wins_across_all_providers_at_once() { + let sessions = four_provider_snapshot(); + let out = resolve(CLAUDE_ID, Some(&sessions), None, None); + assert_eq!(out.matches.len(), 1); + let m = &out.matches[0]; + assert_eq!(m.provider, "claude"); + assert_eq!(m.session_id, CLAUDE_ID); + assert_eq!(m.session_type.as_deref(), Some("claude")); + assert_eq!(m.cwd.as_deref(), Some("/repo/claude")); + assert_eq!(m.match_kind, ResumeMatchKind::Exact); + assert_eq!( + out.hint, + Some(ResumeHint { + provider: ResumeHintProvider::Claude, + source: ResumeHintSource::IdShape, + }) + ); +} + +// Node #2: `short hex prefix matches the amplifier session (spec row: 417e8345)` +#[test] +fn short_hex_prefix_matches_the_amplifier_session() { + let sessions = four_provider_snapshot(); + let out = resolve("417e8345", Some(&sessions), None, None); + assert_eq!(out.matches.len(), 1); + assert_eq!(out.matches[0].provider, "amplifier"); + assert_eq!(out.matches[0].session_id, AMPLIFIER_FULL); + assert_eq!(out.matches[0].match_kind, ResumeMatchKind::Prefix); +} + +// Node #3: `exact-id match is case-insensitive for UUID/hex tokens` +#[test] +fn exact_id_match_is_case_insensitive_for_uuid_hex_tokens() { + let sessions = vec![session("claude", CLAUDE_ID, 100)]; + let out = resolve(&CLAUDE_ID.to_uppercase(), Some(&sessions), None, None); + assert_eq!(out.matches.len(), 1); + assert_eq!(out.matches[0].match_kind, ResumeMatchKind::Exact); +} + +// Node #4: `ses_ ids are case-SENSITIVE (base62): a case-variant does NOT match` +#[test] +fn ses_ids_are_case_sensitive_a_case_variant_does_not_match() { + let sessions = vec![session("opencode", SES_ID, 100)]; + let variant = SES_ID.to_uppercase().replace("SES_", "ses_"); + let out = resolve(&variant, Some(&sessions), None, None); + assert_eq!(out.status, ResumeResolveStatus::Ready); + // Not exact — but it IS a prefix miss too (different chars), so empty. + assert!(out.matches.is_empty()); +} + +// Node #5: `opencode ses_ id resolves to opencode even though other providers exist` +#[test] +fn opencode_ses_id_resolves_to_opencode_even_though_other_providers_exist() { + let sessions = four_provider_snapshot(); + let out = resolve(SES_ID, Some(&sessions), None, None); + assert_eq!(out.matches.len(), 1); + assert_eq!(out.matches[0].provider, "opencode"); +} + +// Node #6: `exact match takes precedence over prefix matches of the same token` +#[test] +fn exact_match_takes_precedence_over_prefix_matches_of_the_same_token() { + let sessions = vec![ + session("amplifier", "417e8345", 1), + session("amplifier", AMPLIFIER_FULL, 2), + ]; + let out = resolve("417e8345", Some(&sessions), None, None); + assert_eq!(out.matches.len(), 1); + assert_eq!(out.matches[0].match_kind, ResumeMatchKind::Exact); +} + +// Node #7: `ambiguous prefix returns all matches most-recent first, capped` +#[test] +fn ambiguous_prefix_returns_all_matches_most_recent_first_capped() { + let many: Vec = (0..25) + .map(|i| session("amplifier", &format!("417e8345-0000-4000-8000-{i:012}"), i)) + .collect(); + let out = resolve("417e8345", Some(&many), None, None); + assert_eq!(out.matches.len(), RESOLVE_MATCH_CAP); + // Most recent first; 25 sessions with activity 24..0 capped at 20 make + // the tail EXACTLY 5. + assert_eq!(out.matches[0].last_activity_at, Some(24)); + assert_eq!(out.matches[RESOLVE_MATCH_CAP - 1].last_activity_at, Some(5)); +} + +// Node #8: `tries candidates in priority order until one resolves` +#[test] +fn tries_candidates_in_priority_order_until_one_resolves() { + // ses_ token (highest parser priority) misses everywhere; the UUID resolves. + let sessions = four_provider_snapshot(); + let out = resolve( + &format!("ses_zzzzzzzzzzzzzzzzzzzzzzzzzz {CLAUDE_ID}"), + Some(&sessions), + None, + None, + ); + assert_eq!(out.matches.len(), 1); + assert_eq!(out.matches[0].session_id, CLAUDE_ID); +} + +// Node #9: `an EXACT id finds a subagent/child session (spec: scan ALL sessions)` +#[test] +fn an_exact_id_finds_a_subagent_child_session() { + let mut child = session("claude", CLAUDE_ID, 100); + child.is_subagent = true; + let out = resolve(CLAUDE_ID, Some(&[child]), None, None); + assert_eq!(out.matches.len(), 1); +} + +// Node #10: `prefix DISCOVERY does not surface subagent sessions` +#[test] +fn prefix_discovery_does_not_surface_subagent_sessions() { + let mut child = session("claude", CLAUDE_ID, 100); + child.is_subagent = true; + let top = session("claude", "ed2afda6-a340-443e-ba60-024a1b3554b5", 90); + let out = resolve("ed2afda6", Some(&[child, top]), None, None); + assert_eq!(out.matches.len(), 1); + assert_eq!( + out.matches[0].session_id, + "ed2afda6-a340-443e-ba60-024a1b3554b5" + ); +} + +// Node #11: `an exact FALLBACK hit beats an indexed PREFIX match of the same token` +#[test] +fn an_exact_fallback_hit_beats_an_indexed_prefix_match_of_the_same_token() { + // Index holds a session whose id merely STARTS WITH the pasted full id. + let longer = session("claude", &format!("{CLAUDE_ID}0"), 100); + let hits = |id: &str| -> Result, ProviderFailure> { + Ok(Some(ClaudeTranscriptHit { + session_id: id.to_ascii_lowercase(), + cwd: Some("/repo/x".into()), + })) + }; + let out = resolve(CLAUDE_ID, Some(&[longer]), Some(&hits), None); + assert_eq!(out.matches.len(), 1); + assert_eq!(out.matches[0].session_id, CLAUDE_ID); + assert_eq!(out.matches[0].provider, "claude"); +} + +// Node #12: `sessionType defaults to the provider name when the index has none` +#[test] +fn session_type_defaults_to_the_provider_name_when_the_overlay_has_none() { + let sessions = vec![session("claude", CLAUDE_ID, 100)]; + let out = resolve(CLAUDE_ID, Some(&sessions), None, None); + assert_eq!(out.matches[0].session_type.as_deref(), Some("claude")); +} + +// Node #13: `index miss consults exact-id fallbacks (claude transcript locator)` +#[test] +fn index_miss_consults_exact_id_fallbacks_claude_transcript_locator() { + let locate = |id: &str| -> Result, ProviderFailure> { + Ok(Some(ClaudeTranscriptHit { + session_id: id.to_ascii_lowercase(), + cwd: Some("/tmp/found".into()), + })) + }; + let out = resolve(OTHER_UUID, Some(&[]), Some(&locate), None); + assert_eq!(out.matches.len(), 1); + let m = &out.matches[0]; + assert_eq!(m.provider, "claude"); + assert_eq!(m.session_id, OTHER_UUID); + assert_eq!(m.cwd.as_deref(), Some("/tmp/found")); + assert_eq!(m.match_kind, ResumeMatchKind::Exact); +} + +// Node #14: `index miss consults opencode by-id fallback` — with the hardened +// row query's richer payload (title + floored lastActivityAt) asserted. +#[test] +fn opencode_fallback_hit_carries_title_and_floored_last_activity() { + let oc = |id: &str| -> Result, ProviderFailure> { + Ok(Some(OpencodeByIdHit { + session_id: id.to_string(), + cwd: Some("/repo/beta".into()), + title: Some("beta work".into()), + last_activity_at: Some(1234), + })) + }; + let out = resolve(SES_ID, Some(&[]), None, Some(&oc)); + let m = &out.matches[0]; + assert_eq!(m.provider, "opencode"); + assert_eq!(m.title.as_deref(), Some("beta work")); + assert_eq!(m.last_activity_at, Some(1234)); + assert_eq!(m.session_type.as_deref(), Some("opencode")); +} + +// Node #15: `zero matches when nothing resolves anywhere` +#[test] +fn zero_matches_when_nothing_resolves_anywhere() { + let sessions = four_provider_snapshot(); + let claude = |_: &str| -> Result, ProviderFailure> { Ok(None) }; + let oc = |_: &str| -> Result, ProviderFailure> { Ok(None) }; + let out = resolve("deadbeef1234", Some(&sessions), Some(&claude), Some(&oc)); + assert_eq!(out.status, ResumeResolveStatus::Ready); + assert!(out.matches.is_empty()); +} + +// Node #16: `a THROWING fallback never fails the request: it degrades with a +// provider error summary` +#[test] +fn a_failing_fallback_never_fails_the_resolve_it_degrades_with_a_provider_error() { + // Node production parity: the opencode worker boundary serializes only + // {name, message} (`opencode-by-id.worker.ts:41-42`) and the runner + // rebuilds the Error WITHOUT `.code` (`opencode-by-id-runner.ts:103-106`), + // so opencode provider errors are message-only on the wire — `code` is + // None here. (Code passthrough-when-present is exercised by the claude + // fallback's EACCES endpoint test in Task 6.) + let broken = |_id: &str| -> Result, ProviderFailure> { + Err(ProviderFailure { + code: None, + message: "unable to open database file".into(), + }) + }; + let out = resolve(SES_ID, Some(&[]), None, Some(&broken)); + assert_eq!(out.status, ResumeResolveStatus::Degraded); + assert!(out.matches.is_empty()); + assert_eq!(out.provider_errors.len(), 1); + assert_eq!(out.provider_errors[0].provider, "opencode"); + assert_eq!(out.provider_errors[0].code, None); + assert_eq!( + out.provider_errors[0].message.as_deref(), + Some("unable to open database file") + ); +} + +// Node #17: `provider identity in providerErrors comes from the fallback PAIR, +// not its position` +#[test] +fn provider_identity_travels_with_the_fallback_not_its_position() { + // BOTH fallbacks present; only claude's fails on a uuid token. + let broken_claude = |_id: &str| -> Result, ProviderFailure> { + Err(ProviderFailure { + code: Some("EACCES".into()), + message: "denied".into(), + }) + }; + let quiet_oc = |_id: &str| -> Result, ProviderFailure> { Ok(None) }; + let out = resolve(OTHER_UUID, Some(&[]), Some(&broken_claude), Some(&quiet_oc)); + assert_eq!(out.provider_errors.len(), 1); + assert_eq!(out.provider_errors[0].provider, "claude"); + assert_eq!(out.provider_errors[0].code.as_deref(), Some("EACCES")); +} + +// Node #18: `a typed ClaudeTranscriptLocatorError surfaces its errno code in +// the provider error` — Rust models Node's typed error as `ProviderFailure.code`. +#[test] +fn a_typed_locator_failure_surfaces_its_errno_code_in_the_provider_error() { + let broken = |_id: &str| -> Result, ProviderFailure> { + Err(ProviderFailure { + code: Some("EACCES".into()), + message: "failed to list claude projects dir: /tmp/x".into(), + }) + }; + let out = resolve(CLAUDE_ID, Some(&[]), Some(&broken), None); + assert_eq!(out.status, ResumeResolveStatus::Degraded); + assert_eq!( + out.provider_errors, + vec![ResumeResolveProviderError { + provider: "claude".into(), + code: Some("EACCES".into()), + message: Some("failed to list claude projects dir: /tmp/x".into()), + }] + ); +} + +// Node #19: `a healthy resolve reports NO provider errors` +#[test] +fn a_healthy_resolve_reports_no_provider_errors_and_stays_ready() { + let sessions = vec![session("claude", CLAUDE_ID, 100)]; + let out = resolve(CLAUDE_ID, Some(&sessions), None, None); + assert_eq!(out.status, ResumeResolveStatus::Ready); + assert!(out.provider_errors.is_empty()); +} + +// Node #20: `a failed exact-id fallback does NOT hide a later lower-priority +// match — but marks the response degraded` +#[test] +fn a_failed_fallback_does_not_hide_a_later_lower_priority_match_but_marks_degraded() { + // ses_ token fails in the fallback; the later hex token prefix-matches the index. + let indexed = vec![session("amplifier", "417e8345aaaa", 50)]; + let broken = |_id: &str| -> Result, ProviderFailure> { + Err(ProviderFailure { + code: None, + message: "locked".into(), + }) + }; + let out = resolve( + &format!("{SES_ID} 417e8345"), + Some(&indexed), + None, + Some(&broken), + ); + assert_eq!(out.status, ResumeResolveStatus::Degraded); + assert_eq!(out.matches.len(), 1); + assert_eq!(out.matches[0].session_id, "417e8345aaaa"); + assert_eq!(out.provider_errors[0].provider, "opencode"); +} + +// Node #21: `a fallback exact hit for a HIGHER-priority token beats an indexed +// exact hit of a LOWER-priority token` +#[test] +fn a_fallback_exact_hit_for_a_higher_priority_token_beats_an_indexed_exact_of_a_lower_one() { + // Candidate order: ses_ (prefixed) outranks the uuid. The ses_ id resolves + // only via the opencode fallback; the uuid has an indexed exact hit. + let indexed = vec![session("claude", CLAUDE_ID, 100)]; + let oc = |id: &str| -> Result, ProviderFailure> { + Ok(Some(OpencodeByIdHit { + session_id: id.to_string(), + cwd: Some("/repo/oc".into()), + title: None, + last_activity_at: None, + })) + }; + let out = resolve( + &format!("{SES_ID} {CLAUDE_ID}"), + Some(&indexed), + None, + Some(&oc), + ); + assert_eq!(out.matches.len(), 1); + assert_eq!(out.matches[0].provider, "opencode"); + assert_eq!(out.matches[0].session_id, SES_ID); +} + +// Node #22: `dedupes duplicate (provider, sessionId) snapshot entries, keeping +// the most recent` +#[test] +fn dedupes_duplicate_provider_session_id_snapshot_entries_keeping_the_most_recent() { + let mut older = session("claude", CLAUDE_ID, 100); + older.title = Some("older file".to_string()); + let mut newer = session("claude", CLAUDE_ID, 500); + newer.title = Some("newer file".to_string()); + let out = resolve(CLAUDE_ID, Some(&[older, newer]), None, None); + assert_eq!(out.matches.len(), 1); + assert_eq!(out.matches[0].title.as_deref(), Some("newer file")); + assert_eq!(out.matches[0].last_activity_at, Some(500)); +} + +// Node #23: `returns warming (not "not found") while the index is not ready` +#[test] +fn returns_warming_not_not_found_while_the_index_is_not_ready() { + let out = resolve(&format!("claude --resume {CLAUDE_ID}"), None, None, None); + assert_eq!(out.status, ResumeResolveStatus::Warming); + assert!(out.matches.is_empty()); + assert_eq!( + out.hint, + Some(ResumeHint { + provider: ResumeHintProvider::Claude, + source: ResumeHintSource::Command, + }) + ); + assert!(out.provider_errors.is_empty()); +} + +// ========================================================================= +// 2. Rust-only shape-gate/budget pins (no Node twin in the core suite; the +// Node originals live in resolve-fallbacks.test.ts). +// ========================================================================= + +#[test] +fn shape_gates_wrong_shape_tokens_do_no_fallback_work() { + use std::sync::atomic::{AtomicUsize, Ordering}; + static CALLS: AtomicUsize = AtomicUsize::new(0); + let counting = |_id: &str| -> Result, ProviderFailure> { + CALLS.fetch_add(1, Ordering::SeqCst); + Ok(None) + }; + // "ses_short001" matches the parser's prefixed-id family but NOT the + // full-id shape ^ses_[0-9a-zA-Z]{26}$ — the fallback must not run. + let out = resolve("ses_short001", Some(&[]), None, Some(&counting)); + assert_eq!(CALLS.load(Ordering::SeqCst), 0); + assert_eq!(out.status, ResumeResolveStatus::Ready); +} + +#[test] +fn fallback_work_is_budgeted_to_two_calls_per_request_per_provider() { + use std::sync::atomic::{AtomicUsize, Ordering}; + static CALLS: AtomicUsize = AtomicUsize::new(0); + let counting = |_id: &str| -> Result, ProviderFailure> { + CALLS.fetch_add(1, Ordering::SeqCst); + Ok(None) + }; + // Four full-shape ses_ ids in one paste: only the first TWO may do work. + let ids = [ + "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa", + "ses_bbbbbbbbbbbbbbbbbbbbbbbbbb", + "ses_cccccccccccccccccccccccccc", + "ses_dddddddddddddddddddddddddd", + ]; + let _ = resolve(&ids.join(" "), Some(&[]), None, Some(&counting)); + assert_eq!(CALLS.load(Ordering::SeqCst), 2); +} + +#[test] +fn wrong_shape_tokens_consume_no_budget() { + use std::sync::atomic::{AtomicUsize, Ordering}; + static CALLS: AtomicUsize = AtomicUsize::new(0); + let counting = |_id: &str| -> Result, ProviderFailure> { + CALLS.fetch_add(1, Ordering::SeqCst); + Ok(None) + }; + // Two ses_ tokens (wrong shape for claude) then a valid uuid: the uuid + // must still reach the claude fallback (shape gate runs BEFORE budget). + let input = + format!("ses_aaaaaaaaaaaaaaaaaaaaaaaaaa ses_bbbbbbbbbbbbbbbbbbbbbbbbbb {OTHER_UUID}"); + let _ = resolve(&input, Some(&[]), Some(&counting), None); + assert_eq!(CALLS.load(Ordering::SeqCst), 1); +} + +// ========================================================================= +// 3. Supplementary pins beyond the Node core suite (resolve-fallbacks.test.ts +// parity + wire-shape pins), carried over from the pre-hardening suite and +// adapted to the hardened interfaces. +// ========================================================================= + +const AMP_ID_NEW: &str = "417e8345-aaaa-4bbb-8ccc-000000000001"; +const AMP_ID_OLD: &str = "417e8345-bbbb-4ccc-8ddd-000000000002"; + +fn session_in(provider: &str, id: &str, project: &str, last_activity_at: i64) -> IndexedSession { + IndexedSession { + session_id: id.to_string(), + provider: provider.to_string(), + project_path: project.to_string(), + title: None, + title_provider_generated: false, + summary: None, + first_user_message: None, + last_activity_at, + created_at: None, + cwd: Some(project.to_string()), + is_subagent: false, + is_non_interactive: false, + source_file: None, + } +} + +/// The Node integration suite's fixtureProjects(), flattened. +fn fixture_sessions() -> Vec { + let mut claude = session_in("claude", CLAUDE_ID, "/repo/alpha", 400); + claude.title = Some("Fix the parser".to_string()); + claude.first_user_message = Some("fix the parser".to_string()); + vec![ + claude, + session_in("codex", CODEX_ID, "/repo/alpha", 300), + session_in("opencode", SES_ID, "/repo/beta", 200), + session_in("amplifier", AMP_ID_NEW, "/repo/beta", 900), + session_in("amplifier", AMP_ID_OLD, "/repo/beta", 100), + ] +} + +#[test] +fn exact_uuid_resolves_to_single_exact_match() { + let sessions = fixture_sessions(); + for (input, provider, id) in [ + (CLAUDE_ID.to_string(), "claude", CLAUDE_ID), + (format!("codex resume {CODEX_ID}"), "codex", CODEX_ID), + (format!("opencode --session {SES_ID}"), "opencode", SES_ID), + ] { + let out = resolve(&input, Some(&sessions), None, None); + assert_eq!(out.status, ResumeResolveStatus::Ready, "input {input:?}"); + assert_eq!(out.matches.len(), 1, "input {input:?}"); + assert_eq!(out.matches[0].provider, provider); + assert_eq!(out.matches[0].session_id, id); + assert_eq!(out.matches[0].match_kind, ResumeMatchKind::Exact); + } +} + +#[test] +fn match_carries_full_resume_metadata() { + let sessions = fixture_sessions(); + let out = resolve(CLAUDE_ID, Some(&sessions), None, None); + // Wire-shape pin: camelCase names, all optionals present here. + assert_eq!( + matches_json(&out), + serde_json::json!([{ + "provider": "claude", + "sessionId": CLAUDE_ID, + "cwd": "/repo/alpha", + // No metadata-store overlay entry: sessionType defaults to the + // provider — hardened Node's `toMatch` emits + // `sessionType ?? provider`, never absent. + "sessionType": "claude", + "title": "Fix the parser", + "firstUserMessage": "fix the parser", + "lastActivityAt": 400, + "matchKind": "exact" + }]) + ); +} + +#[test] +fn session_type_overlays_from_metadata_map() { + let sessions = fixture_sessions(); + let mut types = HashMap::new(); + types.insert(format!("claude:{CLAUDE_ID}"), "freshclaude".to_string()); + let out = resolve_resume_input( + CLAUDE_ID, + &ResolveDeps { + sessions: Some(&sessions), + session_types: &types, + locate_claude_transcript: None, + opencode_session_by_id: None, + }, + ); + assert_eq!(out.matches[0].session_type.as_deref(), Some("freshclaude")); +} + +#[test] +fn prefix_matches_short_hex_most_recent_first() { + let sessions = fixture_sessions(); + let out = resolve("417e8345", Some(&sessions), None, None); + assert_eq!(out.status, ResumeResolveStatus::Ready); + let ids: Vec<&str> = out.matches.iter().map(|m| m.session_id.as_str()).collect(); + assert_eq!(ids, vec![AMP_ID_NEW, AMP_ID_OLD]); + assert_eq!(out.matches[0].match_kind, ResumeMatchKind::Prefix); + assert_eq!(out.matches[0].provider, "amplifier"); +} + +#[test] +fn reports_hint_alongside_evidence() { + let sessions = fixture_sessions(); + let out = resolve( + &format!("codex resume {CODEX_ID}"), + Some(&sessions), + None, + None, + ); + assert_eq!( + out.hint, + Some(ResumeHint { + provider: ResumeHintProvider::Codex, + source: ResumeHintSource::Command, + }) + ); + assert_eq!(out.matches.len(), 1); +} + +#[test] +fn unknown_id_is_ready_with_empty_matches() { + let sessions = fixture_sessions(); + let out = resolve( + "019fffff-ffff-7fff-bfff-ffffffffffff", + Some(&sessions), + None, + None, + ); + assert_eq!(out.status, ResumeResolveStatus::Ready); + assert!(out.matches.is_empty()); +} + +#[test] +fn garbage_input_is_ready_empty_with_no_hint() { + // The `hint: null`-on-the-wire pin lives in the HTTP layer + // (`resolve.rs`); at the core level absence is `None`. + let sessions = fixture_sessions(); + let out = resolve("hello decade facade!!", Some(&sessions), None, None); + assert_eq!(out.status, ResumeResolveStatus::Ready); + assert!(out.matches.is_empty()); + assert_eq!(out.hint, None); + assert!(out.provider_errors.is_empty()); +} + +#[test] +fn opencode_by_id_fallback_uses_row_directory_as_cwd() { + let unknown = "ses_child000000000000000000000"; + let lookup = |id: &str| -> Result, ProviderFailure> { + assert_eq!(id, unknown); + Ok(Some(OpencodeByIdHit { + session_id: id.to_string(), + cwd: Some("/repo/beta".to_string()), + title: None, + last_activity_at: None, + })) + }; + let sessions = fixture_sessions(); + let out = resolve(unknown, Some(&sessions), None, Some(&lookup)); + // Node asserts strict equality: exactly these five keys, nothing else. + assert_eq!( + matches_json(&out), + serde_json::json!([{ + "provider": "opencode", + "sessionId": unknown, + "cwd": "/repo/beta", + "sessionType": "opencode", + "matchKind": "exact" + }]) + ); +} + +#[test] +fn opencode_fallback_hit_without_directory_omits_cwd() { + // Legacy-schema hits carry `cwd: None`, and Node's `row.cwd || undefined` + // ALSO drops empty strings: both must OMIT `cwd` entirely on the wire — + // never `"cwd": null` or `"cwd": ""`. Same rule for `title`. + let unknown = "ses_legacy00000000000000000000"; + for cwd in [None, Some(String::new())] { + let cwd_case = cwd.clone(); + let lookup = move |id: &str| -> Result, ProviderFailure> { + assert_eq!(id, unknown); + Ok(Some(OpencodeByIdHit { + session_id: id.to_string(), + cwd: cwd_case.clone(), + title: Some(String::new()), // Node: `row.title || undefined` + last_activity_at: None, + })) + }; + let sessions = fixture_sessions(); + let out = resolve(unknown, Some(&sessions), None, Some(&lookup)); + assert_eq!( + matches_json(&out), + serde_json::json!([{ + "provider": "opencode", + "sessionId": unknown, + "sessionType": "opencode", + "matchKind": "exact" + }]), + "cwd case {cwd:?}" + ); + } +} + +#[test] +fn claude_transcript_fallback_on_exact_id_index_miss() { + let locate = |id: &str| -> Result, ProviderFailure> { + Ok(Some(ClaudeTranscriptHit { + session_id: id.to_string(), + cwd: Some("/repo/gamma".to_string()), + })) + }; + let sessions = fixture_sessions(); + let out = resolve(OTHER_UUID, Some(&sessions), Some(&locate), None); + assert_eq!( + matches_json(&out), + serde_json::json!([{ + "provider": "claude", + "sessionId": OTHER_UUID, + "cwd": "/repo/gamma", + "sessionType": "claude", + "matchKind": "exact" + }]) + ); +} + +#[test] +fn fallbacks_are_not_consulted_on_an_exact_index_hit() { + // Hardened per-token order is exact → fallback → prefix: an EXACT index + // hit short-circuits before the fallbacks run (fallbacks only cover + // sessions the index cannot see). + let locate = |_id: &str| -> Result, ProviderFailure> { + panic!("locate_claude_transcript must not run on an exact index hit") + }; + let sessions = fixture_sessions(); + let out = resolve(CLAUDE_ID, Some(&sessions), Some(&locate), None); + assert_eq!(out.matches.len(), 1); +} + +#[test] +fn exact_id_fallback_beats_a_prefix_match_on_the_same_token() { + // Wire-shape variant of Node #11: an unindexed session whose id EQUALS + // the token must beat an indexed session whose id merely BEGINS with it. + let sessions = vec![session_in( + "claude", + &format!("{OTHER_UUID}-extra"), + "/repo/alpha", + 400, + )]; + let locate = |id: &str| -> Result, ProviderFailure> { + Ok(Some(ClaudeTranscriptHit { + session_id: id.to_string(), + cwd: Some("/repo/gamma".to_string()), + })) + }; + let out = resolve(OTHER_UUID, Some(&sessions), Some(&locate), None); + assert_eq!( + matches_json(&out), + serde_json::json!([{ + "provider": "claude", + "sessionId": OTHER_UUID, + "cwd": "/repo/gamma", + "sessionType": "claude", + "matchKind": "exact" + }]) + ); +} + +#[test] +fn prefix_still_resolves_when_the_fallback_misses() { + // Fallbacks run before prefix, but a fallback MISS falls through to + // prefix discovery on the same token. + let indexed_id = format!("{OTHER_UUID}-extra"); + let sessions = vec![session_in("claude", &indexed_id, "/repo/alpha", 400)]; + let locate = |_id: &str| -> Result, ProviderFailure> { Ok(None) }; + let out = resolve(OTHER_UUID, Some(&sessions), Some(&locate), None); + assert_eq!(out.matches[0].session_id, indexed_id); + assert_eq!(out.matches[0].match_kind, ResumeMatchKind::Prefix); +} + +#[test] +fn prefix_discovery_excludes_subagent_sessions_among_top_level_matches() { + // Multi-session variant of Node #10: the subagent child is filtered while + // the top-level prefix matches still return, most-recent first. + let mut sessions = fixture_sessions(); + let mut child = session_in( + "amplifier", + "417e8345-cccc-4ddd-8eee-000000000003", + "/repo/beta", + 950, + ); + child.is_subagent = true; + sessions.push(child); + let out = resolve("417e8345", Some(&sessions), None, None); + let ids: Vec<&str> = out.matches.iter().map(|m| m.session_id.as_str()).collect(); + assert_eq!(ids, vec![AMP_ID_NEW, AMP_ID_OLD]); +} + +#[test] +fn exact_index_match_still_reaches_subagent_sessions() { + // The asymmetry is the point: an exact pasted id must resolve even for + // hidden subagent children — only PREFIX discovery filters them. + let subagent_id = "417e8345-cccc-4ddd-8eee-000000000003"; + let mut sessions = fixture_sessions(); + let mut child = session_in("amplifier", subagent_id, "/repo/beta", 950); + child.is_subagent = true; + sessions.push(child); + let out = resolve(subagent_id, Some(&sessions), None, None); + assert_eq!(out.matches.len(), 1); + assert_eq!(out.matches[0].session_id, subagent_id); + assert_eq!(out.matches[0].match_kind, ResumeMatchKind::Exact); +} + +#[test] +fn uuid_matching_is_case_insensitive_but_returns_stored_ids() { + // uuid/hex tokens (hex digits + dashes only) match case-insensitively — + // Node's `isCaseInsensitiveToken` — and the STORED id is returned. + let sessions = fixture_sessions(); + let out = resolve(&CLAUDE_ID.to_uppercase(), Some(&sessions), None, None); + assert_eq!(out.matches[0].session_id, CLAUDE_ID); + assert_eq!(out.matches[0].match_kind, ResumeMatchKind::Exact); +} + +#[test] +fn ses_id_matching_is_case_sensitive() { + // ses_ + base62: upper/lower case are DISTINCT values, so case-folding + // could resolve the WRONG session. A wrong-case ses_ id must NOT match — + // neither exact nor prefix — while the correctly-cased id still resolves. + let sessions = fixture_sessions(); + let out = resolve( + "ses_ROOT0000000000000000000000", + Some(&sessions), + None, + None, + ); + assert_eq!(out.status, ResumeResolveStatus::Ready); + assert!(out.matches.is_empty()); + let out = resolve(SES_ID, Some(&sessions), None, None); + assert_eq!(out.matches[0].session_id, SES_ID); + assert_eq!(out.matches[0].match_kind, ResumeMatchKind::Exact); +} + +#[test] +fn wrong_length_ses_token_never_reaches_the_opencode_fallback() { + // Node's fallback gate is the FULL-id shape `^ses_[0-9a-zA-Z]{26}$` + // (`FALLBACK_ID_SHAPES`, `resolve-fallbacks.ts`), NOT the parser's looser + // 8..=64 `xxx_` family shape. Load-bearing on a legacy-schema opencode + // DB, where the by-id lookup answers a universal HIT for any id it is + // asked about: an ungated wrong-length token would yield a FALSE exact + // hit (Node: miss, zero work). + let lookup = |_id: &str| -> Result, ProviderFailure> { + panic!("opencode fallback must not run for a wrong-length ses_ token") + }; + let sessions = fixture_sessions(); + for wrong_length in [ + "ses_short0000", // 9 base62 chars: parser candidate, not a full id + "ses_toolong000000000000000000000x", // 29 base62 chars + "ses_wrongchar000000000000000-", // 26 chars but '-' is not base62 + ] { + let out = resolve(wrong_length, Some(&sessions), None, Some(&lookup)); + assert_eq!( + out.status, + ResumeResolveStatus::Ready, + "input {wrong_length:?}" + ); + assert!(out.matches.is_empty(), "input {wrong_length:?}"); + } +} + +#[test] +fn claude_fallback_gate_is_the_full_uuid_shape_in_any_case() { + // Node's claude gate `^[0-9a-fA-F]{8}-…-[0-9a-fA-F]{12}$` accepts a full + // UUID in ANY hex case… + let upper = "AAAAAAAA-1111-4222-8333-444444444444"; + let locate = |id: &str| -> Result, ProviderFailure> { + Ok(Some(ClaudeTranscriptHit { + session_id: id.to_ascii_lowercase(), + cwd: Some("/repo/gamma".to_string()), + })) + }; + let sessions = fixture_sessions(); + let out = resolve(upper, Some(&sessions), Some(&locate), None); + assert_eq!(out.matches[0].session_id, upper.to_ascii_lowercase()); + // …and NOTHING shorter: a bare hex-prefix token must never invoke it. + let panicking = |_id: &str| -> Result, ProviderFailure> { + panic!("claude fallback must not run for a non-full-uuid token") + }; + let out = resolve( + "aaaaaaaa11114222833344444444", + Some(&sessions), + Some(&panicking), + None, + ); + assert!(out.matches.is_empty()); +} + +#[test] +fn third_fallback_requiring_token_is_budget_gated_like_node() { + use std::sync::atomic::{AtomicUsize, Ordering}; + // Node's FALLBACK_BUDGET_PER_REQUEST = 2 (`resolve-fallbacks.ts`): the + // first two well-shaped ses_ tokens consume the opencode budget with + // real (missing) lookups; the THIRD would resolve, but must not even be + // looked up — Node answers not-found here, and so must the port. The + // budget is consumed by the invocation itself, hit or miss. + let third = "ses_third00000000000000000000d"; + let calls = AtomicUsize::new(0); + let lookup = |id: &str| -> Result, ProviderFailure> { + calls.fetch_add(1, Ordering::SeqCst); + if id == third { + Ok(Some(OpencodeByIdHit { + session_id: id.to_string(), + cwd: Some("/repo/x".to_string()), + title: None, + last_activity_at: None, + })) + } else { + Ok(None) + } + }; + let sessions = fixture_sessions(); + let input = format!("ses_first00000000000000000000a ses_second0000000000000000000b {third}"); + let out = resolve(&input, Some(&sessions), None, Some(&lookup)); + assert_eq!(out.status, ResumeResolveStatus::Ready); + assert!(out.matches.is_empty()); + assert_eq!( + calls.load(Ordering::SeqCst), + 2, + "budget caps real lookups at 2" + ); +} + +#[test] +fn shape_gated_tokens_do_not_consume_the_fallback_budget() { + use std::sync::atomic::{AtomicUsize, Ordering}; + // Node checks shape FIRST, budget SECOND ("order is load-bearing", + // `resolve-fallbacks.ts`): wrong-shape tokens ahead of the real id are + // free no-ops, so the valid third token still gets its real lookup. + let valid = "ses_valid00000000000000000000c"; + let calls = AtomicUsize::new(0); + let lookup = |id: &str| -> Result, ProviderFailure> { + calls.fetch_add(1, Ordering::SeqCst); + assert_eq!(id, valid, "only the full-shape id may reach the lookup"); + Ok(Some(OpencodeByIdHit { + session_id: id.to_string(), + cwd: Some("/repo/x".to_string()), + title: None, + last_activity_at: None, + })) + }; + let sessions = fixture_sessions(); + let input = format!("ses_short0000 ses_short1111 {valid}"); + let out = resolve(&input, Some(&sessions), None, Some(&lookup)); + assert_eq!(out.matches[0].session_id, valid); + assert_eq!(out.matches[0].match_kind, ResumeMatchKind::Exact); + assert_eq!(calls.load(Ordering::SeqCst), 1); +} + +#[test] +fn fallback_budgets_are_tracked_per_provider() { + use std::sync::atomic::{AtomicUsize, Ordering}; + // Node's `withRequestBudget` keeps a SEPARATE `used` counter per fallback + // key: two opencode lookups must not exhaust the claude budget (or vice + // versa). Parser priority runs prefixed-id tokens before the uuid, so the + // two ses_ misses happen first. + let opencode_calls = AtomicUsize::new(0); + let lookup = |_id: &str| -> Result, ProviderFailure> { + opencode_calls.fetch_add(1, Ordering::SeqCst); + Ok(None) + }; + let locate = |id: &str| -> Result, ProviderFailure> { + Ok(Some(ClaudeTranscriptHit { + session_id: id.to_string(), + cwd: Some("/repo/gamma".to_string()), + })) + }; + let sessions = fixture_sessions(); + let input = + format!("ses_first00000000000000000000a ses_second0000000000000000000b {OTHER_UUID}"); + let out = resolve(&input, Some(&sessions), Some(&locate), Some(&lookup)); + assert_eq!(opencode_calls.load(Ordering::SeqCst), 2); + assert_eq!(out.matches[0].provider, "claude"); + assert_eq!(out.matches[0].session_id, OTHER_UUID); + assert_eq!(out.matches[0].match_kind, ResumeMatchKind::Exact); +} diff --git a/docs/plans/2026-07-14-rust-tauri-parity-completion-checklist.md b/docs/plans/2026-07-14-rust-tauri-parity-completion-checklist.md index e706f9829..18d2ac4f7 100644 --- a/docs/plans/2026-07-14-rust-tauri-parity-completion-checklist.md +++ b/docs/plans/2026-07-14-rust-tauri-parity-completion-checklist.md @@ -800,8 +800,17 @@ Most current-main provider, title, pagination, timestamp, extension, lifecycle, - PARTIAL (2026-07-18): `test/e2e-browser/specs/restore-sync05.spec.ts` :: "a live terminal pane reconnects quietly after a deliberate server restart, with no user-facing error noise" — asserts zero `role="alert"` elements, no auth-required modal, no plain-text error language, and a genuinely functional post-restart terminal, across a real `server.restart()`; green both projects. - PARTIAL (2026-07-19, this task): same file's new test :: "a FreshCodex pane reconnects quietly after a deliberate server restart, targeting the same durable session with no user-facing error noise" — closes the CODEX/APP-BOUND restart leg the prior note left open. Reuses `restore-matrix.spec.ts`'s `fake-app-server.mjs` JSON-RPC sidecar fixture and its already-proven `freshAgent.create`/`freshAgent.attach` wire-observable pattern (TERM-02's fix) for session continuity: seeds a real FreshCodex session (genuine server-assigned session id, one live turn confirmed via the fixture's own reply), triggers a deliberate `server.restart()`, then asserts (a) the SAME "quiet" bar as the general leg (zero `role="alert"`, no auth-required modal, no plain-text error language), (b) every post-restart `freshAgent.create`/`freshAgent.attach` targets the ORIGINAL session id (never a fresh/duplicate one), and (c) the resumed pane renders real, non-blank content and settles idle. Green both projects, 2x each (legacy-chromium and rust-chromium, full-file run: 4/4 both times). MISSING: the equivalent-crash/diagnostics-retained leg and the `PW-TAURI-WIN` half of the validation remain explicitly out of scope (per the file's own doc comment) and left to dependent tickets. -- [ ] **SYNC-06 — Session resume-by-id parity: `POST /api/sessions/resolve` + `sessionResolve` feature flag.** The Node server (`server/sessions-router.ts`) resolves pasted session ids/resume commands across claude/codex/opencode/amplifier and gates the sidebar Resume button via the `sessionResolve` flag in `detectFeatureFlags()`; the Rust server intentionally omits the flag (button stays hidden) until it implements the endpoint. See `docs/plans/2026-07-29-resume-session-button.md`. +- [ ] **SYNC-06 — Session resume-by-id parity: `POST /api/sessions/resolve` + `sessionResolve` feature flag.** The Node server (`server/sessions-router.ts`) resolves pasted session ids/resume commands across claude/codex/opencode/amplifier and gates the sidebar Resume button via the `sessionResolve` flag in `detectFeatureFlags()`. See `docs/plans/2026-07-29-resume-session-button.md`. - **Playwright validation (`PW-RUST`, `PW-TAURI-WIN`):** With the flag declared, the sidebar shows the pinned Resume button; pasting a known session id resumes it in a tab (mirror `test/e2e-browser/specs/resume-button.spec.ts`). + - PARTIAL / REOPENED (2026-07-30): the first pass of this port targeted the RETIRED pre-#586 Node resolve implementation; an independent review reopened it. AT PARITY: (a) the parser — `crates/freshell-sessions/src/resume_input.rs` ports `shared/resume-input-parser.ts` (uuid/prefixed-id/hex-prefix extraction, the known-family prefixed-id regex, the `MAX_RESUME_CANDIDATES = 8` work budget, hints), cross-language anti-drift via `test/fixtures/resume-input/parser-cases.json` (32 cases) GENUINELY consumed by BOTH `test/unit/shared/resume-input-parser.test.ts` and `crates/freshell-sessions/tests/resume_input_parser_parity.rs`; (b) the matching CORE — `crates/freshell-sessions/src/resume_resolve.rs` ports `resolve-session.ts`'s per-token exact→fallback→prefix ordering (a prefix match never outranks an exact-id fallback), uuid/hex-only case-insensitivity (`ses_` base62 ids match case-SENSITIVELY), subagent exclusion from prefix discovery, cap-20/dedupe, `sessionType` always emitted (`sessionType ?? provider`), AND `resolve-fallbacks.ts`'s budgeted shape-gated fallbacks: full-id shape gates matching Node's `FALLBACK_ID_SHAPES` exactly (`^ses_[0-9a-zA-Z]{26}$` / full-UUID), `FALLBACK_BUDGET_PER_REQUEST = 2` real invocations per fallback per request (one counter PER fallback, shape checked BEFORE budget so wrong-shape tokens neither work nor consume budget), plus the claude fallback's cwd read bounded to Node's 64 KiB `CWD_SCAN_BYTES` (`transcript_cwd_bounded`); (c) the route shell — `crates/freshell-server/src/resolve.rs` (`POST /api/sessions/resolve`: auth, 400-validation pinned to zod 4.3.6 wire literals, warming, deleted-override filter). Logic parity tests: `crates/freshell-sessions/tests/resume_resolve.rs` mirrors the matching-semantics subset of `test/integration/server/sessions-resolve-router.test.ts` plus shape-gate/budget coverage. DEFERRED (all remaining divergences, enumerated in the module docs of `resume_resolve.rs`/`resolve.rs` and tracked in `docs/plans/2026-07-30-rust-resolve-parity-hardened.md`): (1) matching semantics — the opencode by-id fallback still runs the RETIRED `resolveOpencodeSessionRoots` parent-walk instead of Node's hardened direct row query (`providers/opencode-by-id-query.ts`): orphaned/cyclic child rows miss where Node hits, a legacy-schema DB universally hits any full-shape `ses_*` id where Node hits only real rows, and hits omit Node's `title`/`lastActivityAt`; fallback hits hardcode `sessionType` `"claude"`/`"opencode"` instead of consulting the session-metadata overlay (Node's `sessionTypeFor`) — a freshclaude/freshopencode session resolved via fallback would resume under the wrong runtime; the claude fallback's `locate_transcript` never probes Node's `//subagents/.jsonl` layout, so subagent child transcripts miss; (2) response surface (plan Tasks 3, 5, 6) — `degraded` status, `providerErrors`, `unsearchedProviders`, `homeDir`, warming/ready readiness merge (the wired fallbacks map read errors to a MISS, never a provider error). Until ALL of that lands, the `sessionResolve` capability flag is rolled back to `false` (`build_platform_payload`, `main.rs`) and `test/e2e-browser/specs/resume-button.spec.ts` is removed from `MATRIX_SPECS` (chromium-only again). MISSING: the deferred items above, the flag + e2e matrix re-enable, and the `PW-TAURI-WIN` (native Windows WebView2) half of the named validation — left to the hardened plan / dependent tickets, per the SYNC-05/SAFE-11 PARTIAL convention. + - PARTIAL (2026-07-30, hardened-contract follow-up, commit `22022a848`): rebased onto `f903e8a6` (#586) and closed the hardening delta. Contract: `status` gains `degraded`; `providerErrors`/`unsearchedProviders`/`homeDir` on the wire (`crates/freshell-server/src/resolve.rs`). Ranking: per-token exact→fallback→prefix, ses_ case-SENSITIVE, subagents excluded from prefix discovery, sessionType provider-default. Parser: known-family prefix regex + MAX_RESUME_CANDIDATES=8, pinned by the EXTENDED shared fixture `test/fixtures/resume-input/parser-cases.json` (34 cases; TS test restored to fixture-driven form) green on BOTH parsers. Provider health: broken opencode store → degraded + providerErrors on the wire (never silent not-found), scan-failure channel + disabled→unsearched, degraded fire-and-forget refresh; hardened by-id row query (archived+child, errors propagate). Async hygiene: all fallback IO inside spawn_blocking, work bounded by cap-8×budget-2. `cargo test --workspace`: 2368 passed, 0 failed; fmt+clippy clean. E2E: `resume-button.spec.ts` green on BOTH projects, 2 runs each (6/6 per run). The `sessionResolve` flag is restored to `true` (`build_platform_payload`) and `resume-button.spec.ts` is back in `MATRIX_SPECS`. MISSING: the `PW-TAURI-WIN` (native Windows WebView2) half remains out of scope, per the SYNC-05/SAFE-11 PARTIAL convention. + - ERRATA (2026-07-31, commit-message record correction): the ORIGINAL messages of the resume-resolve matching-core commit and the POST /api/sessions/resolve route commit claimed "Node-parity semantics"/"Node-parity behavior", but both implemented the RETIRED pre-#586 resolve contract, not the hardened in-tree Node implementation. `ed6346fa3` documents the discrepancy and explicitly calls those parity claims false; `467f6598e` then rolled back the `sessionResolve` capability until the missing behavior landed. Hardened parity was actually delivered by `1480e2a71` (hardened resolve wire: providerErrors/unsearchedProviders/homeDir, scan-failure merge, degraded refresh) and `22022a848` (capability + e2e matrix restore). CORRECTED IN-HISTORY (2026-07-31): both messages were reworded on this branch to accurately describe the retired legacy contract they implemented — now `e6e37c5d8` ("resume-resolve matching core for the retired pre-#586 legacy contract") and `fee2d2e3e` ("POST /api/sessions/resolve implementing the retired pre-#586 legacy contract"); the rewrite was message-only with identical trees. Note: origin/feat/rust-resolve-parity still holds the pre-correction history, so publishing this branch will require a deliberate `git push --force-with-lease` by the user. + - ERRATA (2026-07-31, home-resolution correction): commits `bffd47a25` and `aa36247d9` described home resolution as HOME-then-USERPROFILE on ALL platforms — an interim approximation of Node `os.homedir()`, not its actual contract (Windows reads USERPROFILE and never consults HOME; POSIX reads HOME when set and non-empty, else the effective user's passwd-entry home). Corrected by this commit: `provider_home()`, the `homeDir` wire helper, the claude exact-id fallback root, and opencode's `home_dir()` now delegate to `std::env::home_dir()`, which implements Node's platform semantics exactly. + - ERRATA (2026-07-31, resolve deadline/admission scoping): commit `aa36247d9` wrapped the ENTIRE resolver — permit wait, in-memory index matching, warming and no-candidate paths — in the shared 8-permit admission semaphore and the 15 s deadline, an interim approximation that let saturated fallbacks degrade zero-I/O requests and blamed every enabled provider on a timeout. Corrected by this commit to Node's scoping (`opencode-by-id-runner.ts` bounds the individual by-id worker): the permit + deadline + cooperative-cancel machinery now bounds ONLY each blocking provider-fallback dispatch — parsing/validation, index-only resolution, warming, and no-candidate responses never queue for admission — and a timeout blames only the provider(s) whose fallbacks were actually dispatched. + - ERRATA (2026-07-31, per-dispatch cancellation scoping): the interim shared cancellation flag introduced by `95e026a3a` was shared across BOTH providers' fallback dispatches, so one timed-out dispatch skipped every later fallback and fabricated cross-provider timeouts (e.g. an opencode stall skipped a healthy claude lookup and blamed claude), contradicting the actually-dispatched-only blame claim above; corrected by this commit — each dispatch is bounded independently and later candidates/providers are still attempted, matching Node (`resolve-session.ts:133-156`). + - ERRATA (2026-07-31, fail-fast fallback admission): through commit `63bb31390` the admission semaphore was acquired via an awaited `acquire_owned()` INSIDE the outer resolver `spawn_blocking` worker, so under full permit saturation each fallback dispatch pinned an unbounded outer blocking-pool worker for its entire 15 s deadline — the very blocking-pool exhaustion the semaphore claimed to prevent; corrected by this commit to a synchronous fail-fast `try_acquire_owned()` before any fallback task exists (a starved dispatch degrades immediately with a concurrency-limit provider error; cheap paths remain admission-free). + - ERRATA (2026-07-31, provider-generated-title suppression): the iter4 override projection (`802e8cfda`, whose ORIGINAL pre-reword message claimed the "full post-override session view") omitted Node's provider-generated-title suppression branch (`applyOverride`, `session-indexer.ts:210-211` — a `dir`/`first-message`-sourced `titleOverride` never clobbers a current provider-generated title), always substituting the stale override; corrected by this commit, which plumbs the parse-layer provider-generated flag through `ParsedSessionMeta`/`IndexedSession` and applies Node's exact suppression in `project_session_through_overrides`. + - ERRATA (2026-07-31, second in-history reword — commit-message record correction): an independent review found three commit messages on this branch materially overstated their commits, and they were REWORDED IN-HISTORY (message-only, trees byte-identical, safety tag `pre-reword-backup-iter5`): (1) the provider-home/deadline commit (now `bffd47a25`) claimed HOME||USERPROFILE "matching Node's os.homedir()" and framed its 15 s whole-resolver deadline as Node's "outer deadline" — the home resolution was an approximation (exact platform semantics landed in `acce912dd`) and Node's timeout is per worker, not around the entire resolver (rescoped in `95e026a3a`); (2) the deadline-rescoping commit (now `95e026a3a`) claimed a timeout blamed ONLY the actually-dispatched provider and matched Node's per-worker behavior, but its shared cancellation flag skipped subsequent providers and fabricated timeout errors for work never dispatched (corrected in `63bb31390`); (3) the override-projection commit (now `802e8cfda`) claimed the "full post-override session view" ported "exactly" while omitting Node's provider-generated-title suppression branch (implemented in `feb9cf043`). The reword changed the SHAs of those three commits and every descendant (old→new: `c6f3220b0`→`bffd47a25`, `b3ccc6dd7`→`e15ae2cef`, `bb357a598`→`aa36247d9`, `59e4dd77b`→`eff630f2a`, `97ab164a0`→`6976f1caf`, `4ada5b630`→`acce912dd`, `ffa4aac1a`→`95e026a3a`, `ef2617df8`→`e29acae68`, `75636c7ef`→`802e8cfda`, `37be35b9a`→`63bb31390`, `4b82fa29b`→`fcc2e8f32`, `1bc30a222`→`feb9cf043`, `338f659ca`→`988315880`); doc references above were remapped accordingly. In-source ERRATA comments in `crates/freshell-server/src/resolve.rs` still cite the pre-reword SHAs `bb357a598`/`ffa4aac1a`/`37be35b9a` (source deliberately left untouched by the message-only reword; map to `aa36247d9`/`95e026a3a`/`63bb31390`). Note: `origin/feat/rust-resolve-parity` still holds the pre-correction history, so publishing this branch will require a deliberate `git push --force-with-lease` by the user. ## Final release gates diff --git a/docs/plans/2026-07-29-rust-resolve-parity-spec.md b/docs/plans/2026-07-29-rust-resolve-parity-spec.md index 53b4dfa98..4bfc9c11c 100644 --- a/docs/plans/2026-07-29-rust-resolve-parity-spec.md +++ b/docs/plans/2026-07-29-rust-resolve-parity-spec.md @@ -2,8 +2,10 @@ ## Goal -Close checklist item **SYNC-06** in -`docs/plans/2026-07-14-rust-tauri-parity-completion-checklist.md:803`: implement +Deliver the Rust-server half of checklist item **SYNC-06** in +`docs/plans/2026-07-14-rust-tauri-parity-completion-checklist.md:803` (the item itself +stays unchecked until its `PW-TAURI-WIN` native-Windows validation, out of scope here, +also lands — see Requirement 8): implement `POST /api/sessions/resolve` in the **Rust server** (`crates/freshell-server`) with full behavior parity to the Node implementation, and declare the `sessionResolve` feature flag from the Rust server so the shared client shows the pinned sidebar Resume button on @@ -14,17 +16,24 @@ Rust endpoint's JSON must be wire-compatible with what the client already consum ## Source of truth (parity references — read these first) -- Contract: `shared/resume-resolve-contract.ts` (request `{ input: string, 1..20000, strict }`; - response `{ status: 'ready'|'warming', matches: ResumeResolveMatch[], hint: {provider, source: 'command'|'word'|'id-shape'}|null }`; +- Contract: `shared/resume-resolve-contract.ts` AT THIS WORKTREE'S HEAD — the HARDENED + (#586) shape (request `{ input: string, 1..20000, strict }`; + response `{ status: 'ready'|'warming'|'degraded', matches: ResumeResolveMatch[], hint: {provider, source: 'command'|'word'|'id-shape'}|null, providerErrors: {provider, code?, message?}[], unsearchedProviders: string[], homeDir?: string }`; match fields `provider, sessionId, cwd?, sessionType?, title?, firstUserMessage?, lastActivityAt?, matchKind: 'exact'|'prefix'`). - Rust serde must emit the exact same field names (camelCase) and types. + A failing provider surfaces as `status: 'degraded'` + a `providerErrors` entry — never a + silent empty "not found". Rust serde must emit the exact same field names (camelCase) + and types. - Node behavior: `server/sessions-router.ts` (routing, validation, error shapes/status codes, auth) and `server/coding-cli/resolve-session.ts` (matching semantics, ordering, result cap, fallbacks). - Input parsing + hints: `shared/resume-input-parser.ts` (token shapes: full UUIDs any - case; `ses_` + 26 base62 opencode ids; short hex prefixes ≥8 chars containing ≥1 digit; - noise stripping for command lines/quotes/prompts; candidate ordering; provider hints - from command shapes, agent words, and id-shape heuristics). + case; known `xxx_`-prefixed id families — the NINE prefixes + `ses|sess|session|thread|thr|run|msg|task|amp` each followed by an 8–64-char + `[0-9A-Za-z]` suffix (`PREFIXED_ID_RE`), of which `ses_` + 26 base62 is opencode's + first-class shape [CORRECTED 2026-07-31: this line originally named only the `ses_` + family]; short hex prefixes 8–32 chars containing ≥1 digit; noise stripping for + command lines/quotes/prompts; candidate ordering; provider hints from command shapes, + agent words, and id-shape heuristics). - Existing Rust infra: the session index and existence machinery in `crates/freshell-server/src/existence.rs` (exact-match `IndexExistenceProbe`, opencode by-id DB fallback `session_exists_by_id`, Unknown/warming states) and the per-provider @@ -34,9 +43,27 @@ Rust endpoint's JSON must be wire-compatible with what the client already consum ## Requirements 1. **Endpoint parity.** `POST /api/sessions/resolve` on the Rust server: same path, same - auth requirements as the Rust server's other API routes, same request validation - (reject missing/empty/oversized `input` and unknown body keys with the same status - codes/error shapes the Node router uses), same response schema. + auth requirements as the Rust server's other API routes, same response schema, and — + for JSON-object bodies — the same request validation (reject missing/empty/oversized + `input` and unknown body keys with the same status codes/error shapes the Node + router uses). [CORRECTED 2026-07-31: the original blanket "same validation and error + shapes" wording overstated what was required and what landed. Three narrow + divergences are DELIBERATE and ledgered in the "Accepted deviations" module doc of + `crates/freshell-server/src/resolve.rs` — all preserve status-code parity, differ + only in bodies/routing unreachable by any known client (the dialog treats any + non-2xx as request-failed without reading the body): (a) payloads Express's strict + body parser rejects with an HTML 400 before zod runs (malformed JSON; JSON scalars + string/number/bool/null) get a zod-shaped JSON 400 from Rust; (b) axum's default + 2 MB body-size limit vs Express `json({limit:'1mb'})`; (c) `PATCH`/`GET + /api/sessions/resolve` answer 405 on the merged Rust router where Express would + dispatch `:sessionId="resolve"` to another route. Everything else in this + requirement is met identically.] + [ERRATA 2026-07-31: a FOURTH divergence existed unrecorded until the + Content-Type gating fix — the Rust route parsed the body as JSON regardless of + `Content-Type`, so a valid object under e.g. `text/plain` resolved on Rust while + Node's `express.json()` (default `type: 'application/json'`) skips it and 400s + with the missing-`input` issue; the Rust route now gates parsing on the same + matcher (parity restored, not ledgered as accepted).] 2. **Parser parity.** Port `shared/resume-input-parser.ts` semantics to Rust exactly: token extraction, candidate ordering, and hint derivation must produce the same results for the same inputs. To prevent silent drift between the TS and Rust parsers, @@ -66,11 +93,17 @@ Rust endpoint's JSON must be wire-compatible with what the client already consum 7. **Feature flag.** Declare `sessionResolve` in the Rust server's feature-flags payload (its equivalent of `detectFeatureFlags()`), so the shared client renders the Resume button. Do not gate it on anything else. -8. **Checklist update.** Mark SYNC-06 done in - `docs/plans/2026-07-14-rust-tauri-parity-completion-checklist.md` with evidence, - following the file's existing entry conventions. The `PW-TAURI-WIN` (native Windows - WebView2) half of the validation remains explicitly out of scope, as prior entries - do — note it. +8. **Checklist update.** Record the SYNC-06 parity evidence in + `docs/plans/2026-07-14-rust-tauri-parity-completion-checklist.md` as a `PARTIAL` + bullet following the file's existing entry conventions (the SYNC-05/SAFE-11 + precedent), and KEEP the SYNC-06 checkbox UNCHECKED (`- [ ]`): the `PW-TAURI-WIN` + (native Windows WebView2) half of its named validation is explicitly out of scope + for this work, so the item cannot be marked done — the bullet must name what is + green and list `PW-TAURI-WIN` as `MISSING`. (This matches the implementation plan, + `docs/plans/2026-07-30-rust-resolve-parity-hardened.md` Task 7 Step 4, and the + checklist's current state. An earlier revision of this requirement said "Mark + SYNC-06 done", contradicting the out-of-scope declaration above — corrected + 2026-07-31.) ## Verification diff --git a/docs/plans/2026-07-29-rust-resolve-parity.md b/docs/plans/2026-07-29-rust-resolve-parity.md new file mode 100644 index 000000000..fb14bfe36 --- /dev/null +++ b/docs/plans/2026-07-29-rust-resolve-parity.md @@ -0,0 +1,3165 @@ +# SYNC-06: Rust Server Resume-Resolve Parity Implementation Plan + +> ## ⚠️ ARCHIVED / SUPERSEDED — DO NOT EXECUTE ⚠️ +> +> **This plan is superseded by +> [`docs/plans/2026-07-30-rust-resolve-parity-hardened.md`](2026-07-30-rust-resolve-parity-hardened.md).** +> It is retained only as a historical record of the first (pre-#586) pass. +> +> - It implements the **RETIRED pre-#586 resolve contract**, including the +> retired OpenCode parent-chain resolver (`resolveOpencodeSessionRoots` +> parent-walk) that main's hardened Node implementation replaced with a +> direct by-id row query — while claiming full parity it does not deliver. +> - Its "Parity reference — Node behavior being ported" section (lines ~62-72) +> describes the **obsolete pre-#586 Node behavior**, not the hardened +> contract (`degraded` status, `providerErrors`, `unsearchedProviders`, +> `homeDir` are all absent). +> - Its expected test counts (line ~3103, "32 passed; 14 passed") are **stale** +> and do not match the current tree. +> - Its own final checklist step confirms this plan targeted the retired +> implementation; the completion checklist's SYNC-06 `PARTIAL / REOPENED +> (2026-07-30)` entry records the reopening. +> +> **DO NOT EXECUTE.** Re-executing this plan would reintroduce defects already +> resolved by the hardened plan, and its indexed-Codex E2E gate could still +> pass while doing so. Execute the hardened plan instead. + +> **For agentic workers:** This plan is executed task-by-task by the +> workflow's execute stage: a fresh implementer per task, with a spec + +> quality review after each task. Steps use checkbox (`- [ ]`) syntax +> for tracking. + +**Goal:** Implement `POST /api/sessions/resolve` in the Rust server (`crates/freshell-server`) with full behavior parity to the Node implementation, declare the `sessionResolve` feature flag from the Rust server so the shared client shows the pinned sidebar Resume button on Rust builds, and prove it with cross-language parser fixtures plus the `resume-button` e2e spec green on BOTH Playwright projects. + +**Architecture:** A pure Rust port of `shared/resume-input-parser.ts` and the resolve matching core live in `crates/freshell-sessions` (`resume_input.rs`, `resume_resolve.rs`), pinned to the TS implementation by a shared JSON fixture table both test suites consume. A new focused axum module `crates/freshell-server/src/resolve.rs` owns the HTTP route (auth → zod-shaped validation → resolve core), reading the existing `SessionIndex` for evidence (filtered through the settings store's `deleted` session overrides, exactly like the Rust sidebar), the `SessionMetadataStore` for the `sessionType` overlay, and two exact-id fallbacks built on existing Rust machinery (the claude transcript locator paired with its exported cwd reader; a bug-for-bug port of Node's opencode by-id sqlite parent-chain walk). + +**Tech Stack:** Rust (axum 0.8, tokio, serde/serde_json with `preserve_order`, `regex`, rusqlite), TypeScript (vitest, zod), Playwright. + +## Global Constraints + +- Work ONLY inside the worktree `/home/dan/code/freshell/.worktrees/rust-resolve-parity` on branch `feat/rust-resolve-parity`. Never commit to `main`. Never push to `origin/main`. Do NOT create a PR (requires explicit user approval). +- Git author for every commit: `Dan Shapiro <3732858+danshapiro@users.noreply.github.com>`. Verify with `git log -1 --format='%an <%ae>'` after the first commit; if wrong, amend and prefix subsequent commits with `git -c user.name="Dan Shapiro" -c user.email="3732858+danshapiro@users.noreply.github.com" commit …`. +- Do NOT modify `shared/resume-resolve-contract.ts`, `shared/resume-input-parser.ts` behavior, or any Node server behavior. Allowed exceptions: refactoring `test/unit/shared/resume-input-parser.test.ts` to consume the shared fixture (behavior-identical), and a comment-only update in `server/platform-router.ts`. +- Wire parity is byte-shape parity: response JSON field ORDER matches the Node object literals (`serde_json` `preserve_order` is on workspace-wide; struct field order controls serde output order), optional match fields are OMITTED when absent (`skip_serializing_if`), and `hint` is `null` (never omitted) when absent. +- Constants copied from Node: result cap `RESOLVE_MATCH_CAP = 20`; request `input` length `1..=20000` counted in UTF-16 code units (zod `.min(1).max(20000)` semantics); validation failure is `400 { "error": "Invalid resolve request", "details": [...] }`; "not found" is NEVER 404 — it is `200 { "status": "ready", "matches": [] }`. +- 400 `details` parity: the issue literals (field set, key ORDER, message wording) must match the ACTUAL zod 4.3.6 wire output as probed against the real `ResumeResolveRequestSchema` — e.g. `"Invalid input: expected string, received undefined"`, double-quoted `"Unrecognized keys: \"a\", \"b\""` (singular `"Unrecognized key: \"a\""`), `origin`/`inclusive` fields on `too_small`/`too_big`, and `expected`/`origin` emitted BEFORE `code` (the workspace-wide `preserve_order` feature + `json!` insertion order provide this). Recorded facts: (a) NO consumer reads `details` — the client resume dialog treats any non-2xx as request-failed without inspecting the body, and the Node integration test asserts only status + `error` — so this is test-pinned parity, not consumer-load-bearing; (b) the literals are pinned to zod 4.3.6 and are VERSION-FRAGILE: any future zod bump requires re-probing the real wire output and updating both the Rust literals and Task 6's tests. Accepted deviations (status parity only): payloads Express's strict body parser rejects with an HTML 400 BEFORE zod runs — malformed JSON and JSON scalar bodies (string/number/bool/null) — get the zod-shaped JSON 400 from Rust instead; axum's default 2 MB body limit vs Express `json({ limit: '1mb' })`; and `PATCH`/`GET /api/sessions/resolve` answer 405 on the merged Rust router where Express would dispatch `:sessionId="resolve"` (unreachable by any known client). +- Vitest: NEVER run raw `npx vitest`. Use `npm run test:vitest -- --config --run`. Before any broad run, check `npm run test:status`; set `FRESHELL_TEST_SUMMARY="SYNC-06 rust resolve parity"` on broad runs. +- Rust gates (CI-enforced): `cargo fmt --all -- --check` clean, `cargo clippy --workspace --all-targets -- -D warnings` clean. `cargo test --workspace` requires `node_modules` present (`test -d node_modules || npm ci --no-audit --no-fund`). +- Process safety: never use broad kill patterns (`pkill -f node`, `pkill -f vite`, …). The live self-hosted Rust server on port 3002 must NEVER be restarted (building is fine). The Playwright harness manages its own server PIDs — let it. +- Rust toolchain: workspace `rust-version = "1.96"` (`std::sync::LazyLock` is available). axum 0.8 path syntax is `{param}`, not `:param`. +- Keep files focused: do not grow `sessions.rs` (944 lines) — the new endpoint gets its own module. Structural limits: ≤1K lines/file. +- README.md is the only end-user markdown doc; this plan and the parity-checklist edit are working/agent docs (allowed). Create no other markdown files. +- Commits: Conventional Commits with scope, one focused commit per task step where marked. + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `test/fixtures/resume-input/parser-cases.json` | Create | Single source of truth for parser behavior — consumed by BOTH the TS unit test and the Rust parity test (spec Requirement 2's anti-drift mechanism) | +| `test/unit/shared/resume-input-parser.test.ts` | Rewrite | TS parser test, now fixture-driven | +| `crates/freshell-sessions/Cargo.toml` | Modify | Add `regex = "1"` dependency | +| `crates/freshell-sessions/src/lib.rs` | Modify | Register `resume_input` + `resume_resolve` modules | +| `crates/freshell-sessions/src/resume_input.rs` | Create | Rust port of `shared/resume-input-parser.ts` (pure, no IO) | +| `crates/freshell-sessions/tests/resume_input_parser_parity.rs` | Create | Fixture-driven parser parity test | +| `crates/freshell-sessions/src/parse/opencode.rs` | Modify | Add `opencode_session_directory_by_id` (bug-for-bug port of Node's by-id parent-walk, incl. legacy-schema early hit + truthy-directory filter) | +| `crates/freshell-sessions/src/parse/mod.rs` | Modify | Re-export the new helper + type | +| `crates/freshell-sessions/tests/opencode_directory_by_id.rs` | Create | Sqlite fixture test for the new helper | +| `crates/freshell-freshagent/src/claude_snapshot.rs` | Modify | Promote `transcript_cwd` to `pub` | +| `crates/freshell-freshagent/src/lib.rs` | Modify | Re-export `transcript_cwd` | +| `crates/freshell-freshagent/tests/transcript_cwd_export.rs` | Create | Proves the export + first-non-empty-cwd semantics | +| `crates/freshell-sessions/src/resume_resolve.rs` | Create | Resolve core: wire types (serde) + matching/dedupe/cap/fallback logic over `IndexedSession` | +| `crates/freshell-sessions/tests/resume_resolve.rs` | Create | Logic tests mirroring `test/integration/server/sessions-resolve-router.test.ts` | +| `crates/freshell-server/src/session_metadata.rs` | Modify | Un-gate the `get_all` read (`#[cfg(test)]` → production); `get` stays test-gated (no production caller) | +| `crates/freshell-server/src/resolve.rs` | Create | HTTP endpoint: `ResolveState`, router, auth, validation, handler + in-file oneshot tests | +| `crates/freshell-server/src/main.rs` | Modify | `mod resolve;` + wiring (index clone, metadata clone, settings-store clone for the deleted-override filter, fallback closures) + `sessionResolve` feature flag + flag test updates | +| `server/platform-router.ts` | Modify | Comment-only: the "Rust omits this key" note is now stale | +| `test/e2e-browser/specs/resume-button.spec.ts` | Modify | Delete the `RUST_SKIP` guard (3 call sites + const) | +| `test/e2e-browser/playwright.config.ts` | Modify | Add `resume-button.spec.ts` to `MATRIX_SPECS` | +| `docs/plans/2026-07-14-rust-tauri-parity-completion-checklist.md` | Modify | SYNC-06 evidence entry | + +Reference sources (read-only, do not modify): `shared/resume-resolve-contract.ts`, `shared/resume-input-parser.ts`, `server/sessions-router.ts:243-257`, `server/coding-cli/resolve-session.ts`, `server/coding-cli/claude-transcript-locator.ts`, `test/integration/server/sessions-resolve-router.test.ts`. + +## Parity reference — Node behavior being ported (read once, keep handy) + +- Handler (`server/sessions-router.ts:243-257`): zod `ResumeResolveRequestSchema.safeParse(req.body ?? {})`; failure → `400 { error: 'Invalid resolve request', details: issues }`; success → `res.json(await resolveResumeInput(input, deps))` (always 200). +- Core (`server/coding-cli/resolve-session.ts`): parse input → if index not ready return `{status:'warming', matches:[], hint}` → if no candidates return `{status:'ready', matches:[], hint}` → per candidate (priority order): case-insensitive exact-else-prefix bucket over ALL sessions (all four providers, one flat list from `getProjects().flatMap(g => g.sessions)` — which is Node's POST-deleted-override-filter project groups, `session-indexer.ts:209,1155-1156`; the hint never filters); exact wins wholesale; first candidate with any match short-circuits; sort `lastActivityAt` DESC (missing=0, stable), dedupe by `provider:sessionId` (first survivor = most recent), cap 20. Only if EVERY candidate missed: fallback loop per candidate (BOTH fallbacks bypass the index and its overrides) — (a) `prefixed-id` starting `ses_` → opencode by-id parent-chain WALK (`providers/opencode.ts:239-323`), NOT a bare row read: legacy schema without `parent_id` → EVERY requested id hits with cwd omitted (early return, no row query, no existence check, `opencode.ts:246-250`); modern schema → fetch the row (missing row = miss), keep its `directory` only if TRUTHY (empty string ⇒ cwd omitted, `opencode.ts:265-267,281`), walk `parent_id` with a seen-set — missing parent or cycle ⇒ MISS even though the row exists (`opencode.ts:283-303`, `resolve-session.ts:66`); a hit yields exactly ONE match `{provider:'opencode', sessionId:, cwd?:, sessionType:'opencode', matchKind:'exact'}` (`cwd` omitted when none collected); (b) `uuid` → claude transcript locator, hit yields exactly ONE match `{provider:'claude', sessionId:, cwd:, sessionType:'claude', matchKind:'exact'}`. Nothing found → `{status:'ready', matches:[], hint}`. +- Index-match metadata (`toMatch`): `{provider, sessionId, cwd: session.cwd ?? session.projectPath, sessionType: session.sessionType, title, firstUserMessage, lastActivityAt, matchKind}` — in Node, `sessionType` comes from a SessionMetadataStore overlay (`session-indexer.ts:1159-1161`) and is usually `undefined`; the client falls back to `sessionType ?? provider`. +- Flag (`server/platform-router.ts`): `detectFeatureFlags()` returns unconditional `sessionResolve: true`; the client gate is strict `featureFlags?.sessionResolve === true`. +- Recorded deviations (accepted — none observable under the e2e harness or default config; each line states the direction): + - enabledProviders config gate: Node skips providers disabled in `settings.codingCli.enabledProviders` (`session-indexer.ts:1140`); the Rust snapshot has no provider filter — Rust returns a disabled provider's sessions where Node wouldn't. + - 256 KiB cwd snippet window: Node's full parse reads a head+tail snippet (`session-indexer.ts:20,228-270`) and permanently excludes a >256 KiB transcript whose only `cwd` line sits mid-file; Rust reads whole files — Rust returns such sessions where Node wouldn't. + - Cold-start transient window: right after boot Node's lightweight scan (4 KiB head, top-150 enrichment) can miss sessions until its next full rescan; Rust's `warm()` fully parses before publishing — Rust returns sessions transiently where Node wouldn't. + - Tie-order / recency-fallback deltas: among equal `lastActivityAt` the match order and dedupe survivor can differ (Node group-sorted flatMap order vs Rust `lastActivityAt DESC, key() DESC` pre-sort); Node falls back to `createdAt`/mtime for a missing recency value, Rust sorts it as 0 — different ORDER (not membership) on ties. + - Claude-locator deltas (Task 4's reuse, per the A6 validation): multi-root scan incl. `CLAUDE_CONFIG_DIR` and one-subdir-deeper layouts — Rust hits (exact match) where Node misses, bug-fix-flavored since the real claude CLI honors `CLAUDE_CONFIG_DIR`; cwd found past Node's 64 KiB read cap — Rust supplies `cwd` where Node omits it; invalid-UTF-8 line before the first cwd line — Node supplies `cwd` where Rust omits it. + - Transport: malformed-JSON and JSON-scalar bodies get a zod-shaped JSON 400 from Rust where Express emits an HTML 400 (status parity only); axum's default 2 MB body cap vs Express 1 MB; `PATCH`/`GET /api/sessions/resolve` → 405 on Rust where Express dispatches `:sessionId="resolve"`. + +--- + +### Task 1: Shared cross-language parser fixtures + +Extract the TS parser test table into a JSON fixture that both suites will consume. This is spec Requirement 2's anti-drift mechanism: one committed table, two implementations that must pass it. + +**Files:** +- Create: `test/fixtures/resume-input/parser-cases.json` +- Modify: `test/unit/shared/resume-input-parser.test.ts` (full rewrite, behavior-identical assertions) + +**Interfaces:** +- Consumes: `parseResumeInput(text: string): { candidates: {token, kind}[], hint: {provider, source} | null }` from `@shared/resume-input-parser` (unchanged). +- Produces: `test/fixtures/resume-input/parser-cases.json` with shape `{ "cases": [{ "name", "input", "candidates": [{"token","kind"}], "hint": {"provider","source"} | null }] }` — Task 2's Rust test reads this exact file at this exact path. + +- [ ] **Step 0: Install node deps in the worktree (precondition for EVERY npm/vitest step in this plan)** + +The worktree starts with NO `node_modules`. `npm run test:vitest` shells through `tsx` (an uninstalled devDependency), so Step 3 below — and every later npm/typecheck/vitest step, and any `cargo test -p freshell-server` run that spawns the committed Node fixture `fake-app-server.mjs` (it imports `ws`) — needs deps installed first: + +```bash +cd /home/dan/code/freshell/.worktrees/rust-resolve-parity +test -d node_modules || npm ci --no-audit --no-fund +``` + +This guard is idempotent; later tasks repeat it defensively for fresh-implementer safety, but it MUST run here first. + +- [ ] **Step 1: Write the fixture** + +Every case asserts BOTH candidates and hint (a strict superset of the current suite's per-case assertions — the current suite checks one or the other). Cases 1–25 are the existing suite's inputs verbatim — including the bare-v7-uuid case ("bare v7 uuid" below, the existing suite's `['uuid v7 shape', V7, { provider: 'codex', source: 'id-shape' }]` row), which is the ONLY case exercising the `version === '7' → codex` id-shape branch of `deriveHint` with a bare id (the other V7 inputs are `codex resume …` command-source hints, so dropping it would leave that branch uncovered in both languages); 26–31 pin previously-untested port hazards (stable hex sort, non-`ses_` prefixed ids, uuid versions other than 4/7, case preservation, sub-8-char hex, `-rf` command-shape miss). + +Create `test/fixtures/resume-input/parser-cases.json`: + +```json +{ + "$comment": "SYNC-06 shared parser fixture. Consumed by test/unit/shared/resume-input-parser.test.ts AND crates/freshell-sessions/tests/resume_input_parser_parity.rs. Both implementations of the resume-input parser must pass every case. Add cases here, never inline, so the TS and Rust parsers cannot drift.", + "cases": [ + { + "name": "bare short hex", + "input": "417e8345", + "candidates": [{ "token": "417e8345", "kind": "hex-prefix" }], + "hint": { "provider": "amplifier", "source": "id-shape" } + }, + { + "name": "bare v4 uuid", + "input": "ed2afda6-a340-443e-ba60-024a1b3554b4", + "candidates": [{ "token": "ed2afda6-a340-443e-ba60-024a1b3554b4", "kind": "uuid" }], + "hint": { "provider": "claude", "source": "id-shape" } + }, + { + "name": "bare v7 uuid", + "input": "019fac27-69d7-78a0-b972-b339d551042e", + "candidates": [{ "token": "019fac27-69d7-78a0-b972-b339d551042e", "kind": "uuid" }], + "hint": { "provider": "codex", "source": "id-shape" } + }, + { + "name": "bare opencode id", + "input": "ses_root0000000000000000000000", + "candidates": [{ "token": "ses_root0000000000000000000000", "kind": "prefixed-id" }], + "hint": { "provider": "opencode", "source": "id-shape" } + }, + { + "name": "codex resume command", + "input": "codex resume 019fac27-69d7-78a0-b972-b339d551042e", + "candidates": [{ "token": "019fac27-69d7-78a0-b972-b339d551042e", "kind": "uuid" }], + "hint": { "provider": "codex", "source": "command" } + }, + { + "name": "claude --resume command", + "input": "claude --resume ed2afda6-a340-443e-ba60-024a1b3554b4", + "candidates": [{ "token": "ed2afda6-a340-443e-ba60-024a1b3554b4", "kind": "uuid" }], + "hint": { "provider": "claude", "source": "command" } + }, + { + "name": "claude -r command with prompt", + "input": "$ claude -r ed2afda6-a340-443e-ba60-024a1b3554b4", + "candidates": [{ "token": "ed2afda6-a340-443e-ba60-024a1b3554b4", "kind": "uuid" }], + "hint": { "provider": "claude", "source": "command" } + }, + { + "name": "opencode --session command", + "input": "opencode --session ses_root0000000000000000000000", + "candidates": [{ "token": "ses_root0000000000000000000000", "kind": "prefixed-id" }], + "hint": { "provider": "opencode", "source": "command" } + }, + { + "name": "amplifier --resume short id", + "input": "amplifier --resume 417e8345", + "candidates": [{ "token": "417e8345", "kind": "hex-prefix" }], + "hint": { "provider": "amplifier", "source": "command" } + }, + { + "name": "quoted and padded", + "input": " \"claude --resume ed2afda6-a340-443e-ba60-024a1b3554b4\" ", + "candidates": [{ "token": "ed2afda6-a340-443e-ba60-024a1b3554b4", "kind": "uuid" }], + "hint": { "provider": "claude", "source": "command" } + }, + { + "name": "backticks", + "input": "`417e8345`", + "candidates": [{ "token": "417e8345", "kind": "hex-prefix" }], + "hint": { "provider": "amplifier", "source": "id-shape" } + }, + { + "name": "id embedded in a path", + "input": "/home/x/.claude/projects/foo/ed2afda6-a340-443e-ba60-024a1b3554b4.jsonl", + "candidates": [{ "token": "ed2afda6-a340-443e-ba60-024a1b3554b4", "kind": "uuid" }], + "hint": { "provider": "claude", "source": "word" } + }, + { + "name": "trailing punctuation", + "input": "session 417e8345.", + "candidates": [{ "token": "417e8345", "kind": "hex-prefix" }], + "hint": { "provider": "amplifier", "source": "id-shape" } + }, + { + "name": "ansi codes", + "input": "\u001b[32m417e8345\u001b[0m", + "candidates": [{ "token": "417e8345", "kind": "hex-prefix" }], + "hint": { "provider": "amplifier", "source": "id-shape" } + }, + { + "name": "multi-line noise", + "input": "To continue:\n$ codex resume 019fac27-69d7-78a0-b972-b339d551042e\nor open the app", + "candidates": [{ "token": "019fac27-69d7-78a0-b972-b339d551042e", "kind": "uuid" }], + "hint": { "provider": "codex", "source": "command" } + }, + { + "name": "english hex-looking word", + "input": "decade", + "candidates": [], + "hint": null + }, + { + "name": "facade sentence", + "input": "I spent a decade behind a facade", + "candidates": [], + "hint": null + }, + { + "name": "hex without digits", + "input": "deadbeef", + "candidates": [], + "hint": null + }, + { + "name": "garbage", + "input": "hello world!! no ids here", + "candidates": [], + "hint": null + }, + { + "name": "empty", + "input": "", + "candidates": [], + "hint": null + }, + { + "name": "orders prefixed ids, then uuids, then hex prefixes longest-first", + "input": "417e8345 ed2afda6-a340-443e-ba60-024a1b3554b4 ses_root0000000000000000000000 417e8345abcd", + "candidates": [ + { "token": "ses_root0000000000000000000000", "kind": "prefixed-id" }, + { "token": "ed2afda6-a340-443e-ba60-024a1b3554b4", "kind": "uuid" }, + { "token": "417e8345abcd", "kind": "hex-prefix" }, + { "token": "417e8345", "kind": "hex-prefix" } + ], + "hint": { "provider": "opencode", "source": "id-shape" } + }, + { + "name": "dedupes repeated tokens case-insensitively keeping the first casing", + "input": "ed2afda6-a340-443e-ba60-024a1b3554b4 ED2AFDA6-A340-443E-BA60-024A1B3554B4", + "candidates": [{ "token": "ed2afda6-a340-443e-ba60-024a1b3554b4", "kind": "uuid" }], + "hint": { "provider": "claude", "source": "id-shape" } + }, + { + "name": "caps hex tokens at 32 chars so git shas do not match", + "input": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "candidates": [], + "hint": null + }, + { + "name": "agent word only", + "input": "the claude session ed2afda6-a340-443e-ba60-024a1b3554b4", + "candidates": [{ "token": "ed2afda6-a340-443e-ba60-024a1b3554b4", "kind": "uuid" }], + "hint": { "provider": "claude", "source": "word" } + }, + { + "name": "returns null hint for prose without ids or agent words", + "input": "nothing to see", + "candidates": [], + "hint": null + }, + { + "name": "equal-length hex tokens keep text order (stable sort)", + "input": "417e8345 88997766", + "candidates": [ + { "token": "417e8345", "kind": "hex-prefix" }, + { "token": "88997766", "kind": "hex-prefix" } + ], + "hint": { "provider": "amplifier", "source": "id-shape" } + }, + { + "name": "non-ses prefixed id yields no id-shape hint", + "input": "abc_12345678", + "candidates": [{ "token": "abc_12345678", "kind": "prefixed-id" }], + "hint": null + }, + { + "name": "uuid version other than 4 or 7 yields no id-shape hint", + "input": "ed2afda6-a340-143e-ba60-024a1b3554b4", + "candidates": [{ "token": "ed2afda6-a340-143e-ba60-024a1b3554b4", "kind": "uuid" }], + "hint": null + }, + { + "name": "uppercase uuid token is preserved as written", + "input": "ED2AFDA6-A340-443E-BA60-024A1B3554B4", + "candidates": [{ "token": "ED2AFDA6-A340-443E-BA60-024A1B3554B4", "kind": "uuid" }], + "hint": { "provider": "claude", "source": "id-shape" } + }, + { + "name": "seven hex chars are not a candidate", + "input": "417e834", + "candidates": [], + "hint": null + }, + { + "name": "claude -rf does not match the -r command shape but the word still hints", + "input": "claude -rf ed2afda6-a340-443e-ba60-024a1b3554b4", + "candidates": [{ "token": "ed2afda6-a340-443e-ba60-024a1b3554b4", "kind": "uuid" }], + "hint": { "provider": "claude", "source": "word" } + } + ] +} +``` + +- [ ] **Step 2: Rewrite the TS test to consume the fixture** + +Replace the entire contents of `test/unit/shared/resume-input-parser.test.ts` with: + +```ts +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { parseResumeInput } from '@shared/resume-input-parser' + +// SYNC-06 anti-drift mechanism: the SAME fixture table is consumed by +// crates/freshell-sessions/tests/resume_input_parser_parity.rs. Every case +// asserts BOTH candidates and hint. Add cases to the fixture, never inline. +interface FixtureCase { + name: string + input: string + candidates: Array<{ token: string; kind: string }> + hint: { provider: string; source: string } | null +} + +const { cases } = JSON.parse( + readFileSync(new URL('../../fixtures/resume-input/parser-cases.json', import.meta.url), 'utf8'), +) as { cases: FixtureCase[] } + +describe('parseResumeInput — shared fixture parity', () => { + it('fixture is non-trivial', () => { + expect(cases.length).toBeGreaterThanOrEqual(31) + }) + + it.each(cases.map((c) => [c.name, c] as const))('%s', (_name, c) => { + const parsed = parseResumeInput(c.input) + expect(parsed.candidates).toEqual(c.candidates) + expect(parsed.hint).toEqual(c.hint) + }) +}) +``` + +- [ ] **Step 3: Run the TS test — must pass without touching the parser** + +```bash +cd /home/dan/code/freshell/.worktrees/rust-resolve-parity +npm run test:vitest -- --config config/vitest/vitest.config.ts test/unit/shared/resume-input-parser.test.ts --run +``` + +Expected: 32 passed (31 cases + the non-trivial guard), 0 failed. If any case fails, the FIXTURE is wrong (the TS parser is the reference — do not change it); re-derive the expected value from `shared/resume-input-parser.ts` semantics and fix the fixture. + +- [ ] **Step 4: Commit** + +```bash +git add test/fixtures/resume-input/parser-cases.json test/unit/shared/resume-input-parser.test.ts +git commit -m "test(shared): extract resume-input parser cases into cross-language fixture (SYNC-06)" +``` + +--- + +### Task 2: Rust parser port (`resume_input.rs`) + +Port `shared/resume-input-parser.ts` to Rust, exactly. The fixture from Task 1 is the oracle. + +**Files:** +- Modify: `crates/freshell-sessions/Cargo.toml` (add `regex`) +- Modify: `crates/freshell-sessions/src/lib.rs` (register module) +- Create: `crates/freshell-sessions/src/resume_input.rs` +- Test: `crates/freshell-sessions/tests/resume_input_parser_parity.rs` + +**Interfaces:** +- Consumes: `test/fixtures/resume-input/parser-cases.json` (Task 1). +- Produces (used by Task 5's resolve core and Task 6's handler): + - `freshell_sessions::resume_input::parse_resume_input(text: &str) -> ResumeInputParse` + - `pub struct ResumeInputParse { pub candidates: Vec, pub hint: Option }` + - `pub struct ResumeCandidate { pub token: String, pub kind: ResumeCandidateKind }` + - `pub enum ResumeCandidateKind { PrefixedId, Uuid, HexPrefix }` (serde: `"prefixed-id" | "uuid" | "hex-prefix"`) + - `pub struct ResumeHint { pub provider: ResumeHintProvider, pub source: ResumeHintSource }` (serde: `{"provider": "claude|codex|opencode|amplifier", "source": "command|word|id-shape"}`) + +- [ ] **Step 1: Write the failing fixture-parity test** + +Create `crates/freshell-sessions/tests/resume_input_parser_parity.rs`: + +```rust +//! SYNC-06 cross-language parser parity: the SAME fixture table that pins +//! `shared/resume-input-parser.ts` (via `test/unit/shared/resume-input-parser.test.ts`) +//! pins this port. If either implementation changes behavior, exactly one of +//! the two suites goes red — silent drift is impossible. + +use freshell_sessions::resume_input::parse_resume_input; + +#[derive(serde::Deserialize)] +struct Fixture { + cases: Vec, +} + +#[derive(serde::Deserialize)] +struct Case { + name: String, + input: String, + candidates: serde_json::Value, + hint: serde_json::Value, +} + +#[test] +fn parser_matches_every_shared_fixture_case() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test/fixtures/resume-input/parser-cases.json"); + let raw = std::fs::read_to_string(&path).expect("read shared parser fixture"); + let fixture: Fixture = serde_json::from_str(&raw).expect("parse fixture json"); + assert!( + fixture.cases.len() >= 31, + "shared fixture unexpectedly small: {}", + fixture.cases.len() + ); + for case in &fixture.cases { + let parsed = parse_resume_input(&case.input); + let candidates = serde_json::to_value(&parsed.candidates).expect("serialize candidates"); + let hint = serde_json::to_value(&parsed.hint).expect("serialize hint"); + assert_eq!( + candidates, case.candidates, + "candidates mismatch for case '{}' (input {:?})", + case.name, case.input + ); + assert_eq!( + hint, case.hint, + "hint mismatch for case '{}' (input {:?})", + case.name, case.input + ); + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +cd /home/dan/code/freshell/.worktrees/rust-resolve-parity +cargo test -p freshell-sessions --test resume_input_parser_parity +``` + +Expected: COMPILE ERROR — `unresolved import freshell_sessions::resume_input` (module does not exist yet). + +- [ ] **Step 3: Add the `regex` dependency and register the module** + +In `crates/freshell-sessions/Cargo.toml`, under `[dependencies]`, after the `rusqlite` entry, add: + +```toml +# SYNC-06 resume-resolve parity: the `shared/resume-input-parser.ts` port's +# candidate-extraction regexes + hint tables (`resume_input.rs`). `(?-u:\b)` +# keeps JS's ASCII \b word-boundary semantics. Same major already a direct +# dep of freshell-server (logging.rs redaction scrub). +regex = "1" +``` + +In `crates/freshell-sessions/src/lib.rs`, after the existing `pub mod parse;` line in the module list, add (keeping the list alphabetical: `resume_input` and `resume_resolve` sort after `parse`, before `search`): + +```rust +pub mod resume_input; +pub mod resume_resolve; +``` + +> Note: `resume_resolve` is created in Task 5. To keep this task compiling on its own, add ONLY `pub mod resume_input;` now; Task 5 adds `pub mod resume_resolve;`. + +- [ ] **Step 4: Write the parser** + +Create `crates/freshell-sessions/src/resume_input.rs`: + +```rust +//! Rust port of `shared/resume-input-parser.ts` — a pure, dependency-free +//! parser that extracts candidate session ids and an advisory provider hint +//! from arbitrary pasted text. Hints only assist the UI — session-store +//! evidence decides the provider. +//! +//! PARITY-PINNED: both this port and the TS original are driven by the shared +//! fixture `test/fixtures/resume-input/parser-cases.json` +//! (`tests/resume_input_parser_parity.rs` here, +//! `test/unit/shared/resume-input-parser.test.ts` there). Behavior changes go +//! through the fixture first. +//! +//! Port notes (things that look odd but are load-bearing): +//! - `(?-u:\b)` everywhere a JS `\b` appears: JS word boundaries are ASCII +//! (`[A-Za-z0-9_]`); Rust's default `\b` is Unicode-aware and would diverge +//! on inputs like `é417e8345`. +//! - The ANSI CSI strip replaces each escape with ONE space (length-changing); +//! hint derivation reads that `sanitized` text, so earliest-match indices +//! shift with it. Do not "fix" this to a length-preserving mask. +//! - Extraction masks each match with `' '.repeat(len)` (length-preserving) +//! so UUID hex groups never re-match as hex prefixes. All matched chars are +//! ASCII, so byte length == char length. +//! - Hex tokens sort by length DESC with a STABLE sort (JS `Array.sort` is +//! stable): equal-length tokens keep extraction (text) order. + +use std::collections::HashSet; +use std::sync::LazyLock; + +use regex::Regex; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum ResumeCandidateKind { + #[serde(rename = "prefixed-id")] + PrefixedId, + #[serde(rename = "uuid")] + Uuid, + #[serde(rename = "hex-prefix")] + HexPrefix, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct ResumeCandidate { + pub token: String, + pub kind: ResumeCandidateKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ResumeHintProvider { + Claude, + Codex, + Opencode, + Amplifier, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +pub enum ResumeHintSource { + #[serde(rename = "command")] + Command, + #[serde(rename = "word")] + Word, + #[serde(rename = "id-shape")] + IdShape, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct ResumeHint { + pub provider: ResumeHintProvider, + pub source: ResumeHintSource, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResumeInputParse { + /// Candidate tokens in resolution-priority order. + pub candidates: Vec, + pub hint: Option, +} + +static ANSI_ESCAPE_RE: LazyLock = + LazyLock::new(|| Regex::new(r"\x1b\[[0-9;?]*[0-9A-Za-z]").expect("static regex")); +static UUID_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") + .expect("static regex") +}); +// ses_ + 26 base62 is the first-class shape; the generic form also accepts +// other known xxx_-prefixed id families. +static PREFIXED_ID_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?-u:\b)[a-z]{2,10}_[0-9A-Za-z]{8,40}(?-u:\b)").expect("static regex")); +// >=8 hex chars, <=32; must contain a digit (filters decade/facade/deadbeef). +static HEX_PREFIX_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?-u:\b)[0-9a-fA-F]{8,32}(?-u:\b)").expect("static regex")); + +static COMMAND_HINTS: LazyLock> = LazyLock::new(|| { + vec![ + ( + Regex::new(r"(?i)(?-u:\b)claude\s+(?:--resume|-r)(?-u:\b)").expect("static regex"), + ResumeHintProvider::Claude, + ), + ( + Regex::new(r"(?i)(?-u:\b)codex\s+resume(?-u:\b)").expect("static regex"), + ResumeHintProvider::Codex, + ), + ( + Regex::new(r"(?i)(?-u:\b)opencode\s+--session(?-u:\b)").expect("static regex"), + ResumeHintProvider::Opencode, + ), + ( + Regex::new(r"(?i)(?-u:\b)amplifier\s+(?:--resume|resume)(?-u:\b)").expect("static regex"), + ResumeHintProvider::Amplifier, + ), + ] +}); + +static WORD_HINTS: LazyLock> = LazyLock::new(|| { + vec![ + ( + Regex::new(r"(?i)(?-u:\b)claude(?-u:\b)").expect("static regex"), + ResumeHintProvider::Claude, + ), + ( + Regex::new(r"(?i)(?-u:\b)codex(?-u:\b)").expect("static regex"), + ResumeHintProvider::Codex, + ), + ( + Regex::new(r"(?i)(?-u:\b)opencode(?-u:\b)").expect("static regex"), + ResumeHintProvider::Opencode, + ), + ( + Regex::new(r"(?i)(?-u:\b)amplifier(?-u:\b)").expect("static regex"), + ResumeHintProvider::Amplifier, + ), + ] +}); + +/// `extractAndMask`: push every match, replace it with a same-length run of +/// spaces so later passes cannot re-match inside it. +fn extract_and_mask(text: &str, re: &Regex, out: &mut Vec) -> String { + re.replace_all(text, |caps: ®ex::Captures<'_>| { + let m = caps.get(0).expect("group 0 always present").as_str(); + out.push(m.to_string()); + " ".repeat(m.len()) + }) + .into_owned() +} + +/// `earliestHint`: run every regex, keep the provider with the smallest match +/// start. Ties break by table order (strict `<`, first entry wins) — same as +/// the TS original. Byte offsets vs UTF-16 offsets order matches identically +/// (the mapping is monotonic). +fn earliest_hint(text: &str, table: &[(Regex, ResumeHintProvider)]) -> Option { + let mut best: Option = None; + let mut best_index = usize::MAX; + for (re, provider) in table { + if let Some(m) = re.find(text) { + if m.start() < best_index { + best_index = m.start(); + best = Some(*provider); + } + } + } + best +} + +fn derive_hint(text: &str, candidates: &[ResumeCandidate]) -> Option { + if let Some(provider) = earliest_hint(text, &COMMAND_HINTS) { + return Some(ResumeHint { + provider, + source: ResumeHintSource::Command, + }); + } + if let Some(provider) = earliest_hint(text, &WORD_HINTS) { + return Some(ResumeHint { + provider, + source: ResumeHintSource::Word, + }); + } + let top = candidates.first()?; + match top.kind { + ResumeCandidateKind::PrefixedId => { + if top.token.starts_with("ses_") { + Some(ResumeHint { + provider: ResumeHintProvider::Opencode, + source: ResumeHintSource::IdShape, + }) + } else { + None + } + } + // charAt(14) is the uuid version nibble (0-based). Real-store caveat: + // amplifier TOP-LEVEL session ids are also UUIDv4, so v4 => claude is + // a heuristic, not an invariant — acceptable because hints are + // advisory only. + ResumeCandidateKind::Uuid => match top.token.as_bytes().get(14) { + Some(b'7') => Some(ResumeHint { + provider: ResumeHintProvider::Codex, + source: ResumeHintSource::IdShape, + }), + Some(b'4') => Some(ResumeHint { + provider: ResumeHintProvider::Claude, + source: ResumeHintSource::IdShape, + }), + _ => None, + }, + ResumeCandidateKind::HexPrefix => Some(ResumeHint { + provider: ResumeHintProvider::Amplifier, + source: ResumeHintSource::IdShape, + }), + } +} + +fn push_candidate( + token: &str, + kind: ResumeCandidateKind, + seen: &mut HashSet, + out: &mut Vec, +) { + // Dedup key: prefixed ids verbatim (case-sensitive); uuid/hex lowercased. + // All token classes are ASCII by construction, so to_ascii_lowercase() + // is equivalent to JS toLowerCase() here. + let key = match kind { + ResumeCandidateKind::PrefixedId => token.to_string(), + _ => token.to_ascii_lowercase(), + }; + if !seen.insert(key) { + return; + } + out.push(ResumeCandidate { + token: token.to_string(), + kind, + }); +} + +pub fn parse_resume_input(text: &str) -> ResumeInputParse { + // Each CSI escape collapses to ONE space (length-changing, matches TS). + let sanitized = ANSI_ESCAPE_RE.replace_all(text, " ").into_owned(); + + let mut uuids: Vec = Vec::new(); + let mut prefixed: Vec = Vec::new(); + let mut raw_hex: Vec = Vec::new(); + + // Mask each class as it is extracted so uuid segments never re-match as hex. + let masked = extract_and_mask(&sanitized, &UUID_RE, &mut uuids); + let masked = extract_and_mask(&masked, &PREFIXED_ID_RE, &mut prefixed); + extract_and_mask(&masked, &HEX_PREFIX_RE, &mut raw_hex); + + let mut hex_tokens: Vec = raw_hex + .into_iter() + .filter(|token| token.bytes().any(|b| b.is_ascii_digit())) + .collect(); + // STABLE sort (like JS Array.sort): equal lengths keep text order. + // NOTE: sort_by_key(Reverse(len)) — not sort_by(|a, b| b.len().cmp(&a.len())), + // which trips clippy's warn-by-default `unnecessary_sort_by` under the + // -D warnings gate. Vec::sort_by_key is equally stable; behavior identical. + hex_tokens.sort_by_key(|t| std::cmp::Reverse(t.len())); + + let mut seen: HashSet = HashSet::new(); + let mut candidates: Vec = Vec::new(); + for token in &prefixed { + push_candidate(token, ResumeCandidateKind::PrefixedId, &mut seen, &mut candidates); + } + for token in &uuids { + push_candidate(token, ResumeCandidateKind::Uuid, &mut seen, &mut candidates); + } + for token in &hex_tokens { + push_candidate(token, ResumeCandidateKind::HexPrefix, &mut seen, &mut candidates); + } + + let hint = derive_hint(&sanitized, &candidates); + ResumeInputParse { candidates, hint } +} +``` + +- [ ] **Step 5: Run the parity test — must pass** + +```bash +cargo test -p freshell-sessions --test resume_input_parser_parity +``` + +Expected: `test parser_matches_every_shared_fixture_case ... ok` — 1 passed. If a case fails, the RUST port is wrong (the fixture passed against TS in Task 1); fix the port, not the fixture. + +- [ ] **Step 6: Format, lint, commit** + +```bash +cargo fmt --all +cargo clippy -p freshell-sessions --all-targets -- -D warnings +git add crates/freshell-sessions/Cargo.toml crates/freshell-sessions/src/lib.rs crates/freshell-sessions/src/resume_input.rs crates/freshell-sessions/tests/resume_input_parser_parity.rs +git commit -m "feat(sessions): port resume-input parser to Rust, pinned by shared fixture (SYNC-06)" +``` + +--- + +### Task 3: Opencode by-id directory lookup + +The existing `session_exists_by_id` selects only `1` and never walks parents. Node's resolve fallback (`deps.resolveOpencodeSessionIds` → `OpencodeProvider.resolveOpencodeSessionRoots`, `server/coding-cli/providers/opencode.ts:239-323`) is NOT a bare row read — it is a parent-chain WALK with legacy-schema and truthy-directory quirks, all of them wire-observable, so the port replicates them bug for bug: + +- LEGACY schema (`session` lacks `parent_id`, detected the same way the listing code detects it): Node returns EARLY (`opencode.ts:246-250`) — every requested id "resolves" as its own root with NO row query at all, so even a NONEXISTENT id is a HIT, and an existing row's `directory` is never read (`cwd` omitted on the wire). +- MODERN schema: the requested row is fetched (missing row = miss); its own `directory` is kept only if TRUTHY (`opencode.ts:265-267, 281` — empty string ⇒ no cwd); then the `parent_id` chain is walked with a `seen` set (`opencode.ts:283-303`): a missing parent row or a cycle marks the requested id UNRESOLVED (`resolve-session.ts:66` ⇒ MISS) even though the row itself exists and its directory was already collected. + +Add a sibling helper with `session_exists_by_id`'s open/error conventions that implements exactly that walk. + +**Files:** +- Modify: `crates/freshell-sessions/src/parse/opencode.rs` +- Modify: `crates/freshell-sessions/src/parse/mod.rs` +- Test: `crates/freshell-sessions/tests/opencode_directory_by_id.rs` + +**Interfaces:** +- Consumes: existing `OpencodeReadError`, `Connection::open_with_flags(READ_ONLY|URI)`, `EXISTENCE_BY_ID_BUSY_TIMEOUT_MS`, and the listing code's `PRAGMA table_info(session)` parent-id detection pattern (all already in `opencode.rs`). +- Produces (used by Tasks 5–6): + - `freshell_sessions::parse::opencode_session_directory_by_id(data_home: &Path, session_id: &str) -> Result, OpencodeReadError>` + - `pub struct OpencodeSessionDirectory { pub directory: Option }` — `Ok(Some(hit))` = Node's walk would resolve the id; `hit.directory` is the requested row's own truthy `directory` (spawn cwd), `None` when it is empty/NULL or on ANY legacy-schema hit. `Ok(None)` = miss (no DB file, no row, orphaned parent chain, or parent cycle). `Err` = unreadable (callers treat as a resolve miss, never 5xx). + +- [ ] **Step 1: Write the failing test** + +Create `crates/freshell-sessions/tests/opencode_directory_by_id.rs`: + +```rust +//! SYNC-06 resolve fallback: by-id `directory` (spawn cwd) lookup — a +//! bug-for-bug port of Node's `resolveOpencodeSessionRoots` walk +//! (`server/coding-cli/providers/opencode.ts:246-250, 265-267, 281, +//! 283-303`, consumed by `resolve-session.ts:59-85`): +//! - LEGACY schema (no `parent_id` column): EVERY requested id HITS with +//! `directory: None` — Node's early return does no row query, so even a +//! nonexistent id resolves and an existing row's directory is never read; +//! - MODERN schema: the requested row's OWN `directory` is kept only if +//! truthy (empty string ⇒ `None`), then the parent chain is walked — a +//! missing parent row or a cycle is a MISS despite the row existing. + +use freshell_sessions::parse::{opencode_session_directory_by_id, OpencodeSessionDirectory}; + +fn temp_data_home(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "freshell-dir-by-id-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("mkdir temp data home"); + dir +} + +fn seed_schema(data_home: &std::path::Path) -> rusqlite::Connection { + let conn = + rusqlite::Connection::open(data_home.join("opencode.db")).expect("open fixture db"); + conn.execute_batch( + "CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT); + CREATE TABLE session ( + id TEXT PRIMARY KEY, directory TEXT, title TEXT, + time_created INTEGER, time_updated INTEGER, time_archived INTEGER, + project_id TEXT, parent_id TEXT + );", + ) + .expect("create schema"); + conn +} + +fn seed_legacy_schema(data_home: &std::path::Path) -> rusqlite::Connection { + // The pre-`parent_id` opencode schema (identical minus that column). + let conn = + rusqlite::Connection::open(data_home.join("opencode.db")).expect("open fixture db"); + conn.execute_batch( + "CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT); + CREATE TABLE session ( + id TEXT PRIMARY KEY, directory TEXT, title TEXT, + time_created INTEGER, time_updated INTEGER, time_archived INTEGER, + project_id TEXT + );", + ) + .expect("create legacy schema"); + conn +} + +fn insert(conn: &rusqlite::Connection, id: &str, directory: Option<&str>, parent: Option<&str>) { + conn.execute( + "INSERT INTO session (id, directory, parent_id) VALUES (?1, ?2, ?3)", + rusqlite::params![id, directory, parent], + ) + .expect("insert row"); +} + +#[test] +fn child_hit_returns_the_childs_own_directory() { + let home = temp_data_home("child"); + let conn = seed_schema(&home); + insert(&conn, "ses_root0000000000000000000000", Some("/repo/root"), None); + insert( + &conn, + "ses_child000000000000000000000", + Some("/repo/child"), + Some("ses_root0000000000000000000000"), + ); + // Node collects the REQUESTED row's directory (`opencode.ts:265-267`), + // NOT the root's, then walks the chain to prove a root is reachable. + let hit = opencode_session_directory_by_id(&home, "ses_child000000000000000000000") + .expect("query ok"); + assert_eq!( + hit, + Some(OpencodeSessionDirectory { + directory: Some("/repo/child".to_string()) + }) + ); +} + +#[test] +fn root_row_hits_with_its_directory() { + let home = temp_data_home("root"); + let conn = seed_schema(&home); + insert(&conn, "ses_plain000000000000000000000", Some("/repo/plain"), None); + let hit = opencode_session_directory_by_id(&home, "ses_plain000000000000000000000") + .expect("query ok"); + assert_eq!( + hit, + Some(OpencodeSessionDirectory { + directory: Some("/repo/plain".to_string()) + }) + ); +} + +#[test] +fn archived_row_still_resolves() { + let home = temp_data_home("archived"); + let conn = seed_schema(&home); + conn.execute( + "INSERT INTO session (id, directory, time_archived) VALUES (?1, ?2, ?3)", + rusqlite::params!["ses_arch0000000000000000000000", "/repo/old", 123_i64], + ) + .expect("insert row"); + let hit = opencode_session_directory_by_id(&home, "ses_arch0000000000000000000000") + .expect("query ok"); + assert_eq!( + hit, + Some(OpencodeSessionDirectory { + directory: Some("/repo/old".to_string()) + }) + ); +} + +#[test] +fn missing_row_is_a_miss() { + let home = temp_data_home("missing"); + let _conn = seed_schema(&home); + let hit = opencode_session_directory_by_id(&home, "ses_missing0000000000000000000") + .expect("query ok"); + assert_eq!(hit, None); +} + +#[test] +fn orphaned_parent_chain_is_a_miss_despite_the_row_existing() { + let home = temp_data_home("orphan"); + let conn = seed_schema(&home); + insert( + &conn, + "ses_orphan00000000000000000000", + Some("/repo/orphan"), + Some("ses_gone00000000000000000000000"), + ); + // Node's missing-parent guard (`opencode.ts:292-295`) marks the REQUESTED + // id unresolved -> `resolve-session.ts:66` -> miss. + let hit = opencode_session_directory_by_id(&home, "ses_orphan00000000000000000000") + .expect("query ok"); + assert_eq!(hit, None); +} + +#[test] +fn parent_cycle_is_a_miss() { + let home = temp_data_home("cycle"); + let conn = seed_schema(&home); + insert( + &conn, + "ses_cyca000000000000000000000a", + Some("/repo/cyca"), + Some("ses_cycb000000000000000000000b"), + ); + insert( + &conn, + "ses_cycb000000000000000000000b", + Some("/repo/cycb"), + Some("ses_cyca000000000000000000000a"), + ); + // Node's seen-set cycle guard (`opencode.ts:287-290`) -> miss. + let hit = opencode_session_directory_by_id(&home, "ses_cyca000000000000000000000a") + .expect("query ok"); + assert_eq!(hit, None); +} + +#[test] +fn empty_string_directory_hits_with_directory_none() { + let home = temp_data_home("emptydir"); + let conn = seed_schema(&home); + insert(&conn, "ses_empty000000000000000000000", Some(""), None); + // Truthy filter (`opencode.ts:265`): '' is dropped -> Node omits `cwd`. + let hit = opencode_session_directory_by_id(&home, "ses_empty000000000000000000000") + .expect("query ok"); + assert_eq!(hit, Some(OpencodeSessionDirectory { directory: None })); +} + +#[test] +fn null_directory_hits_with_directory_none() { + let home = temp_data_home("nulldir"); + let conn = seed_schema(&home); + insert(&conn, "ses_dirless0000000000000000000", None, None); + let hit = opencode_session_directory_by_id(&home, "ses_dirless0000000000000000000") + .expect("query ok"); + assert_eq!(hit, Some(OpencodeSessionDirectory { directory: None })); +} + +#[test] +fn legacy_schema_existing_id_hits_with_directory_none() { + let home = temp_data_home("legacy"); + let conn = seed_legacy_schema(&home); + conn.execute( + "INSERT INTO session (id, directory) VALUES (?1, ?2)", + rusqlite::params!["ses_legacy00000000000000000000", "/repo/legacy"], + ) + .expect("insert row"); + // Node's early return (`opencode.ts:246-250`) never reads the row: the + // directory exists in sqlite but `cwd` is still omitted on the wire. + let hit = opencode_session_directory_by_id(&home, "ses_legacy00000000000000000000") + .expect("query ok"); + assert_eq!(hit, Some(OpencodeSessionDirectory { directory: None })); +} + +#[test] +fn legacy_schema_nonexistent_id_still_hits() { + let home = temp_data_home("legacyghost"); + let _conn = seed_legacy_schema(&home); + // Bug-for-bug: Node fabricates a hit with ZERO existence check on the + // legacy schema (`opencode.ts:247-250` resolves every requested id). + let hit = opencode_session_directory_by_id(&home, "ses_ghostleg000000000000000000") + .expect("query ok"); + assert_eq!(hit, Some(OpencodeSessionDirectory { directory: None })); +} + +#[test] +fn missing_db_file_is_ok_none() { + let home = temp_data_home("nodb"); + let hit = opencode_session_directory_by_id(&home, "ses_root0000000000000000000000") + .expect("benign"); + assert_eq!(hit, None); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +cargo test -p freshell-sessions --test opencode_directory_by_id +``` + +Expected: COMPILE ERROR — `opencode_session_directory_by_id` / `OpencodeSessionDirectory` not found in `freshell_sessions::parse`. + +- [ ] **Step 3: Implement the helper** + +In `crates/freshell-sessions/src/parse/opencode.rs`, directly AFTER the existing `session_exists_by_id` function, add: + +```rust +/// A resume-resolve by-id fallback HIT: Node's `resolveOpencodeSessionRoots` +/// walk resolved the requested id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpencodeSessionDirectory { + /// The requested row's OWN `directory` column — the SPAWN cwd opencode + /// resumes in (`resolve-session.ts:77-84`: NOT the project root) — kept + /// only when TRUTHY (`opencode.ts:265-267, 281`). `None` for an empty or + /// NULL `directory` and for EVERY legacy-schema hit (Node's early return + /// never reads the row). `None` ⇒ the wire match OMITS `cwd`. + pub directory: Option, +} + +/// One row of the walk: `(directory, parent_id)` for an id, `None` = no row. +fn fetch_session_row( + conn: &Connection, + session_id: &str, +) -> Result, Option)>, OpencodeReadError> { + match conn.query_row( + "SELECT directory, parent_id FROM session WHERE id = ?1", + rusqlite::params![session_id], + |row| { + Ok(( + row.get::<_, Option>(0)?, + row.get::<_, Option>(1)?, + )) + }, + ) { + Ok(row) => Ok(Some(row)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(OpencodeReadError(e.to_string())), + } +} + +/// Resume-resolve by-id lookup — a bug-for-bug port of Node's +/// `OpencodeProvider.resolveOpencodeSessionRoots` +/// (`server/coding-cli/providers/opencode.ts:239-323`, consumed by +/// `resolve-session.ts:59-85`). This is deliberately NOT the attach-arm +/// existence probe: Node walks the `parent_id` chain, and every quirk of +/// that walk is wire-observable, so all are replicated: +/// +/// - LEGACY schema (`session` lacks `parent_id`, detected with the same +/// `PRAGMA table_info(session)` probe the listing uses): return a HIT with +/// `directory: None` for ANY requested id — Node returns early +/// (`opencode.ts:246-250`) with NO row query and NO existence check, so +/// even nonexistent ids hit and existing directories are never read. +/// - MODERN schema: fetch the requested row (missing row ⇒ `Ok(None)`); +/// keep its OWN `directory` only if non-empty (truthy filter, +/// `opencode.ts:265-267, 281`); then walk `parent_id` with a `seen` set — +/// a missing parent row (`opencode.ts:292-295`) or a cycle +/// (`opencode.ts:287-290`) marks the requested id unresolved ⇒ `Ok(None)` +/// even though the row exists; reaching a root (`parent_id` NULL) ⇒ HIT. +/// +/// Same read-only open and short busy timeout as [`session_exists_by_id`]. +/// `Err` for ANY read failure — the resolve endpoint treats `Err` as a miss +/// (empty matches), never a 5xx (Node likewise degrades: 3 retries then all +/// ids unresolved, `opencode.ts:239-322`). +pub fn opencode_session_directory_by_id( + data_home: &Path, + session_id: &str, +) -> Result, OpencodeReadError> { + let db_path = data_home.join("opencode.db"); + if !db_path.exists() { + return Ok(None); + } + let conn = Connection::open_with_flags( + &db_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, + ) + .map_err(|e| OpencodeReadError(e.to_string()))?; + conn.busy_timeout(std::time::Duration::from_millis( + EXISTENCE_BY_ID_BUSY_TIMEOUT_MS, + )) + .map_err(|e| OpencodeReadError(e.to_string()))?; + + // PRAGMA table_info(session) -> hasParentId (same detection as the + // listing's `run_opencode_query_inner`). + let has_parent_id = { + let mut stmt = conn + .prepare("PRAGMA table_info(session)") + .map_err(|e| OpencodeReadError(e.to_string()))?; + let names = stmt + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|e| OpencodeReadError(e.to_string()))?; + let mut found = false; + for name in names { + if name.map_err(|e| OpencodeReadError(e.to_string()))? == "parent_id" { + found = true; + } + } + found + }; + if !has_parent_id { + // Node's legacy early return (`opencode.ts:246-250`): every requested + // id resolves as its own root — no row query, no existence check, no + // directory read. Bug-for-bug: nonexistent ids HIT, `cwd` omitted. + return Ok(Some(OpencodeSessionDirectory { directory: None })); + } + + let Some((directory, first_parent)) = fetch_session_row(&conn, session_id)? else { + return Ok(None); + }; + // Truthy filter (`opencode.ts:265-267, 281`): empty string ⇒ no cwd. + let directory = directory.filter(|d| !d.is_empty()); + + // Parent walk (`opencode.ts:283-303`): a missing parent or a cycle marks + // the REQUESTED id unresolved (`resolve-session.ts:66`) ⇒ miss, even + // though its own row exists and its directory was already collected. + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + seen.insert(session_id.to_string()); + let mut parent = first_parent; + while let Some(current) = parent { + if !seen.insert(current.clone()) { + return Ok(None); // cycle guard (`opencode.ts:287-290`) + } + match fetch_session_row(&conn, ¤t)? { + None => return Ok(None), // missing parent (`opencode.ts:292-295`) + Some((_, next_parent)) => parent = next_parent, + } + } + Ok(Some(OpencodeSessionDirectory { directory })) +} +``` + +In `crates/freshell-sessions/src/parse/mod.rs`, extend the existing `pub use opencode::{...}` re-export list to also include `opencode_session_directory_by_id` and `OpencodeSessionDirectory` (keep the list alphabetical within the braces): + +```rust +pub use opencode::{ + default_opencode_data_home, opencode_session_directory_by_id, run_opencode_listing_query, + session_exists_by_id, OpencodeDegrade, OpencodeListing, OpencodeListingResult, + OpencodeProvider, OpencodeReadError, OpencodeSession, OpencodeSessionDirectory, + OpencodeSessionRow, THREE_VIEWS_MARKER_SQL_PATTERN, +}; +``` + +- [ ] **Step 4: Run tests — must pass** + +```bash +cargo test -p freshell-sessions --test opencode_directory_by_id +cargo test -p freshell-sessions --test opencode_exists_by_id +``` + +Expected: 11 passed in the new test; the existing exists-by-id suite still fully green. + +- [ ] **Step 5: Format, lint, commit** + +```bash +cargo fmt --all +cargo clippy -p freshell-sessions --all-targets -- -D warnings +git add crates/freshell-sessions/src/parse/opencode.rs crates/freshell-sessions/src/parse/mod.rs crates/freshell-sessions/tests/opencode_directory_by_id.rs +git commit -m "feat(sessions): opencode by-id directory lookup for resume-resolve fallback (SYNC-06)" +``` + +--- + +### Task 4: Export the claude transcript cwd reader + +`freshell_freshagent::locate_transcript(id) -> Option` already exists and is exported; its cwd companion `transcript_cwd(path) -> Option` is `pub(crate)`. The resolve fallback needs both (Node's locator returns `{sessionId, cwd?}`). Promote and re-export — do NOT re-implement the scan. + +**Files:** +- Modify: `crates/freshell-freshagent/src/claude_snapshot.rs` (visibility only) +- Modify: `crates/freshell-freshagent/src/lib.rs` (re-export) +- Test: `crates/freshell-freshagent/tests/transcript_cwd_export.rs` + +**Interfaces:** +- Consumes: existing `transcript_cwd` implementation (first non-empty `cwd` field among the transcript's JSONL lines; malformed lines skipped). +- Produces (used by Task 6's wiring): `freshell_freshagent::transcript_cwd(path: &Path) -> Option` — crate-root export alongside `locate_transcript`. + +- [ ] **Step 1: Write the failing test** + +Create `crates/freshell-freshagent/tests/transcript_cwd_export.rs`: + +```rust +//! SYNC-06: the resume-resolve claude fallback needs the transcript's +//! original cwd (`claude-transcript-locator.ts` parity: first line carrying a +//! non-empty string `cwd`, malformed lines skipped). This pins the crate-root +//! export and the first-non-empty-cwd semantics. + +use std::io::Write; + +fn temp_transcript(lines: &[&str]) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!( + "freshell-transcript-cwd-{}-{}.jsonl", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let mut file = std::fs::File::create(&path).expect("create fixture transcript"); + for line in lines { + writeln!(file, "{line}").expect("write fixture line"); + } + path +} + +#[test] +fn first_non_empty_cwd_wins_and_malformed_lines_are_skipped() { + let path = temp_transcript(&[ + "not json at all {", + r#"{"type":"summary","cwd":""}"#, + r#"{"type":"user","cwd":"/repo/gamma","message":{}}"#, + r#"{"type":"assistant","cwd":"/repo/other"}"#, + ]); + assert_eq!( + freshell_freshagent::transcript_cwd(&path), + Some("/repo/gamma".to_string()) + ); +} + +#[test] +fn transcript_without_cwd_yields_none() { + let path = temp_transcript(&[r#"{"type":"summary"}"#, r#"{"leafUuid":"x"}"#]); + assert_eq!(freshell_freshagent::transcript_cwd(&path), None); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +cargo test -p freshell-freshagent --test transcript_cwd_export +``` + +Expected: COMPILE ERROR — `transcript_cwd` is private (or not found at the crate root). + +- [ ] **Step 3: Promote and re-export** + +In `crates/freshell-freshagent/src/claude_snapshot.rs`, change the signature line of `transcript_cwd` from: + +```rust +pub(crate) fn transcript_cwd(path: &Path) -> Option { +``` + +to: + +```rust +pub fn transcript_cwd(path: &Path) -> Option { +``` + +In `crates/freshell-freshagent/src/lib.rs`, change: + +```rust +// Kata 09v1: the ONE claude_snapshot item visible outside this crate — the +// raw-file existence check freshell-server's IndexExistenceProbe shares with +// the attach arm. Keep the rest of claude_snapshot crate-private. +pub use claude_snapshot::locate_transcript; +``` + +to: + +```rust +// Kata 09v1 + SYNC-06: the TWO claude_snapshot items visible outside this +// crate — the raw-file existence check freshell-server's IndexExistenceProbe +// shares with the attach arm, and the original-cwd reader the resume-resolve +// claude fallback pairs with it (`claude-transcript-locator.ts` parity). +// Keep the rest of claude_snapshot crate-private. +pub use claude_snapshot::{locate_transcript, transcript_cwd}; +``` + +**Recorded deviations (accepted).** Reusing the attach-arm locator (`locate_transcript` + `transcript_cwd`) instead of porting Node's `claude-transcript-locator.ts` carries these deltas (A6 validation). All are production-only — the e2e harness sets `HOME`/`CLAUDE_HOME` and DELETES `CLAUDE_CONFIG_DIR`, collapsing the Rust root list to the single `/.claude` root Node scans — so no in-plan test can trip them, and NO code change is made for them: + +- Multi-root scan: Rust honors `CLAUDE_CONFIG_DIR` > `CLAUDE_HOME` > `$HOME/.claude`; Node scans only `(CLAUDE_HOME || ~/.claude)/projects`. Rust returns the exact match where Node returns `matches: []` — bug-fix-flavored, since the real claude CLI honors `CLAUDE_CONFIG_DIR`. +- One-subdir-deeper layouts (`//.jsonl`): Rust hits, Node misses. (Claude SUBAGENT transcripts live TWO levels down — `//subagents/` — and are missed by BOTH locators.) +- cwd window: Node reads only the first 64 KiB (a cwd past the cap, or a first cwd-bearing line straddling the boundary, is dropped); Rust reads the whole file and supplies `cwd` where Node omits it (Rust richer). +- Invalid-UTF-8 line before the first cwd line: Rust's `BufRead::lines()` stops scanning and yields no cwd; Node's lossy decode keeps scanning and can still find one (Node richer). + +- [ ] **Step 4: Run tests — must pass** + +```bash +cargo test -p freshell-freshagent --test transcript_cwd_export +``` + +Expected: 2 passed. + +- [ ] **Step 5: Format, lint, commit** + +```bash +cargo fmt --all +cargo clippy -p freshell-freshagent --all-targets -- -D warnings +git add crates/freshell-freshagent/src/claude_snapshot.rs crates/freshell-freshagent/src/lib.rs crates/freshell-freshagent/tests/transcript_cwd_export.rs +git commit -m "feat(freshagent): export transcript_cwd for resume-resolve claude fallback (SYNC-06)" +``` + +--- + +### Task 5: Resolve core (`resume_resolve.rs`) + +The provider-agnostic matching engine: wire types (serde, camelCase, Node field order) + the exact algorithm from `resolve-session.ts`. Pure and synchronous — the HTTP layer supplies the snapshot, the sessionType overlay map, and fallback closures. + +**Files:** +- Create: `crates/freshell-sessions/src/resume_resolve.rs` +- Modify: `crates/freshell-sessions/src/lib.rs` (add `pub mod resume_resolve;`) +- Test: `crates/freshell-sessions/tests/resume_resolve.rs` + +**Interfaces:** +- Consumes: `parse_resume_input`, `ResumeCandidateKind`, `ResumeHint` (Task 2); `IndexedSession` (existing, `directory_index.rs` — fields `session_id, provider, project_path, title, summary, first_user_message, last_activity_at: i64, created_at, cwd, is_subagent, is_non_interactive, source_file`); `OpencodeSessionDirectory` (Task 3). +- Produces (used by Task 6): + - `pub const RESOLVE_MATCH_CAP: usize = 20;` + - `pub struct ClaudeTranscriptHit { pub session_id: String, pub cwd: Option }` + - `pub struct ResolveDeps<'a> { pub sessions: Option<&'a [IndexedSession]>, pub session_types: &'a HashMap, pub opencode_dir_by_id: Option<&'a (dyn Fn(&str) -> Option + Send + Sync)>, pub locate_claude_transcript: Option<&'a (dyn Fn(&str) -> Option + Send + Sync)> }` + - `pub fn resolve_resume_input(input: &str, deps: &ResolveDeps<'_>) -> ResumeResolveResponse` + - `pub struct ResumeResolveResponse { pub status: ResumeResolveStatus, pub matches: Vec, pub hint: Option }` (Serialize; `hint: None` → JSON `null`) + - `pub struct ResumeResolveMatch` (Serialize, camelCase, optional fields omitted when `None`) + - `sessions: None` ⇒ `status: "warming"`; `session_types` is keyed `"{provider}:{session_id}"`. + +- [ ] **Step 1: Write the failing tests** + +Create `crates/freshell-sessions/tests/resume_resolve.rs` (mirrors `test/integration/server/sessions-resolve-router.test.ts` at the logic level, plus serialization-shape pins): + +```rust +//! SYNC-06 resolve-core parity tests — a 1:1 mirror of the Node integration +//! suite `test/integration/server/sessions-resolve-router.test.ts` (matching, +//! ordering, cap, dedupe, warming, fallbacks) at the logic level, plus +//! wire-shape pins the Node suite leaves implicit (camelCase field names, +//! omitted optionals, hint null). + +use std::collections::HashMap; + +use freshell_sessions::directory_index::IndexedSession; +use freshell_sessions::parse::OpencodeSessionDirectory; +use freshell_sessions::resume_resolve::{ + resolve_resume_input, ClaudeTranscriptHit, ResolveDeps, ResumeResolveResponse, + RESOLVE_MATCH_CAP, +}; + +const CLAUDE_ID: &str = "ed2afda6-a340-443e-ba60-024a1b3554b4"; +const CODEX_ID: &str = "019fac27-69d7-78a0-b972-b339d551042e"; +const OPENCODE_ID: &str = "ses_root0000000000000000000000"; +const AMP_ID_NEW: &str = "417e8345-aaaa-4bbb-8ccc-000000000001"; +const AMP_ID_OLD: &str = "417e8345-bbbb-4ccc-8ddd-000000000002"; + +fn session(provider: &str, id: &str, project: &str, last_activity_at: i64) -> IndexedSession { + IndexedSession { + session_id: id.to_string(), + provider: provider.to_string(), + project_path: project.to_string(), + title: None, + summary: None, + first_user_message: None, + last_activity_at, + created_at: None, + cwd: Some(project.to_string()), + is_subagent: false, + is_non_interactive: false, + source_file: None, + } +} + +/// The Node suite's fixtureProjects(), flattened. +fn fixture_sessions() -> Vec { + let mut claude = session("claude", CLAUDE_ID, "/repo/alpha", 400); + claude.title = Some("Fix the parser".to_string()); + claude.first_user_message = Some("fix the parser".to_string()); + vec![ + claude, + session("codex", CODEX_ID, "/repo/alpha", 300), + session("opencode", OPENCODE_ID, "/repo/beta", 200), + session("amplifier", AMP_ID_NEW, "/repo/beta", 900), + session("amplifier", AMP_ID_OLD, "/repo/beta", 100), + ] +} + +fn no_types() -> HashMap { + HashMap::new() +} + +fn resolve(input: &str, sessions: &[IndexedSession]) -> ResumeResolveResponse { + let types = no_types(); + resolve_resume_input( + input, + &ResolveDeps { + sessions: Some(sessions), + session_types: &types, + opencode_dir_by_id: None, + locate_claude_transcript: None, + }, + ) +} + +fn as_json(response: &ResumeResolveResponse) -> serde_json::Value { + serde_json::to_value(response).expect("serialize response") +} + +#[test] +fn exact_uuid_resolves_to_single_exact_match() { + let sessions = fixture_sessions(); + for (input, provider, id) in [ + (CLAUDE_ID.to_string(), "claude", CLAUDE_ID), + (format!("codex resume {CODEX_ID}"), "codex", CODEX_ID), + ( + format!("opencode --session {OPENCODE_ID}"), + "opencode", + OPENCODE_ID, + ), + ] { + let body = as_json(&resolve(&input, &sessions)); + assert_eq!(body["status"], "ready", "input {input:?}"); + assert_eq!(body["matches"].as_array().unwrap().len(), 1, "input {input:?}"); + assert_eq!(body["matches"][0]["provider"], provider); + assert_eq!(body["matches"][0]["sessionId"], id); + assert_eq!(body["matches"][0]["matchKind"], "exact"); + } +} + +#[test] +fn match_carries_full_resume_metadata() { + let body = as_json(&resolve(CLAUDE_ID, &fixture_sessions())); + let m = &body["matches"][0]; + assert_eq!(m["provider"], "claude"); + assert_eq!(m["sessionId"], CLAUDE_ID); + assert_eq!(m["cwd"], "/repo/alpha"); + assert_eq!(m["title"], "Fix the parser"); + assert_eq!(m["firstUserMessage"], "fix the parser"); + assert_eq!(m["lastActivityAt"], 400); + // sessionType absent (no metadata-store overlay entry): key OMITTED, + // not null — the client and the Node contract treat undefined as omitted. + assert!(m.get("sessionType").is_none()); +} + +#[test] +fn session_type_overlays_from_metadata_map() { + let sessions = fixture_sessions(); + let mut types = HashMap::new(); + types.insert(format!("claude:{CLAUDE_ID}"), "freshclaude".to_string()); + let response = resolve_resume_input( + CLAUDE_ID, + &ResolveDeps { + sessions: Some(&sessions), + session_types: &types, + opencode_dir_by_id: None, + locate_claude_transcript: None, + }, + ); + let body = as_json(&response); + assert_eq!(body["matches"][0]["sessionType"], "freshclaude"); +} + +#[test] +fn prefix_matches_short_hex_most_recent_first() { + let body = as_json(&resolve("417e8345", &fixture_sessions())); + assert_eq!(body["status"], "ready"); + let ids: Vec<&str> = body["matches"] + .as_array() + .unwrap() + .iter() + .map(|m| m["sessionId"].as_str().unwrap()) + .collect(); + assert_eq!(ids, vec![AMP_ID_NEW, AMP_ID_OLD]); + assert_eq!(body["matches"][0]["matchKind"], "prefix"); + assert_eq!(body["matches"][0]["provider"], "amplifier"); +} + +#[test] +fn caps_ambiguous_prefix_matches_at_20() { + let many: Vec = (0..25) + .map(|i| { + session( + "amplifier", + &format!("417e8345-0000-4000-8000-{i:012}"), + "/repo/many", + i, + ) + }) + .collect(); + let body = as_json(&resolve("417e8345", &many)); + assert_eq!(body["matches"].as_array().unwrap().len(), RESOLVE_MATCH_CAP); + assert_eq!(body["matches"][0]["lastActivityAt"], 24); // most recent first +} + +#[test] +fn dedupes_duplicate_provider_session_id_keeping_most_recent() { + let mut older = session("claude", CLAUDE_ID, "/repo/alpha", 100); + older.title = Some("older file".to_string()); + let mut newer = session("claude", CLAUDE_ID, "/repo/alpha", 500); + newer.title = Some("newer file".to_string()); + let body = as_json(&resolve(CLAUDE_ID, &[older, newer])); + assert_eq!(body["matches"].as_array().unwrap().len(), 1); + assert_eq!(body["matches"][0]["title"], "newer file"); + assert_eq!(body["matches"][0]["lastActivityAt"], 500); +} + +#[test] +fn reports_hint_alongside_evidence() { + let body = as_json(&resolve(&format!("codex resume {CODEX_ID}"), &fixture_sessions())); + assert_eq!( + body["hint"], + serde_json::json!({ "provider": "codex", "source": "command" }) + ); +} + +#[test] +fn unknown_id_is_ready_with_empty_matches() { + let body = as_json(&resolve("019fffff-ffff-7fff-bfff-ffffffffffff", &fixture_sessions())); + assert_eq!(body["status"], "ready"); + assert_eq!(body["matches"], serde_json::json!([])); +} + +#[test] +fn warming_when_no_snapshot_with_hint_and_empty_matches() { + let types = no_types(); + let response = resolve_resume_input( + &format!("claude --resume {CLAUDE_ID}"), + &ResolveDeps { + sessions: None, + session_types: &types, + opencode_dir_by_id: None, + locate_claude_transcript: None, + }, + ); + assert_eq!( + as_json(&response), + serde_json::json!({ + "status": "warming", + "matches": [], + "hint": { "provider": "claude", "source": "command" } + }) + ); +} + +#[test] +fn opencode_by_id_fallback_uses_row_directory_as_cwd() { + let unknown = "ses_child000000000000000000000"; + let lookup = |id: &str| { + assert_eq!(id, unknown); + Some(OpencodeSessionDirectory { + directory: Some("/repo/beta".to_string()), + }) + }; + let types = no_types(); + let sessions = fixture_sessions(); + let response = resolve_resume_input( + unknown, + &ResolveDeps { + sessions: Some(&sessions), + session_types: &types, + opencode_dir_by_id: Some(&lookup), + locate_claude_transcript: None, + }, + ); + // Node asserts strict equality: exactly these five keys, nothing else. + assert_eq!( + as_json(&response)["matches"], + serde_json::json!([{ + "provider": "opencode", + "sessionId": unknown, + "cwd": "/repo/beta", + "sessionType": "opencode", + "matchKind": "exact" + }]) + ); +} + +#[test] +fn opencode_fallback_hit_without_directory_omits_cwd() { + // Legacy-schema and empty-string-directory walk hits carry + // `directory: None` (Task 3): the wire match must OMIT `cwd` entirely — + // matching Node, where `cwd: undefined` is dropped by `res.json` — not + // emit `"cwd": null` or `"cwd": ""`. + let unknown = "ses_legacy00000000000000000000"; + let lookup = |id: &str| { + assert_eq!(id, unknown); + Some(OpencodeSessionDirectory { directory: None }) + }; + let types = no_types(); + let sessions = fixture_sessions(); + let response = resolve_resume_input( + unknown, + &ResolveDeps { + sessions: Some(&sessions), + session_types: &types, + opencode_dir_by_id: Some(&lookup), + locate_claude_transcript: None, + }, + ); + assert_eq!( + as_json(&response)["matches"], + serde_json::json!([{ + "provider": "opencode", + "sessionId": unknown, + "sessionType": "opencode", + "matchKind": "exact" + }]) + ); +} + +#[test] +fn claude_transcript_fallback_on_exact_id_index_miss() { + let unknown = "aaaaaaaa-1111-4222-8333-444444444444"; + let locate = |id: &str| { + Some(ClaudeTranscriptHit { + session_id: id.to_string(), + cwd: Some("/repo/gamma".to_string()), + }) + }; + let types = no_types(); + let sessions = fixture_sessions(); + let response = resolve_resume_input( + unknown, + &ResolveDeps { + sessions: Some(&sessions), + session_types: &types, + opencode_dir_by_id: None, + locate_claude_transcript: Some(&locate), + }, + ); + assert_eq!( + as_json(&response)["matches"], + serde_json::json!([{ + "provider": "claude", + "sessionId": unknown, + "cwd": "/repo/gamma", + "sessionType": "claude", + "matchKind": "exact" + }]) + ); +} + +#[test] +fn fallbacks_are_not_consulted_when_the_index_matches() { + // Node only reaches the fallback loop when EVERY candidate missed the index. + let locate = |_id: &str| -> Option { + panic!("locate_claude_transcript must not run on an index hit") + }; + let types = no_types(); + let sessions = fixture_sessions(); + let response = resolve_resume_input( + CLAUDE_ID, + &ResolveDeps { + sessions: Some(&sessions), + session_types: &types, + opencode_dir_by_id: None, + locate_claude_transcript: Some(&locate), + }, + ); + assert_eq!(as_json(&response)["matches"].as_array().unwrap().len(), 1); +} + +#[test] +fn garbage_input_is_ready_empty_with_null_hint() { + let response = resolve("hello decade facade!!", &fixture_sessions()); + assert_eq!( + as_json(&response), + serde_json::json!({ "status": "ready", "matches": [], "hint": null }) + ); +} + +#[test] +fn matching_is_case_insensitive_but_returns_stored_ids() { + let body = as_json(&resolve(&CLAUDE_ID.to_uppercase(), &fixture_sessions())); + assert_eq!(body["matches"][0]["sessionId"], CLAUDE_ID); + assert_eq!(body["matches"][0]["matchKind"], "exact"); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +cargo test -p freshell-sessions --test resume_resolve +``` + +Expected: COMPILE ERROR — `resume_resolve` module does not exist. + +- [ ] **Step 3: Implement the core** + +Add to `crates/freshell-sessions/src/lib.rs` after `pub mod resume_input;`: + +```rust +pub mod resume_resolve; +``` + +Create `crates/freshell-sessions/src/resume_resolve.rs`: + +```rust +//! Rust port of `server/coding-cli/resolve-session.ts` — the resume-by-id +//! resolve core. Pure and synchronous: the HTTP layer +//! (`crates/freshell-server/src/resolve.rs`) supplies the index snapshot, the +//! sessionType overlay map, and the two exact-id fallback closures, then +//! serializes the returned response verbatim. +//! +//! Wire parity notes: +//! - Field ORDER in `ResumeResolveMatch` matches the Node object literals +//! (`toMatch` / the fallback literals) — `serde_json` has `preserve_order` +//! on workspace-wide and struct field order drives serde output order. +//! - Optional match fields are OMITTED when `None` (Node/JSON.stringify drop +//! `undefined`); `hint` is `null` when absent (zod `.nullable()`), so it is +//! deliberately NOT `skip_serializing_if`. + +use std::collections::{HashMap, HashSet}; + +use crate::directory_index::IndexedSession; +use crate::parse::OpencodeSessionDirectory; +use crate::resume_input::{parse_resume_input, ResumeCandidateKind, ResumeHint}; + +/// `RESOLVE_MATCH_CAP` (`resolve-session.ts:9`). +pub const RESOLVE_MATCH_CAP: usize = 20; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ResumeResolveStatus { + Ready, + Warming, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ResumeMatchKind { + Exact, + Prefix, +} + +/// One resolve match (`ResumeResolveMatchSchema`, +/// `shared/resume-resolve-contract.ts`). Field order = Node's `toMatch`. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResumeResolveMatch { + pub provider: String, + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub first_user_message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_activity_at: Option, + pub match_kind: ResumeMatchKind, +} + +/// `ResumeResolveResponseSchema`: `{ status, matches, hint }` — `hint` is +/// `null` (present) when absent. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct ResumeResolveResponse { + pub status: ResumeResolveStatus, + pub matches: Vec, + pub hint: Option, +} + +/// The claude transcript fallback's answer (`ClaudeTranscriptHit` in +/// `claude-transcript-locator.ts`, minus `sourceFile` which the API never +/// surfaces). `session_id` is the LOWERCASED id (the Node locator lowercases). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaudeTranscriptHit { + pub session_id: String, + pub cwd: Option, +} + +/// Dependencies for one resolve call (`ResolveResumeDeps` in +/// `resolve-session.ts`). +pub struct ResolveDeps<'a> { + /// The flattened session list (Node: `getProjects().flatMap(g => g.sessions)`, + /// which is the POST-deleted-override-filter project groups, + /// `session-indexer.ts:209,1155-1156`). The slice the Rust server passes is + /// likewise the DELETED-FILTERED snapshot (the HTTP layer drops sessions + /// whose `"{provider}:{session_id}"` override says `deleted: true` before + /// calling in — see `resolve.rs`); this core stays filter-free on purpose. + /// `None` = the index has never published a snapshot ⇒ `status: "warming"` + /// (Node's `isIndexReady() === false`). + pub sessions: Option<&'a [IndexedSession]>, + /// sessionType overlay keyed `"{provider}:{session_id}"` (Node: + /// `session-indexer.ts:1159-1161` overlays the SessionMetadataStore). + pub session_types: &'a HashMap, + /// opencode `ses_*` exact-id fallback (`resolveOpencodeSessionIds` → + /// Node's by-id parent-walk): `Some(hit)` = the walk resolved the id — + /// `hit.directory` is the row's own TRUTHY `directory` (spawn cwd), and + /// is `None` for empty/NULL directories and ALL legacy-schema hits (the + /// wire match then omits `cwd`). `None` = miss (no row, orphaned chain, + /// cycle). Read errors are mapped to `None` by the caller — never a 5xx. + pub opencode_dir_by_id: + Option<&'a (dyn Fn(&str) -> Option + Send + Sync)>, + /// claude transcript exact-id fallback (`locateClaudeTranscript`). + pub locate_claude_transcript: + Option<&'a (dyn Fn(&str) -> Option + Send + Sync)>, +} + +/// `resolveResumeInput` (`resolve-session.ts:24-107`), step for step. +pub fn resolve_resume_input(input: &str, deps: &ResolveDeps<'_>) -> ResumeResolveResponse { + // Parse BEFORE the warming gate: the warming response still carries the hint. + let parsed = parse_resume_input(input); + let hint = parsed.hint; + + let Some(sessions) = deps.sessions else { + return ResumeResolveResponse { + status: ResumeResolveStatus::Warming, + matches: Vec::new(), + hint, + }; + }; + if parsed.candidates.is_empty() { + return ResumeResolveResponse { + status: ResumeResolveStatus::Ready, + matches: Vec::new(), + hint, + }; + } + + // Evidence pass: one scan answers all providers at once. Candidates are + // tried in priority order until one resolves. The hint NEVER filters. + for candidate in &parsed.candidates { + let needle = candidate.token.to_ascii_lowercase(); + let mut exact: Vec = Vec::new(); + let mut prefix: Vec = Vec::new(); + for session in sessions { + let id = session.session_id.to_ascii_lowercase(); + if id == needle { + exact.push(to_match(session, ResumeMatchKind::Exact, deps.session_types)); + } else if id.starts_with(&needle) { + prefix.push(to_match(session, ResumeMatchKind::Prefix, deps.session_types)); + } + } + // Exact wins wholesale — exact and prefix are never mixed. + let mut matches = if !exact.is_empty() { exact } else { prefix }; + if !matches.is_empty() { + // Sort BEFORE dedupe (stable), so the dedupe survivor is the + // most-recent entry. Missing lastActivityAt sorts as 0 in Node; + // the Rust index always has a value. + matches.sort_by(|a, b| { + b.last_activity_at + .unwrap_or(0) + .cmp(&a.last_activity_at.unwrap_or(0)) + }); + let matches: Vec = + dedupe(matches).into_iter().take(RESOLVE_MATCH_CAP).collect(); + return ResumeResolveResponse { + status: ResumeResolveStatus::Ready, + matches, + hint, + }; + } + } + + // Exact-id fallbacks for sessions the index cannot see (opencode child + // sessions; cwd-less claude transcripts skipped by the R10b cwd gate) — + // only reached when EVERY candidate missed the index. + for candidate in &parsed.candidates { + if candidate.kind == ResumeCandidateKind::PrefixedId + && candidate.token.starts_with("ses_") + { + if let Some(lookup) = deps.opencode_dir_by_id { + if let Some(hit) = lookup(&candidate.token) { + return ResumeResolveResponse { + status: ResumeResolveStatus::Ready, + matches: vec![ResumeResolveMatch { + provider: "opencode".to_string(), + session_id: candidate.token.clone(), + // opencode resumes in the SPAWN cwd (the sqlite + // row's own `directory` column), not the project + // root. `None` (empty-string directory, or any + // legacy-schema hit) serializes with `cwd` + // OMITTED — matching Node, whose `cwd: undefined` + // is dropped by `res.json`. + cwd: hit.directory, + session_type: Some("opencode".to_string()), + title: None, + first_user_message: None, + last_activity_at: None, + match_kind: ResumeMatchKind::Exact, + }], + hint, + }; + } + } + } + if candidate.kind == ResumeCandidateKind::Uuid { + if let Some(locate) = deps.locate_claude_transcript { + if let Some(hit) = locate(&candidate.token) { + return ResumeResolveResponse { + status: ResumeResolveStatus::Ready, + matches: vec![ResumeResolveMatch { + provider: "claude".to_string(), + session_id: hit.session_id, + cwd: hit.cwd, + session_type: Some("claude".to_string()), + title: None, + first_user_message: None, + last_activity_at: None, + match_kind: ResumeMatchKind::Exact, + }], + hint, + }; + } + } + } + } + + ResumeResolveResponse { + status: ResumeResolveStatus::Ready, + matches: Vec::new(), + hint, + } +} + +/// `toMatch` (`resolve-session.ts:109-119`): `cwd: session.cwd ?? projectPath`; +/// `sessionType` overlays from the metadata map (usually absent). +fn to_match( + session: &IndexedSession, + match_kind: ResumeMatchKind, + session_types: &HashMap, +) -> ResumeResolveMatch { + ResumeResolveMatch { + provider: session.provider.clone(), + session_id: session.session_id.clone(), + cwd: Some( + session + .cwd + .clone() + .unwrap_or_else(|| session.project_path.clone()), + ), + session_type: session_types.get(&session.key()).cloned(), + title: session.title.clone(), + first_user_message: session.first_user_message.clone(), + last_activity_at: Some(session.last_activity_at), + match_kind, + } +} + +/// `dedupe` (`resolve-session.ts:121-133`): first `provider:sessionId` wins — +/// which, post-sort, is the most recent entry. +fn dedupe(matches: Vec) -> Vec { + let mut seen: HashSet = HashSet::new(); + matches + .into_iter() + .filter(|m| seen.insert(format!("{}:{}", m.provider, m.session_id))) + .collect() +} +``` + +- [ ] **Step 4: Run tests — must pass** + +```bash +cargo test -p freshell-sessions --test resume_resolve +cargo test -p freshell-sessions +``` + +Expected: 15 passed in the new suite; the whole `freshell-sessions` crate green. + +- [ ] **Step 5: Format, lint, commit** + +```bash +cargo fmt --all +cargo clippy -p freshell-sessions --all-targets -- -D warnings +git add crates/freshell-sessions/src/lib.rs crates/freshell-sessions/src/resume_resolve.rs crates/freshell-sessions/tests/resume_resolve.rs +git commit -m "feat(sessions): resume-resolve matching core with Node-parity semantics (SYNC-06)" +``` + +--- + +### Task 6: HTTP endpoint (`resolve.rs`) + wiring + +The axum route: auth → zod-shaped validation → readiness gate → resolve core (in `spawn_blocking`, because the fallbacks do sqlite/filesystem IO). New focused module — do not touch `sessions.rs`. + +**Files:** +- Modify: `crates/freshell-server/src/session_metadata.rs` (un-gate the `get_all` read) +- Create: `crates/freshell-server/src/resolve.rs` +- Modify: `crates/freshell-server/src/main.rs` (module registration + wiring) + +**Interfaces:** +- Consumes: `resolve_resume_input`, `ResolveDeps`, `ClaudeTranscriptHit` (Task 5); `OpencodeSessionDirectory` + `opencode_session_directory_by_id` (Task 3); `freshell_freshagent::{locate_transcript, transcript_cwd}` (Task 4); `SessionIndex::{peek, snapshot}` + `IndexedSession::key`, `crate::boot::{is_authed, unauthorized}`, `SessionMetadataStore::{new, get_all}`, `crate::settings_store::SettingsStore` (the SYNC `session_overrides()` read, `settings_store.rs:673-679` — the same overlay source the sidebar's `apply_session_overrides` uses). +- Produces: `POST /api/sessions/resolve` and: + - `pub struct ResolveState { pub auth_token: Arc, pub settings: SettingsStore, pub session_index: Option>, pub session_metadata: SessionMetadataStore, pub opencode_dir_by_id: Option, pub locate_claude_transcript: Option }` + - `pub type OpencodeDirLookup = Arc Option + Send + Sync>;` + - `pub type ClaudeLocator = Arc Option + Send + Sync>;` + - `pub fn router(state: ResolveState) -> Router` + +- [ ] **Step 1: Un-gate the session-metadata `get_all` read** + +In `crates/freshell-server/src/session_metadata.rs`: +1. Around lines 30-31, the `use std::collections::HashMap;` import is gated behind `#[cfg(test)]` — remove the gate so it is a plain import (`get_all`'s return type needs it in the non-test build now; merge into the top-level use block if rustfmt prefers). +2. Remove the `#[cfg(test)]` attribute from `pub async fn get_all(...)` (line ~137) ONLY. Do NOT un-gate `pub async fn get(...)` (line ~121): this plan gives it no production caller, and `freshell-server` is a binary crate (no lib target, no `dead_code` allowances), so an un-gated `get` would trip rustc's `dead_code` lint on the non-test build and fail the `cargo clippy ... -D warnings` gates in Step 6 and Task 9. Its existing doc comment ("when that lands, the compiler will force this gate off") remains accurate — leave it as is. +3. `get_all`'s body is unchanged. Replace its stale doc line `/// Test-only today — see \`get\` above.` with: + +```rust + /// Production read (SYNC-06): the resolve endpoint overlays match + /// `sessionType` from this store, mirroring Node's + /// `session-indexer.ts:1159-1161` overlay. Keyed `"{provider}:{session_id}"`. +``` + +Verify: `cargo test -p freshell-server session_metadata` still green (the existing tests already call `get`/`get_all`; `get` stays available to them under `#[cfg(test)]`, and `resolve.rs`'s test module in this same crate can also see it if needed). + +- [ ] **Step 2: Write the endpoint module with its tests** + +Create `crates/freshell-server/src/resolve.rs`. Write the WHOLE file in this step — types, router, validation, handler, and the in-file test module — then prove behavior in Step 3/4. (The handler is small enough that the test module is the larger half; tests below are the authority if any divergence creeps in.) + +```rust +//! `POST /api/sessions/resolve` — SYNC-06 parity port of +//! `server/sessions-router.ts:243-257` + `server/coding-cli/resolve-session.ts`. +//! +//! Behavior contract (mirrors Node exactly): +//! - auth: same `x-auth-token` / `freshell-auth` cookie check as every other +//! `/api` route (`boot::is_authed`), 401 `{"error":"Unauthorized"}`. +//! - validation: strict body `{ input: string 1..=20000 }` (UTF-16 code +//! units); any failure → 400 +//! `{"error":"Invalid resolve request","details":[issues]}` where the +//! issue literals replicate the ACTUAL zod 4.3.6 wire output — field set, +//! key ORDER (`expected`/`origin` before `code`; `preserve_order` + `json!` +//! insertion order provide it), and message wording, probed against the +//! real `ResumeResolveRequestSchema`. NOTHING reads `details` (the client +//! dialog treats any non-2xx as request-failed without inspecting the +//! body), so this is test-pinned parity; the literals are pinned to zod +//! 4.3.6 and MUST be re-probed on any zod bump. +//! - membership: the index snapshot is filtered through `deleted: true` +//! session overrides before matching — Node's resolve reads the +//! post-filter project groups (`session-indexer.ts:209,1155-1156`) and the +//! Rust sidebar applies the same overlay (`session_directory.rs` +//! `apply_session_overrides`). The exact-id fallbacks BYPASS the filter, +//! as Node's do (`resolve-session.ts:59-103`). +//! - success is ALWAYS 200 — "not found" is `{status:"ready",matches:[]}`, +//! cold index is `{status:"warming",matches:[],hint}` (never 404/5xx). +//! +//! Accepted deviations (status parity only, recorded): payloads Express's +//! strict body parser rejects with an HTML 400 before zod runs (malformed +//! JSON; JSON scalars string/number/bool/null) get the zod-shaped JSON 400 +//! here; axum's default 2 MB body limit vs express `json({limit:'1mb'})`; +//! `PATCH`/`GET /api/sessions/resolve` answer 405 on the merged Rust router +//! where Express would dispatch `:sessionId="resolve"` (unreachable by any +//! known client). +//! +//! Readiness: `SessionIndex::peek()` `None` = never-published = Node's +//! `isIndexReady() === false`. A machine with no resolvable provider home +//! (`session_index: None`) also answers `warming` — the same honest-Unknown +//! convention `NoIndexProbe` uses for existence. + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::body::Bytes; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use axum::{Json, Router}; +use serde_json::{json, Map, Value}; + +use freshell_sessions::directory_index::{IndexedSession, SessionIndex}; +use freshell_sessions::parse::OpencodeSessionDirectory; +use freshell_sessions::resume_resolve::{ + resolve_resume_input, ClaudeTranscriptHit, ResolveDeps, ResumeResolveResponse, + ResumeResolveStatus, +}; + +use crate::boot::{is_authed, unauthorized}; +use crate::session_metadata::SessionMetadataStore; +use crate::settings_store::SettingsStore; + +/// zod `.max(20000)` on `input` (`shared/resume-resolve-contract.ts`). +const RESOLVE_INPUT_MAX_UTF16: usize = 20000; + +/// opencode `ses_*` by-id fallback: `Some(hit)` = Node's by-id parent-walk +/// resolved the id (`hit.directory` is the row's own truthy `directory` — +/// the spawn cwd — and `None` for empty/NULL directories and legacy-schema +/// hits), `None` = walk miss (no row, orphaned chain, cycle) OR unreadable +/// DB (read errors are a miss here — the endpoint never 5xxes). +pub type OpencodeDirLookup = Arc Option + Send + Sync>; + +/// claude transcript exact-id fallback: lowercased id + original cwd. +pub type ClaudeLocator = Arc Option + Send + Sync>; + +/// Shared state for the resolve surface. +#[derive(Clone)] +pub struct ResolveState { + pub auth_token: Arc, + /// `config.sessionOverrides` reader (`settings_store.rs`): the resolve + /// read model drops `deleted: true` sessions exactly like the sidebar's + /// `apply_session_overrides` and Node's post-filter `getProjects()`. + pub settings: SettingsStore, + pub session_index: Option>, + pub session_metadata: SessionMetadataStore, + pub opencode_dir_by_id: Option, + pub locate_claude_transcript: Option, +} + +pub fn router(state: ResolveState) -> Router { + Router::new() + .route("/api/sessions/resolve", post(resolve_session)) + .with_state(state) +} + +/// zod v4's received-type word for a JSON value. +fn received_type(value: &Value) -> &'static str { + match value { + Value::Array(_) => "array", + Value::String(_) => "string", + Value::Number(_) => "number", + Value::Bool(_) => "boolean", + Value::Null => "null", + Value::Object(_) => "object", + } +} + +/// Validate the request body against `ResumeResolveRequestSchema` semantics: +/// strict object, `input: string`, 1..=20000 UTF-16 code units. Returns the +/// input on success, or the `details` issue array on failure — every literal +/// (field set, key ORDER, message wording) is the ACTUAL zod 4.3.6 wire +/// output, probed against the real schema; see the module doc for the +/// version-fragility and no-consumer notes. `json!` insertion order IS the +/// serialized key order (workspace-wide `preserve_order`). +fn validate_resolve_body(body: &Value) -> Result { + let Value::Object(map) = body else { + // zod 4.3.6: `expected` precedes `code`; message carries the + // received type: `[1,2]` -> "...received array", `"x"` -> + // "...received string", etc. + return Err(json!([{ + "expected": "object", + "code": "invalid_type", + "path": [], + "message": format!("Invalid input: expected object, received {}", received_type(body)) + }])); + }; + let mut issues: Vec = Vec::new(); + // zod emits the shape (`input`) issue BEFORE `unrecognized_keys` + // (probed: `{foo:1}` -> [invalid_type(input), unrecognized_keys]). + match map.get("input") { + Some(Value::String(s)) => { + let len = s.encode_utf16().count(); + if len < 1 { + issues.push(json!({ + "origin": "string", + "code": "too_small", + "minimum": 1, + "inclusive": true, + "path": ["input"], + "message": "Too small: expected string to have >=1 characters" + })); + } else if len > RESOLVE_INPUT_MAX_UTF16 { + issues.push(json!({ + "origin": "string", + "code": "too_big", + "maximum": RESOLVE_INPUT_MAX_UTF16, + "inclusive": true, + "path": ["input"], + "message": "Too big: expected string to have <=20000 characters" + })); + } + } + other => { + // Missing (`received undefined`) and non-string values both + // surface zod's invalid_type, with the actual received type. + let received = other.map_or("undefined", received_type); + issues.push(json!({ + "expected": "string", + "code": "invalid_type", + "path": ["input"], + "message": format!("Invalid input: expected string, received {received}") + })); + } + } + let unknown: Vec<&str> = map + .keys() + .map(String::as_str) + .filter(|k| *k != "input") + .collect(); + if !unknown.is_empty() { + // zod 4.3.6: double-quoted names, singular/plural noun. + let listed = unknown + .iter() + .map(|k| format!("\"{k}\"")) + .collect::>() + .join(", "); + let noun = if unknown.len() == 1 { "key" } else { "keys" }; + issues.push(json!({ + "code": "unrecognized_keys", + "keys": unknown, + "path": [], + "message": format!("Unrecognized {noun}: {listed}") + })); + } + if issues.is_empty() { + Ok(map + .get("input") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string()) + } else { + Err(Value::Array(issues)) + } +} + +/// `POST /api/sessions/resolve`. Body taken as raw bytes (never an +/// axum-flavored rejection): an ABSENT or UNPARSEABLE body becomes `{}` — +/// the same value Express's `req.body ?? {}` hands zod for an absent body — +/// so it 400s with the missing-`input` issue. Parsed non-object values +/// (array/string/number/bool/null) flow to the invalid_type-object branch. +/// Recorded deviation (module doc): Express's strict body parser answers +/// malformed JSON and JSON scalars with an HTML 400 before zod ever runs; +/// this port answers those with the zod-shaped JSON 400 (status parity only +/// — no consumer reads 400 bodies). Arrays reach zod on both sides. +async fn resolve_session( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !is_authed(&headers, &state.auth_token) { + return unauthorized(); + } + let parsed: Value = + serde_json::from_slice(&body).unwrap_or_else(|_| Value::Object(Map::new())); + let input = match validate_resolve_body(&parsed) { + Ok(input) => input, + Err(details) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Invalid resolve request", "details": details })), + ) + .into_response(); + } + }; + + // Readiness gate = Node's `getIndexReadiness()`: a never-published (or + // absent) index answers `warming`. When a snapshot exists, `snapshot()` + // returns it immediately (stale-while-revalidate) — it only blocks when + // truly cold, which `peek()` has already excluded. + let snapshot = match state.session_index.as_ref() { + Some(index) => match index.peek() { + Some(_) => Some(index.snapshot().await), + None => None, + }, + None => None, + }; + + // Deleted-override filter: Node's resolve reads the POST-filter project + // groups (`session-indexer.ts:209,1155-1156`) and the Rust sidebar + // applies the same overlay (`session_directory.rs` + // `apply_session_overrides`) — the resolve read model must agree with + // both. Composite key `"{provider}:{session_id}"` ONLY: Node's extra + // bare-id/legacy-claude override keys are a pre-existing accepted + // divergence (the Rust sidebar does not consult them either). The + // exact-id FALLBACKS below intentionally BYPASS this filter — Node's + // fallbacks read sqlite/the filesystem directly and never consult + // overrides (`resolve-session.ts:59-103`) — bug-for-bug. + let snapshot: Option> = snapshot.map(|sessions| { + let overrides = state.settings.session_overrides(); + sessions + .iter() + .filter(|session| { + overrides + .get(&session.key()) + .and_then(Value::as_object) + .is_none_or(|ov| { + !ov.get("deleted").and_then(Value::as_bool).unwrap_or(false) + }) + }) + .cloned() + .collect() + }); + + // sessionType overlay (Node: `session-indexer.ts:1159-1161`), keyed + // `"{provider}:{session_id}"`. Only needed when we can match at all. + let session_types: HashMap = if snapshot.is_some() { + state + .session_metadata + .get_all() + .await + .into_iter() + .filter_map(|(key, entry)| { + entry + .get("sessionType") + .and_then(Value::as_str) + .map(|t| (key, t.to_string())) + }) + .collect() + } else { + HashMap::new() + }; + + let opencode = state.opencode_dir_by_id.clone(); + let claude = state.locate_claude_transcript.clone(); + let joined = tokio::task::spawn_blocking(move || { + let deps = ResolveDeps { + // as_deref (Option> -> Option<&[T]>): as_ref().map(|s| s.as_slice()) + // trips clippy's warn-by-default `option_as_ref_deref` under -D warnings. + sessions: snapshot.as_deref(), + session_types: &session_types, + opencode_dir_by_id: opencode.as_deref(), + locate_claude_transcript: claude.as_deref(), + }; + resolve_resume_input(&input, &deps) + }) + .await; + + // JoinError = the resolve task panicked. Express would 500 here; this + // port answers a benign ready-empty (Global Constraint: never 5xx) and + // the panic is already on stderr for diagnosis. + let response = joined.unwrap_or(ResumeResolveResponse { + status: ResumeResolveStatus::Ready, + matches: Vec::new(), + hint: None, + }); + Json(response).into_response() +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use tower::ServiceExt; + + use freshell_sessions::directory_index::{ + FileStat, IndexedSession, SessionIndex, SessionSource, + }; + + const CLAUDE_ID: &str = "ed2afda6-a340-443e-ba60-024a1b3554b4"; + + /// A file-less, direct-listed source: `discover()` empty, `direct_list()` + /// serves the fixture rows — a hermetic SessionIndex with zero disk IO. + struct FixtureSource(Vec); + + impl SessionSource for FixtureSource { + fn discover(&self) -> Vec { + Vec::new() + } + fn parse(&self, _path: &std::path::Path) -> Option { + None + } + fn direct_change_token(&self) -> Option { + Some(1) + } + fn direct_list(&self) -> Result, String> { + Ok(self.0.clone()) + } + } + + async fn fixture_index(sessions: Vec) -> Arc { + let index = Arc::new(SessionIndex::with_ttl_and_cache_path( + vec![Arc::new(FixtureSource(sessions)) as Arc], + std::time::Duration::from_secs(3600), + None, + )); + index.warm().await; + index + } + + fn claude_fixture() -> IndexedSession { + IndexedSession { + session_id: CLAUDE_ID.to_string(), + provider: "claude".to_string(), + project_path: "/repo/alpha".to_string(), + title: Some("Fix the parser".to_string()), + summary: None, + first_user_message: Some("fix the parser".to_string()), + last_activity_at: 400, + created_at: None, + cwd: Some("/repo/alpha".to_string()), + is_subagent: false, + is_non_interactive: false, + source_file: None, + } + } + + fn temp_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "frs-resolve-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("mkdir temp dir"); + dir + } + + fn state( + dir: &std::path::Path, + index: Option>, + ) -> super::ResolveState { + super::ResolveState { + auth_token: Arc::new("tok".into()), + // Isolated home: overrides read/write under `/.freshell/`, + // never the developer's real config (same pattern as the + // session_directory router tests). + settings: crate::settings_store::SettingsStore::load( + Some(dir), + vec!["claude".into()], + ), + session_index: index, + session_metadata: crate::session_metadata::SessionMetadataStore::new(dir), + opencode_dir_by_id: None, + locate_claude_transcript: None, + } + } + + async fn post( + state: super::ResolveState, + body: serde_json::Value, + with_auth: bool, + ) -> (StatusCode, serde_json::Value) { + let app = super::router(state); + let mut builder = Request::builder() + .method("POST") + .uri("/api/sessions/resolve") + .header("content-type", "application/json"); + if with_auth { + builder = builder.header("x-auth-token", "tok"); + } + let request = builder.body(Body::from(body.to_string())).unwrap(); + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + (status, value) + } + + #[tokio::test] + async fn rejects_unauthenticated_requests() { + let dir = temp_dir("auth"); + let (status, body) = post( + state(&dir, None), + serde_json::json!({ "input": CLAUDE_ID }), + false, + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body, serde_json::json!({ "error": "Unauthorized" })); + } + + #[tokio::test] + async fn rejects_unknown_keys_with_the_zod_4_3_6_literal() { + // `input` valid, two unknown keys: exactly ONE issue, plural noun, + // double-quoted names, key order code/keys/path/message. + let dir = temp_dir("strict"); + let (status, body) = post( + state(&dir, None), + serde_json::json!({ "input": "x", "foo": 1, "bar": 2 }), + true, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"], "Invalid resolve request"); + assert_eq!( + body["details"], + serde_json::json!([{ + "code": "unrecognized_keys", + "keys": ["foo", "bar"], + "path": [], + "message": "Unrecognized keys: \"foo\", \"bar\"" + }]) + ); + } + + #[tokio::test] + async fn multi_issue_order_is_input_issue_then_unrecognized_keys() { + // Probed zod 4.3.6 behavior for `{foo:1}`: the `input` invalid_type + // issue comes FIRST, `unrecognized_keys` (singular form) SECOND. + let dir = temp_dir("multi"); + let (status, body) = + post(state(&dir, None), serde_json::json!({ "foo": 1 }), true).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + body["details"], + serde_json::json!([ + { + "expected": "string", + "code": "invalid_type", + "path": ["input"], + "message": "Invalid input: expected string, received undefined" + }, + { + "code": "unrecognized_keys", + "keys": ["foo"], + "path": [], + "message": "Unrecognized key: \"foo\"" + } + ]) + ); + } + + #[tokio::test] + async fn zod_details_literals_match_zod_4_3_6_wire_output() { + // One case per failure class; expectations are the EXACT zod 4.3.6 + // `parsed.error.issues` output probed against the real schema. The + // scalar bodies (`null` here) are the recorded deviation: Express's + // strict body parser HTML-400s them before zod, Rust answers the + // zod-shaped issue for the parsed value instead. + let dir = temp_dir("bounds"); + let cases: Vec<(serde_json::Value, serde_json::Value)> = vec![ + ( + serde_json::json!({ "input": "" }), + serde_json::json!([{ + "origin": "string", + "code": "too_small", + "minimum": 1, + "inclusive": true, + "path": ["input"], + "message": "Too small: expected string to have >=1 characters" + }]), + ), + ( + serde_json::json!({}), + serde_json::json!([{ + "expected": "string", + "code": "invalid_type", + "path": ["input"], + "message": "Invalid input: expected string, received undefined" + }]), + ), + ( + serde_json::json!({ "input": 123 }), + serde_json::json!([{ + "expected": "string", + "code": "invalid_type", + "path": ["input"], + "message": "Invalid input: expected string, received number" + }]), + ), + ( + serde_json::json!({ "input": "x".repeat(20001) }), + serde_json::json!([{ + "origin": "string", + "code": "too_big", + "maximum": 20000, + "inclusive": true, + "path": ["input"], + "message": "Too big: expected string to have <=20000 characters" + }]), + ), + ( + serde_json::json!([1, 2]), + serde_json::json!([{ + "expected": "object", + "code": "invalid_type", + "path": [], + "message": "Invalid input: expected object, received array" + }]), + ), + ( + serde_json::json!(null), + serde_json::json!([{ + "expected": "object", + "code": "invalid_type", + "path": [], + "message": "Invalid input: expected object, received null" + }]), + ), + ]; + for (body, details) in cases { + let (status, response) = post(state(&dir, None), body.clone(), true).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "body {body}"); + assert_eq!(response["error"], "Invalid resolve request", "body {body}"); + assert_eq!(response["details"], details, "body {body}"); + } + // Key ORDER is part of the wire shape (zod v4 emits `expected` / + // `origin` BEFORE `code`). `Value` equality is order-insensitive, so + // pin one case as a serialized string — `preserve_order` makes the + // parsed order round-trip the wire order. + let (_, response) = + post(state(&dir, None), serde_json::json!({ "input": 123 }), true).await; + assert_eq!( + serde_json::to_string(&response["details"]).unwrap(), + r#"[{"expected":"string","code":"invalid_type","path":["input"],"message":"Invalid input: expected string, received number"}]"# + ); + } + + #[tokio::test] + async fn input_of_exactly_20000_chars_is_accepted() { + let dir = temp_dir("maxok"); + let (status, body) = + post(state(&dir, None), serde_json::json!({ "input": "x".repeat(20000) }), true).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "warming"); // no index in this state + } + + #[tokio::test] + async fn warming_with_hint_when_index_never_published() { + let dir = temp_dir("warming"); + let (status, body) = post( + state(&dir, None), + serde_json::json!({ "input": format!("claude --resume {CLAUDE_ID}") }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + body, + serde_json::json!({ + "status": "warming", + "matches": [], + "hint": { "provider": "claude", "source": "command" } + }) + ); + } + + #[tokio::test] + async fn exact_match_returns_full_metadata_via_the_index() { + let dir = temp_dir("exact"); + let index = fixture_index(vec![claude_fixture()]).await; + let (status, body) = post( + state(&dir, Some(index)), + serde_json::json!({ "input": CLAUDE_ID }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "ready"); + assert_eq!( + body["matches"], + serde_json::json!([{ + "provider": "claude", + "sessionId": CLAUDE_ID, + "cwd": "/repo/alpha", + "title": "Fix the parser", + "firstUserMessage": "fix the parser", + "lastActivityAt": 400, + "matchKind": "exact" + }]) + ); + } + + #[tokio::test] + async fn session_type_overlays_from_the_metadata_store_file() { + let dir = temp_dir("stype"); + std::fs::write( + dir.join("session-metadata.json"), + serde_json::json!({ + "version": 1, + "sessions": { + "claude": { + CLAUDE_ID: { "sessionType": "freshclaude", "sessionTypeSource": "explicit" } + } + } + }) + .to_string(), + ) + .unwrap(); + let index = fixture_index(vec![claude_fixture()]).await; + let (_, body) = post( + state(&dir, Some(index)), + serde_json::json!({ "input": CLAUDE_ID }), + true, + ) + .await; + assert_eq!(body["matches"][0]["sessionType"], "freshclaude"); + } + + #[tokio::test] + async fn unknown_id_is_ready_empty_never_404() { + let dir = temp_dir("miss"); + let index = fixture_index(vec![claude_fixture()]).await; + let (status, body) = post( + state(&dir, Some(index)), + serde_json::json!({ "input": "019fffff-ffff-7fff-bfff-ffffffffffff" }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "ready"); + assert_eq!(body["matches"], serde_json::json!([])); + } + + #[tokio::test] + async fn opencode_fallback_answers_with_row_directory() { + let dir = temp_dir("ocfb"); + let index = fixture_index(vec![claude_fixture()]).await; + let unknown = "ses_child000000000000000000000"; + let mut st = state(&dir, Some(index)); + st.opencode_dir_by_id = Some(Arc::new(|_id: &str| { + Some(freshell_sessions::parse::OpencodeSessionDirectory { + directory: Some("/repo/beta".to_string()), + }) + })); + let (status, body) = post(st, serde_json::json!({ "input": unknown }), true).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + body["matches"], + serde_json::json!([{ + "provider": "opencode", + "sessionId": unknown, + "cwd": "/repo/beta", + "sessionType": "opencode", + "matchKind": "exact" + }]) + ); + } + + #[tokio::test] + async fn claude_transcript_fallback_answers_on_index_miss() { + let dir = temp_dir("clfb"); + let index = fixture_index(vec![claude_fixture()]).await; + let unknown = "aaaaaaaa-1111-4222-8333-444444444444"; + let mut st = state(&dir, Some(index)); + st.locate_claude_transcript = Some(Arc::new(move |id: &str| { + Some(freshell_sessions::resume_resolve::ClaudeTranscriptHit { + session_id: id.to_ascii_lowercase(), + cwd: Some("/repo/gamma".to_string()), + }) + })); + let (status, body) = post(st, serde_json::json!({ "input": unknown }), true).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + body["matches"], + serde_json::json!([{ + "provider": "claude", + "sessionId": unknown, + "cwd": "/repo/gamma", + "sessionType": "claude", + "matchKind": "exact" + }]) + ); + } + + #[tokio::test] + async fn deleted_override_hides_the_session_from_resolve() { + // Node's resolve reads the post-deleted-filter project groups + // (`session-indexer.ts:209,1155-1156`) and the Rust sidebar filters + // the same way (`session_directory.rs::apply_session_overrides`) — + // the resolve read model must agree with both. Written through the + // REAL override write path (`patch_session_override`, the same call + // `PATCH /api/sessions/{id}` lands on). + let dir = temp_dir("deleted"); + let index = fixture_index(vec![claude_fixture()]).await; + let st = state(&dir, Some(index)); + st.settings + .patch_session_override( + &format!("claude:{CLAUDE_ID}"), + &[("deleted", Some(serde_json::json!(true)))], + ) + .await; + let (status, body) = post(st, serde_json::json!({ "input": CLAUDE_ID }), true).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "ready"); + assert_eq!(body["matches"], serde_json::json!([])); + } + + #[tokio::test] + async fn malformed_json_body_degrades_to_the_missing_input_400() { + // Express's strict body parser answers malformed JSON with an HTML + // 400 before zod runs; this port treats an unparseable body as `{}` + // (Node's absent-body `req.body ?? {}`) and answers the zod-shaped + // missing-`input` 400 — status parity only, a recorded deviation. + let dir = temp_dir("badjson"); + let app = super::router(state(&dir, None)); + let request = Request::builder() + .method("POST") + .uri("/api/sessions/resolve") + .header("content-type", "application/json") + .header("x-auth-token", "tok") + .body(Body::from("{not json")) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["error"], "Invalid resolve request"); + assert_eq!( + body["details"], + serde_json::json!([{ + "expected": "string", + "code": "invalid_type", + "path": ["input"], + "message": "Invalid input: expected string, received undefined" + }]) + ); + } +} +``` + +- [ ] **Step 3: Register the module and run the new tests — RED then GREEN** + +In `crates/freshell-server/src/main.rs`, add `mod resolve;` to the module declaration list (lines ~19-45, alphabetical: after `recovery_inventory`/`repo_icon*`... place it between `rate_limit` and `recovery_inventory` — keep whatever ordering the list actually uses). + +```bash +cargo test -p freshell-server resolve:: +``` + +Expected on first run: everything compiles and the tests PASS if Step 2 was transcribed faithfully (the handler was written alongside its tests). If any test fails, fix the handler — the tests in Step 2 are the parity contract. Also confirm no route-collision panic: the static `/api/sessions/resolve` coexists with `sessions.rs`'s `PATCH /api/sessions/{session_id}` (different router, merged; matchit prefers static segments). + +- [ ] **Step 4: Wire production dependencies in `main.rs`** + +(a) Immediately BEFORE the `let diag_session_index = session_index.clone();` line (~line 900), add: + +```rust + // SYNC-06: the resolve endpoint reads the SAME session index the History + // surfaces read (clone before the move below into `session_directory_state`). + let resolve_session_index = session_index.clone(); +``` + +(b) At the `session_metadata::router(...)` state construction (~line 983), the store is MOVED (`store: session_metadata_store`). Change that field to `store: session_metadata_store.clone(),` so the binding survives for the resolve state. + +(c) In the app assembly (~line 1074), directly after the `.merge(sessions::router(...))` block, add: + +```rust + .merge(resolve::router(resolve::ResolveState { + auth_token: Arc::clone(&auth_token), + // SYNC-06 deleted-override filter: the SAME settings store the + // sidebar overlay (`SessionDirectoryState.settings`) and + // `PATCH /api/sessions/{id}` write path use (constructed once + // at ~line 196; Clone shares the Arc-backed innards). + settings: settings_store.clone(), + session_index: resolve_session_index, + // SYNC-06 sessionType overlay: the SAME store `POST + // /api/session-metadata` writes (Node overlays it in + // `session-indexer.ts:1159-1161`). + session_metadata: session_metadata_store.clone(), + // opencode `ses_*` exact-id fallback: the SAME data home the + // OpencodeSource uses. Read errors (`Err`) are a resolve miss, + // never a 5xx — the endpoint's never-5xx contract. + opencode_dir_by_id: Some(std::sync::Arc::new(|session_id: &str| { + let data_home = freshell_sessions::parse::default_opencode_data_home(); + freshell_sessions::parse::opencode_session_directory_by_id( + &data_home, session_id, + ) + .ok() + .flatten() + })), + // claude transcript exact-id fallback: the SAME ordered-roots scan + // the attach arm and IndexExistenceProbe trust + // (CLAUDE_CONFIG_DIR > CLAUDE_HOME > $HOME/.claude), paired with + // the original-cwd reader. Node's locator lowercases the id + // before scanning and returns the lowercased id — mirrored here. + locate_claude_transcript: Some(std::sync::Arc::new(|session_id: &str| { + let lowered = session_id.to_ascii_lowercase(); + let path = freshell_freshagent::locate_transcript(&lowered)?; + Some(freshell_sessions::resume_resolve::ClaudeTranscriptHit { + session_id: lowered, + cwd: freshell_freshagent::transcript_cwd(&path), + }) + })), + })) +``` + +Note: `session_metadata_store` is constructed at ~line 980, i.e. AFTER `resolve_session_index` is cloned at ~line 900 but BEFORE the app assembly at ~line 1074 — both are in scope at the merge site. If the compiler disagrees about ordering, move the resolve-state construction into a `let resolve_state = ...` binding placed after line 983 and merge that. + +- [ ] **Step 5: Build + full crate test** + +Node deps must be present (`tests/safe11_term22_shutdown_reaping.rs` spawns the committed Node fixture `fake-app-server.mjs`, which imports `ws`) — the guard is a no-op if Task 1 Step 0 already ran: + +```bash +test -d node_modules || npm ci --no-audit --no-fund +cargo test -p freshell-server +``` + +Expected: all green, including the pre-existing `session_metadata` and `sessions` suites. + +- [ ] **Step 6: Format, lint, commit** + +```bash +cargo fmt --all +cargo clippy -p freshell-server --all-targets -- -D warnings +git add crates/freshell-server/src/resolve.rs crates/freshell-server/src/session_metadata.rs crates/freshell-server/src/main.rs +git commit -m "feat(server): POST /api/sessions/resolve with Node-parity behavior (SYNC-06)" +``` + +--- + +### Task 7: Declare the `sessionResolve` feature flag + +Unconditional `true`, exactly like Node. Test-first: the two whole-object flag assertions in `main.rs` are the RED. + +**Files:** +- Modify: `crates/freshell-server/src/main.rs` (`build_platform_payload` + its two tests) +- Modify: `server/platform-router.ts` (comment only) + +**Interfaces:** +- Consumes: nothing new. +- Produces: `GET /api/platform` / `GET /api/bootstrap` featureFlags now include `"sessionResolve": true` — the shared client's `s.connection?.featureFlags?.sessionResolve === true` gate (Sidebar.tsx) starts rendering the Resume button on Rust builds. No client change. + +- [ ] **Step 1: Update the two flag tests to expect the new flag (RED)** + +In `crates/freshell-server/src/main.rs` tests (~lines 2179-2199), update BOTH assertions: + +```rust + #[test] + fn platform_payload_feature_flags_shape_matches_legacy() { + // `server/platform-router.ts#detectFeatureFlags`: `{ kilroy, aiEnabled, + // sessionResolve }`, camelCase, no extra fields — mirrored 1:1 in the + // Rust payload. `sessionResolve` is an unconditional literal on both + // servers (SYNC-06). + let env = MapEnv::new().with("GOOGLE_GENERATIVE_AI_API_KEY", "sk-live-abc123"); + let payload = build_platform_payload(serde_json::json!({}), &env); + assert_eq!( + payload["featureFlags"], + serde_json::json!({ "kilroy": false, "aiEnabled": true, "sessionResolve": true }) + ); + } + + #[test] + fn platform_payload_ai_enabled_false_without_key() { + let env = MapEnv::new(); + let payload = build_platform_payload(serde_json::json!({}), &env); + assert_eq!( + payload["featureFlags"], + serde_json::json!({ "kilroy": false, "aiEnabled": false, "sessionResolve": true }) + ); + } +``` + +- [ ] **Step 2: Run to verify both fail** + +```bash +cargo test -p freshell-server platform_payload +``` + +Expected: 2 FAILED (payload lacks `sessionResolve`). + +- [ ] **Step 3: Declare the flag** + +In `build_platform_payload` (~line 1553), change the featureFlags line to: + +```rust + "featureFlags": { "kilroy": false, "aiEnabled": ai_enabled(env), "sessionResolve": true }, +``` + +and extend the function's doc comment with one line: + +```rust +/// `featureFlags.sessionResolve` is the unconditional literal both servers +/// declare now that `POST /api/sessions/resolve` exists here too (SYNC-06). +``` + +- [ ] **Step 4: Run to verify both pass** + +```bash +cargo test -p freshell-server platform_payload +``` + +Expected: 2 passed. + +- [ ] **Step 5: Retire the stale Node comment** + +In `server/platform-router.ts` (inside `detectFeatureFlags`), replace: + +```ts + // Resume-by-id UI: only the Node server implements POST /api/sessions/resolve. + // The Rust server's featureFlags parity (crates/freshell-server/src/boot.rs) + // intentionally omits this key, hiding the Sidebar Resume button there. + sessionResolve: true, +``` + +with: + +```ts + // Resume-by-id UI (SYNC-06): BOTH servers implement POST + // /api/sessions/resolve and declare this flag — the Rust side in + // build_platform_payload (crates/freshell-server/src/main.rs). + sessionResolve: true, +``` + +Comment-only change; verify with (deps guard is a no-op if Task 1 Step 0 already ran): + +```bash +test -d node_modules || npm ci --no-audit --no-fund +npm run typecheck +npm run test:vitest -- --config config/vitest/vitest.server.config.ts test/integration/server/sessions-resolve-router.test.ts --run +``` + +Expected: typecheck clean; 14 passed (Node suite untouched, still green). + +- [ ] **Step 6: Commit** + +```bash +git add crates/freshell-server/src/main.rs server/platform-router.ts +git commit -m "feat(server): declare sessionResolve feature flag from the Rust server (SYNC-06)" +``` + +--- + +### Task 8: Enable the resume-button e2e spec on BOTH Playwright projects + +The checklist's PW-RUST validation. Two coupled edits — doing only one produces a silent false green: (1) delete the defensive skip guard, (2) register the spec in `MATRIX_SPECS`. + +**Files:** +- Modify: `test/e2e-browser/specs/resume-button.spec.ts` +- Modify: `test/e2e-browser/playwright.config.ts` + +**Interfaces:** +- Consumes: the Rust endpoint + flag (Tasks 6–7). The spec's `bootResumeScenario(e2eServerKind)` already parameterizes server kind, seeds an isolated HOME with 45 codex `~/.codex/sessions/*.jsonl` fixtures (first line `{type:'session_meta', payload:{id, cwd}}` — indexable by Rust's `CodexSource`, which honors the harness's `CODEX_HOME`), and boots via `createE2eServerHandle`. +- Produces: 3 tests × 2 projects green — the GATE-01 "no Rust-only skips for a user-visible feature" evidence. + +- [ ] **Step 0: Install node deps in the worktree (Playwright precondition)** + +The worktree starts with NO `node_modules` and no `dist/client`. Playwright's `globalSetup` builds the client but does NOT install dependencies, and the legacy leg's `fake-app-server.mjs` imports `ws` — so ANY Playwright invocation below (even `--list`) needs deps installed first: + +```bash +cd /home/dan/code/freshell/.worktrees/rust-resolve-parity +test -d node_modules || npm ci --no-audit --no-fund +``` + +- [ ] **Step 1: Delete the skip guard** + +In `test/e2e-browser/specs/resume-button.spec.ts`: +1. Delete the `RUST_SKIP` constant (lines ~46-50): + +```ts +const RUST_SKIP = + 'KNOWN DIVERGENCE: the Rust server has no POST /api/sessions/resolve and does not ' + + 'declare the sessionResolve feature flag (button hidden there by design) — ' + + 'out of scope, see docs/plans/2026-07-29-resume-session-button.md.' +``` + +2. Delete all three `test.skip(e2eServerKind !== 'legacy', RUST_SKIP)` lines (the first statement of each test, at ~lines 233, 264, 284). Keep the `e2eServerKind` fixture parameter in each test signature — it still drives `createE2eServerHandle`/`bootResumeScenario`. + +- [ ] **Step 2: Register the spec in `MATRIX_SPECS`** + +In `test/e2e-browser/playwright.config.ts`, add to the `MATRIX_SPECS` array (alphabetical near the other `resume`/`sidebar` entries, following the file's one-comment-per-entry convention): + +```ts + // SYNC-06 -- resume-by-id parity: the pinned sidebar Resume button and the + // paste-then-Enter resume path against BOTH servers (POST /api/sessions/resolve + // + sessionResolve flag now exist on the Rust server too). + /resume-button\.spec\.ts$/, +``` + +- [ ] **Step 3: Verify collection is non-zero on BOTH projects (silent-false-green guard)** + +```bash +cd /home/dan/code/freshell/.worktrees/rust-resolve-parity +npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium --list specs/resume-button.spec.ts +npx playwright test --config test/e2e-browser/playwright.config.ts --project=legacy-chromium --list specs/resume-button.spec.ts +``` + +Expected: each lists exactly 3 tests ("resume button stays visible at top/middle/bottom scroll", "resume button is visible in fullWidth mobile mode", "paste-then-Enter resumes the session with the right agent"). Zero collected = the config edit is wrong; stop and fix. + +- [ ] **Step 4: Build the release server and run the rust leg** + +```bash +cargo build --release -p freshell-server +npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium specs/resume-button.spec.ts +``` + +Expected: 3 passed (NOT skipped — verify the summary says "3 passed", not "3 skipped"). Debugging notes if red: +- Button not visible → check `GET /api/bootstrap` from the harness server includes `featureFlags.sessionResolve: true` (Task 7). +- Dialog finds no match → the Rust `CodexSource` didn't index the seeded fixtures; confirm the harness env sets `CODEX_HOME=/.codex` (`helpers/rust-server.ts` `applyIsolatedHomeEnvironment`) and that the fixture's first line carries `payload.cwd` (Rust's `parse_codex_file` requires `meta.cwd`). +- `status: warming` forever → the index never warmed; check the server booted with a resolvable `HOME` (the harness sets one). +- The dual-mode `CODEX_CMD` wrapper is harmless on the Rust leg (falls through to TUI mode); the argv-log assertion still applies. + +- [ ] **Step 5: Run the legacy leg (regression)** + +```bash +npx playwright test --config test/e2e-browser/playwright.config.ts --project=legacy-chromium specs/resume-button.spec.ts +``` + +Expected: 3 passed. + +- [ ] **Step 6: Run both legs once more (flake check), then commit** + +```bash +npx playwright test --config test/e2e-browser/playwright.config.ts --project=rust-chromium --project=legacy-chromium specs/resume-button.spec.ts +git add test/e2e-browser/specs/resume-button.spec.ts test/e2e-browser/playwright.config.ts +git commit -m "test(e2e): run resume-button spec on both server kinds (SYNC-06 PW-RUST)" +``` + +Expected: 6 passed. + +--- + +### Task 9: Full verification sweep + checklist evidence + +**Files:** +- Modify: `docs/plans/2026-07-14-rust-tauri-parity-completion-checklist.md` + +**Interfaces:** +- Consumes: everything above. +- Produces: the SYNC-06 closure entry with evidence; a fully green tree on the branch. + +- [ ] **Step 1: Rust gates** + +```bash +cd /home/dan/code/freshell/.worktrees/rust-resolve-parity +test -d node_modules || npm ci --no-audit --no-fund +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +FRESHELL_TEST_SUMMARY="SYNC-06 rust resolve parity" cargo test --workspace +``` + +Expected: fmt clean, clippy clean, workspace tests 0 failed. Record the per-crate pass counts for the evidence entry. + +- [ ] **Step 2: TS gates (focused files + typecheck)** + +```bash +npm run typecheck +npm run test:vitest -- --config config/vitest/vitest.config.ts test/unit/shared/resume-input-parser.test.ts --run +npm run test:vitest -- --config config/vitest/vitest.server.config.ts test/integration/server/sessions-resolve-router.test.ts --run +``` + +Expected: typecheck clean; 32 passed; 14 passed. + +- [ ] **Step 3: Checklist evidence entry** + +Follow the checklist's OWN convention for items with outstanding platform legs: the checkbox stays UNCHECKED (`- [ ]`) and the evidence lands as a `PARTIAL` bullet that names what is green and what is `MISSING`. That is the SYNC-05/SAFE-11 precedent — the file's existing entry (~line 276) reads, verbatim: + +> - PARTIAL (2026-07-18): `crates/freshell-server/tests/safe11_term22_shutdown_reaping.rs` (commits edf1e93d, a8d43d9d) boots the real binary, […] proven RED before the fix, green after (including sandboxed runs). MISSING: this is a Rust integration test, not a `PW-RUST`/stress-project Playwright spec — it does not cover […] (that slice is `SYNC-05`, itself only partial). + +In `docs/plans/2026-07-14-rust-tauri-parity-completion-checklist.md`, KEEP the SYNC-06 checkbox (~line 803) as `- [ ]` — the linux Playwright legs are green but the `PW-TAURI-WIN` half of its named validation is outstanding — and rewrite the entry as below, following the `file :: test title — assertion — projects/runs` convention. Substitute the real date, commit sha, and pass counts observed in Steps 1–2 and Task 8: + +```markdown +- [ ] **SYNC-06 — Session resume-by-id parity: `POST /api/sessions/resolve` + `sessionResolve` feature flag.** The Node server (`server/sessions-router.ts`) resolves pasted session ids/resume commands across claude/codex/opencode/amplifier and gates the sidebar Resume button via the `sessionResolve` flag in `detectFeatureFlags()`. See `docs/plans/2026-07-29-resume-session-button.md`. + - **Playwright validation (`PW-RUST`, `PW-TAURI-WIN`):** With the flag declared, the sidebar shows the pinned Resume button; pasting a known session id resumes it in a tab (mirror `test/e2e-browser/specs/resume-button.spec.ts`). + - PARTIAL (, commit ``): Rust endpoint `crates/freshell-server/src/resolve.rs` (`POST /api/sessions/resolve`: auth/400-validation pinned to zod 4.3.6 wire literals/warming/exact/prefix/cap-20/dedupe/deleted-override filter/opencode-walk+claude exact-id fallbacks) + `crates/freshell-sessions/{resume_input.rs,resume_resolve.rs}`; flag declared in `build_platform_payload` (`main.rs`). Cross-language anti-drift: `test/fixtures/resume-input/parser-cases.json` (31 cases) consumed by BOTH `test/unit/shared/resume-input-parser.test.ts` (32 passed) and `crates/freshell-sessions/tests/resume_input_parser_parity.rs` (green). Logic parity: `crates/freshell-sessions/tests/resume_resolve.rs` mirrors `test/integration/server/sessions-resolve-router.test.ts` (14 passed, unchanged). `cargo test --workspace`: passed, 0 failed; `cargo fmt --all -- --check` / `cargo clippy --workspace --all-targets -- -D warnings` clean. E2E (`PW-RUST` half): `test/e2e-browser/specs/resume-button.spec.ts` moved into `MATRIX_SPECS` with the legacy-only skip guard DELETED — all 3 tests (pinned visibility at scroll, fullWidth mobile visibility, paste-then-Enter real resume with argv proof) green on BOTH projects (legacy-chromium and rust-chromium), 2 runs each. MISSING: the `PW-TAURI-WIN` (native Windows WebView2) half of the named validation — left to dependent tickets, per the SYNC-05/SAFE-11 PARTIAL convention. +``` + +- [ ] **Step 4: Final commit** + +```bash +git add docs/plans/2026-07-14-rust-tauri-parity-completion-checklist.md +git commit -m "docs: record SYNC-06 rust resolve parity evidence in completion checklist" +git log --oneline origin/main..HEAD +``` + +Expected: the branch carries one focused commit per task (≈10). Do NOT push/PR without explicit user approval. + +--- + +## Self-Review Record + +**1. Spec coverage.** R1 endpoint parity → Task 6 (same path, same auth helper as all Rust API routes, zod-shaped strict validation with the exact 400 body, always-200 semantics). R2 parser parity + shared fixtures → Tasks 1–2 (one committed JSON table, both suites consume it). R3 matching parity (four providers, exact+prefix, most-recent-first, cap 20, matchKind) → Task 5 (+ HTTP-level pins in Task 6). R4 fallback parity → Task 3 ports Node's opencode by-id parent-walk bug for bug (legacy early hit, truthy-directory filter, orphan/cycle miss); Task 4 reuses the existing claude `locate_transcript` + its already-written cwd companion via export, with the locator's known deltas RECORDED as accepted deviations (Task 4 note + Parity Reference list) rather than claimed as parity. R5 metadata parity → Task 5 `to_match` + the sessionType overlay from the metadata store (Task 6 Step 1 un-gates the read; Node's overlay source is the same store, and absence is the normal case on both servers — the client falls back to `sessionType ?? provider`). R6 warming parity → Task 5/6 (peek-gated; hint still populated; `session_index: None` also warms, documented). R7 feature flag, ungated → Task 7. R8 checklist update with PW-TAURI-WIN out-of-scope note → Task 9. Verification section: Rust tests mirroring the Node suite (Task 5 + Task 6 tests), clippy/fmt (Tasks 2–9), cross-language fixtures (Tasks 1–2), e2e on both projects with guard removal + MATRIX_SPECS registration (Task 8), client suite untouched (comment-only TS changes; typecheck + the two vitest files re-run in Task 9). + +**1b. No silent deferrals.** Every requirement lands as production behavior proven by an observable outcome: the e2e paste-then-Enter test spawns a REAL CLI with `resume ` argv against the REAL Rust server binary (no stub); fallback closures in production wiring call the real sqlite/filesystem code (test doubles appear only inside unit tests, with the production path covered by Task 3/4's direct tests + Task 8's e2e). The single intentional error-path divergence (Rust answers ready-empty where Express would 500 on a thrown dependency) is recorded in code comments (Task 5/6) and follows the Rust port's existing never-5xx convention; it is unobservable by the client's happy path and untested on the Node side. + +**2. Placeholder scan.** No TBDs; every code step carries complete code; commands carry expected outputs. Two deliberate "verbatim-context" dependencies remain (main.rs line numbers drift; the implementer anchors on the quoted surrounding code, which is provided), and Task 9's evidence entry contains `//` placeholders that are explicitly instructed to be substituted with observed values — they cannot be known at plan time. + +**3. Type consistency.** `ResumeCandidateKind/{PrefixedId,Uuid,HexPrefix}`, `ResumeHint{provider,source}` (Task 2) are consumed with those exact names in Tasks 5–6. `OpencodeSessionDirectory{directory}` (Task 3) is the closure payload in Tasks 5–6. `ClaudeTranscriptHit{session_id,cwd}` (Task 5) is constructed in Task 6's wiring and tests. `ResolveDeps` field names/borrow shapes match between definition (Task 5) and use (Task 6: `as_deref()` against `Arc` matches the `&(dyn Fn ... + Send + Sync)` field type). `RESOLVE_MATCH_CAP` is defined once (Task 5) and asserted in tests. `SessionMetadataStore::new(dir)` appends `session-metadata.json` — Task 6's overlay test writes that exact filename into the dir it passes. + +**4. Load-bearing-assumption revision (2026-07-29).** A validation pass falsified four assumptions; this plan was revised accordingly and the self-review items above were re-applied to every edited task. A2 (V2-zod-truth): Task 6's 400 `details` literals were zod v3 wording — rewritten to the probed zod 4.3.6 wire output (received-type message suffixes via a `received_type` helper, `origin`/`inclusive` fields, double-quoted singular/plural `Unrecognized key(s)` form, `expected`/`origin`-before-`code` key order pinned by a serialized-string test, input-issue-before-`unrecognized_keys` array order pinned by a new multi-issue test), the body parse-failure path now degrades to `{}` (Node's absent-body `req.body ?? {}`) instead of `Null`, and the Global Constraints/module doc now record that NO consumer reads `details`, that the literals are zod-4.3.6-version-fragile (re-probe on bumps), and the accepted Express-HTML-400 / body-limit / 405-method deviations. A3 (V3-opencode-sqlite): Task 3's bare `SELECT directory` diverged from Node on 4 of 9 executed variants (D1-D4) — replaced with a bug-for-bug port of `resolveOpencodeSessionRoots` (legacy-schema early HIT with no existence check, truthy-directory filter, orphan/cycle ⇒ miss via a seen-set parent walk) plus an 11-case fixture suite covering every probed variant; Task 5's fallback docs and a new omitted-`cwd` test cover the `directory: None` hit. A4 + A14 (V4-semantics-diff, V7-deleted-overrides): the raw-snapshot membership claim was falsified — the Task 6 handler now drops `deleted`-overridden sessions via the SYNC `SettingsStore::session_overrides()` read (composite-key-only, matching Node's post-filter `getProjects()` AND the Rust sidebar's own `apply_session_overrides`; the exact-id fallbacks bypass the filter as Node's do, per comment), with `ResolveState.settings` wired from the same `settings_store.clone()` pattern as `SessionDirectoryState` and pinned by a new handler test; the remaining membership/ordering/locator deltas (enabledProviders gate, 256 KiB snippet window, cold-start window, tie-order/recency, A6 claude-locator deltas) are recorded as accepted deviations in the Parity Reference and Task 4 rather than silently claimed as parity. Also folded in: V1's 405-method note, V1's warmed-empty-index note (already honored — warming tests keep `session_index: None`), and Task 8's `npm ci` precondition (the worktree ships without `node_modules`; Playwright's globalSetup builds but does not install). diff --git a/docs/plans/2026-07-30-rust-resolve-parity-hardened.md b/docs/plans/2026-07-30-rust-resolve-parity-hardened.md new file mode 100644 index 000000000..e3b4ae40f --- /dev/null +++ b/docs/plans/2026-07-30-rust-resolve-parity-hardened.md @@ -0,0 +1,2214 @@ +# Rust Resolve-Session Parity with the Hardened (#586) Resume Contract — Implementation Plan + +> ## ⚠️ EXECUTED / COMPLETION RECORD — DO NOT EXECUTE ⚠️ +> +> **This plan was FULLY IMPLEMENTED on branch `feat/rust-resolve-parity`** +> (all seven tasks, committed; the run's evidence is recorded in the SYNC-06 +> `PARTIAL (2026-07-30, hardened-contract follow-up, commit 22022a848)` +> bullet of `docs/plans/2026-07-14-rust-tauri-parity-completion-checklist.md`). +> The text below is preserved as a HISTORICAL RECORD of the executed +> workflow — it is NOT an executable task sequence against HEAD: +> +> - **Pre-implementation assertions in the body describe the state BEFORE +> execution, not HEAD.** Statements like "API X does not exist yet", +> "Expected: compile FAILURE", and the Task 1 Step 4 drift findings were +> true at planning time; every listed API/behavior has since LANDED, and +> the RED-gate "expected failure" runs succeeded during execution and are +> no longer reproducible at HEAD (the suites now compile and pass). +> - **The step checkboxes (`- [ ]`) are preserved in their historical +> unchecked working form** — the executing workflow tracked per-task +> completion externally (fresh implementer per task, spec + quality review +> after each). They do NOT indicate pending work: every step of every task +> was executed and committed. +> - **The branch's git history was subsequently REWORDED in place** (two +> commit messages corrected; commit `6976f1caf` remapped the checklist +> SHAs). `origin/feat/rust-resolve-parity` still holds the divergent +> pre-reword history, so Task 7's plain +> `git push -u origin feat/rust-resolve-parity` no longer applies: +> publishing requires the user's deliberate `git push --force-with-lease` +> (safety tag `pre-reword-backup` preserves the pre-reword tip). Pre-reword +> SHAs cited in the body (e.g. `c38422a0`) are superseded — see the +> in-place annotations. +> - A few passages were corrected by post-review fix commits (home-resolution +> parity via `provider_home()`; resolve admission rescoped to the fallback +> dispatch) — the `POST-EXECUTION NOTE` blocks in the body mark where the +> landed implementation diverged from the original planned text. +> - ERRATA (2026-07-31): the interim admission design (through `63bb31390`) +> awaited the semaphore INSIDE the outer resolver blocking worker, so permit +> starvation pinned an unbounded blocking-pool worker per dispatch for the +> full deadline — the exhaustion the semaphore claimed to prevent; fixed by +> this commit with synchronous fail-fast `try_acquire` admission before the +> fallback task exists. +> +> **DO NOT EXECUTE.** Re-running these steps against HEAD would fail on +> already-landed APIs and unmet "expected failure" gates. + +> **For agentic workers:** This plan is executed task-by-task by the +> workflow's execute stage: a fresh implementer per task, with a spec + +> quality review after each task. Steps use checkbox (`- [ ]`) syntax +> for tracking. + +**Goal:** Bring the existing, rebased 16-commit Rust resolve-parity foundation (branch `feat/rust-resolve-parity`) up to the HARDENED resume contract that landed on main in PR #586 (merge `f903e8a6`), so the Rust `POST /api/sessions/resolve` is wire- and behavior-identical to the hardened Node implementation. + +**Architecture:** This is an ADOPT-AND-EXTEND delta, not a rebuild. The rebase onto `f903e8a6` is already done (merge-base verified == `f903e8a6`); conflicts were resolved favoring main's hardened TS. The Node code AT THIS WORKTREE'S HEAD is therefore the authoritative truth to mirror: `shared/resume-resolve-contract.ts`, `shared/resume-input-parser.ts`, `server/coding-cli/resolve-session.ts`, `server/coding-cli/resolve-fallbacks.ts`, `server/coding-cli/providers/opencode-by-id-query.ts`, `server/sessions-router.ts` (lines 255–316). The Rust side to extend: `crates/freshell-sessions/src/{resume_input.rs,resume_resolve.rs}`, `crates/freshell-sessions/src/parse/opencode.rs`, `crates/freshell-sessions/src/directory_index.rs`, `crates/freshell-server/src/resolve.rs`, `crates/freshell-server/src/main.rs`. The cross-language fixture `test/fixtures/resume-input/parser-cases.json` is the anti-drift keystone: it gets EXTENDED to the hardened parser behavior and BOTH implementations must pass it (the rebase silently dropped the TS side's fixture consumption — Task 2 restores it). + +**Tech Stack:** Rust (axum, serde with `preserve_order`, rusqlite, regex, tokio), TypeScript (Node server, zod 4.3.6, vitest), Playwright e2e matrix (legacy-chromium + rust-chromium). + +## Global Constraints + +- NEVER touch ports 3001/3002 or any process you did not spawn (production server + live tabs run there). All server testing on ephemeral ports with throwaway HOMEs. Repeat this constraint to any subagent you dispatch. +- The MAIN checkout `/home/dan/code/freshell` has ~15 dirty files + untracked files from OTHER live sessions — never stage, stash, clean, or modify them. ALL work happens in the worktree `/home/dan/code/freshell/.worktrees/rust-resolve-parity` on branch `feat/rust-resolve-parity`. +- TS imports use NodeNext ESM: relative imports carry the `.js` extension. +- Coordinated tests: run `npm run test:status` before any broad vitest run; use `npm run test:vitest -- --config --run` for focused runs; `cargo test -p ` for Rust. +- Conventional commits, focused and atomic, each with the Amplifier footer: + + ``` + 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) + + Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> + ``` + +- Wire-shape parity constants (copy these EXACT values): `RESOLVE_MATCH_CAP = 20`, `MAX_RESUME_CANDIDATES = 8`, `FALLBACK_BUDGET_PER_REQUEST = 2`, opencode by-id busy timeout `500` ms, known resume providers `["claude", "codex", "opencode", "amplifier"]`, scan-failure message literal `"session scan failed"`. +- `serde_json` has `preserve_order` enabled workspace-wide: struct field order IS wire key order. Field order in Rust wire structs must match the Node object literals. +- README.md is the only end-user markdown doc; this plan and the SYNC-06 checklist/spec under `docs/plans/` are working/agent docs. +- Branch may be pushed to origin at the end (Task 7). NO pull request without explicit user approval. + +--- + +### Task 1: Verify post-rebase reality and the committed spec doc + +The rebase resolved TS conflicts favoring main's hardened semantics. Before changing anything, pin down what actually compiles and passes NOW — the failures and drift found here are the worklist the later tasks close. The SYNC-06 spec doc is ALREADY COMMITTED (`docs/plans/2026-07-29-rust-resolve-parity-spec.md`, aligned to the hardened contract during plan review) — verify it, do not re-commit it. + +**Files:** +- Read (already committed): `docs/plans/2026-07-29-rust-resolve-parity-spec.md` +- No file changes and NO commit in this task. + +**Interfaces:** +- Consumes: the rebased worktree at `feat/rust-resolve-parity` (merge-base == `f903e8a6`). +- Produces: a verified baseline (recorded in this task's completion report; later tasks' commit bodies may cite it): cargo workspace state, which resume suites pass, and the confirmed drift findings listed below. + +- [ ] **Step 1: Confirm worktree identity and cleanliness** + +Run: +```bash +cd /home/dan/code/freshell/.worktrees/rust-resolve-parity +git branch --show-current && git merge-base HEAD f903e8a6f5e2e0e926890e38c28e775776fec7de && git status --porcelain +``` +Expected: branch `feat/rust-resolve-parity`; merge-base prints `f903e8a6f5e2e0e926890e38c28e775776fec7de`; `git status --porcelain` prints NOTHING (clean tree — the spec doc is already committed). If there ARE dirty/untracked files or lock files, STOP and surface it — the worktree is not idle. + +- [ ] **Step 2: Rust baseline** + +Run (from the worktree root): +```bash +set -o pipefail +cargo test -p freshell-sessions -p freshell-server 2>&1 | tail -20 +cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -5 +``` +(`set -o pipefail` is load-bearing on every piped cargo command in this plan: without it a failing `cargo` exits through `tail`'s success status.) +Expected: all tests pass and fmt/clippy are clean. The Rust side compiled at the old base and the rebase touched no Rust files' dependencies, so a failure here means the rebase broke something — investigate before proceeding (do not "fix forward" blind). + +- [ ] **Step 3: TS baseline for the resume suites** + +Run: +```bash +npm run test:status +npm run test:vitest -- --config config/vitest/vitest.config.ts test/unit/shared/resume-input-parser.test.ts --run +npm run test:vitest -- --config config/vitest/vitest.server.config.ts test/unit/server/coding-cli/resolve-session.test.ts test/unit/server/coding-cli/resolve-fallbacks.test.ts test/integration/server/sessions-resolve-router.test.ts --run +``` +Expected: ALL PASS (these are main's hardened tests, untouched by the branch). + +- [ ] **Step 4: Confirm the drift findings (read, don't fix — fixes are Tasks 2–6)** + +Verify each of these against the code; they are the delta worklist: + +> **POST-EXECUTION NOTE (2026-07-31):** the five drift findings below were the +> PRE-EXECUTION baseline, preserved verbatim. All five have since LANDED +> (Tasks 2–6): the TS test is fixture-driven again, the Rust parser carries +> the known-family regex + `MAX_RESUME_CANDIDATES = 8`, the resolve core is +> the hardened per-token port (`Degraded`, `providerErrors`, case rules, +> subagent exclusion, budgeted shape-gated fallbacks), the opencode lookup is +> the direct by-id row query, and the wire response carries +> `providerErrors`/`unsearchedProviders`/`homeDir`. Checking these items +> against HEAD shows the OPPOSITE of what each asserts — that is the proof of +> completion, not a plan/code mismatch. + +1. `test/unit/shared/resume-input-parser.test.ts` does NOT read `test/fixtures/resume-input/parser-cases.json` (the rebase kept main's inline version) — the anti-drift keystone is broken on the TS side even though both suites are green. +2. `crates/freshell-sessions/src/resume_input.rs` still has the generic `[a-z]{2,10}_[0-9A-Za-z]{8,40}` prefixed-id regex and NO candidate cap; the hardened TS parser (`shared/resume-input-parser.ts:29,37`) has the known-family regex and `MAX_RESUME_CANDIDATES = 8`. +3. `crates/freshell-sessions/src/resume_resolve.rs` has status `Ready|Warming` only (no `Degraded`), no `providerErrors`, lowercases ALL tokens (ses_ ids must be case-SENSITIVE), does not exclude subagents from prefix discovery, runs the index pass for ALL tokens before ANY fallback (hardened order is per-token exact → fallback → prefix), has no fallback shape gates or per-request budget, and maps fallback read errors to a silent miss (the incident class). +4. `crates/freshell-sessions/src/parse/opencode.rs::opencode_session_directory_by_id` is the OLD #583 parent-walk; hardened Node (`opencode-by-id-query.ts`) is a direct by-id row query (archived + child sessions included, full row returned, errors PROPAGATE). +5. `crates/freshell-server/src/resolve.rs` response is `{status, matches, hint}` only; hardened wire adds `providerErrors`, `unsearchedProviders`, `homeDir` (`server/sessions-router.ts:306-314`), plus scan-failure merge, disabled-provider reporting, and degraded fire-and-forget refresh. + +- [ ] **Step 5: Verify the committed spec doc and record the baseline** + +Run: `git log --oneline -1 -- docs/plans/2026-07-29-rust-resolve-parity-spec.md` +Expected: one commit line (the spec is tracked, nothing uncommitted). Open the spec and confirm its "Contract" bullet describes the HARDENED response (`ready|warming|degraded`, `providerErrors`, `unsearchedProviders`, `homeDir`) — it was aligned during plan review; if it still describes a `{status, matches, hint}`-only response, STOP and surface it. Record the Step 2–4 baseline findings (workspace state, suite results, confirmed drift list) in this task's completion report. NO commit in this task. + +--- + +### Task 2: Extend the shared parser fixture to the hardened parser; both parsers pass it + +The fixture table is the anti-drift mechanism. Extend it to the hardened TS parser's behavior, restore fixture consumption on the TS side, and port the hardened parser rules (known-family prefix regex, candidate cap 8) to Rust. One task because the fixture change necessarily goes red on one side until both parsers agree — the task is done only when BOTH suites are green against the SAME table. + +**Files:** +- Modify: `test/fixtures/resume-input/parser-cases.json` +- Rewrite: `test/unit/shared/resume-input-parser.test.ts` +- Modify: `crates/freshell-sessions/src/resume_input.rs` +- Existing (unchanged): `crates/freshell-sessions/tests/resume_input_parser_parity.rs` (already fixture-driven) + +**Interfaces:** +- Consumes: `parseResumeInput` / `parse_resume_input` as they exist today. +- Produces: `pub const MAX_RESUME_CANDIDATES: usize = 8` exported from `crates/freshell-sessions/src/resume_input.rs` (Task 3's core relies on the parser capping candidates; nothing else changes in the parser's public signature). TS `MAX_RESUME_CANDIDATES` already exists. + +- [ ] **Step 1: Rewrite the TS test to be fixture-driven (this is the failing test)** + +Replace the ENTIRE contents of `test/unit/shared/resume-input-parser.test.ts` with: + +```ts +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { parseResumeInput, MAX_RESUME_CANDIDATES } from '@shared/resume-input-parser' + +interface FixtureCase { + name: string + input: string + candidates: Array<{ token: string; kind: string }> + hint: { provider: string; source: string } | null +} + +const fixturePath = fileURLToPath( + new URL('../../fixtures/resume-input/parser-cases.json', import.meta.url), +) +const fixture = JSON.parse(readFileSync(fixturePath, 'utf8')) as { cases: FixtureCase[] } + +describe('parseResumeInput — shared cross-language fixture (SYNC-06 anti-drift)', () => { + it('fixture is non-trivial', () => { + expect(fixture.cases.length).toBeGreaterThanOrEqual(31) + }) + + it.each(fixture.cases.map((c) => [c.name, c] as const))('%s', (_name, testCase) => { + const parsed = parseResumeInput(testCase.input) + expect(parsed.candidates).toEqual(testCase.candidates) + expect(parsed.hint).toEqual(testCase.hint) + }) +}) + +describe('parseResumeInput — TS-only invariants', () => { + // The cap VALUE is part of the server work-budget contract; the capping + // BEHAVIOR is pinned by the fixture's cap case in both languages. + it('MAX_RESUME_CANDIDATES is 8', () => { + expect(MAX_RESUME_CANDIDATES).toBe(8) + }) +}) +``` + +- [ ] **Step 2: Run it — expect FAIL (proves the keystone pins the TS parser again)** + +Run: `npm run test:vitest -- --config config/vitest/vitest.config.ts test/unit/shared/resume-input-parser.test.ts --run` +Expected: FAIL on exactly one case — `non-ses prefixed id yields no id-shape hint` (`abc_12345678`): the hardened TS parser rejects unknown `abc_` families, so it returns NO candidates while the stale fixture expects one. Every other case passes. + +- [ ] **Step 3: Update the fixture to the hardened parser's behavior** + +In `test/fixtures/resume-input/parser-cases.json`: + +3a. REPLACE the case named `"non-ses prefixed id yields no id-shape hint"` (the `abc_12345678` one) with: + +```json + { + "name": "arbitrary snake_case prefix is not a known id family", + "input": "abc_12345678", + "candidates": [], + "hint": null + }, +``` + +3b. APPEND these cases before the closing `]` (after the `"claude -rf ..."` case, adding a comma to that case's closing brace): + +```json + { + "name": "arbitrary snake_case identifiers never match", + "input": "my_function123 snake_casedword9", + "candidates": [], + "hint": null + }, + { + "name": "known thread_ id family", + "input": "thread_abc123456", + "candidates": [{ "token": "thread_abc123456", "kind": "prefixed-id" }], + "hint": null + }, + { + "name": "known task_ id family with a long 46-char suffix", + "input": "task_a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8s9T0u1V2w3", + "candidates": [ + { "token": "task_a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8s9T0u1V2w3", "kind": "prefixed-id" } + ], + "hint": null + }, + { + "name": "candidates are capped at 8 (server work budget)", + "input": "417e83450a00 417e83450a01 417e83450a02 417e83450a03 417e83450a04 417e83450a05 417e83450a06 417e83450a07 417e83450a08 417e83450a09 417e83450a10 417e83450a11", + "candidates": [ + { "token": "417e83450a00", "kind": "hex-prefix" }, + { "token": "417e83450a01", "kind": "hex-prefix" }, + { "token": "417e83450a02", "kind": "hex-prefix" }, + { "token": "417e83450a03", "kind": "hex-prefix" }, + { "token": "417e83450a04", "kind": "hex-prefix" }, + { "token": "417e83450a05", "kind": "hex-prefix" }, + { "token": "417e83450a06", "kind": "hex-prefix" }, + { "token": "417e83450a07", "kind": "hex-prefix" } + ], + "hint": { "provider": "amplifier", "source": "id-shape" } + } +``` + +(Verification notes for the case authors above, against `shared/resume-input-parser.ts`: `thread_abc123456` has an 9-char suffix within `{8,64}` and no agent word → prefixed-id candidate, no `ses_` prefix → hint null. The 46-char `task_` suffix is within `{8,64}` and beyond the OLD Rust regex's 40 cap. The 12 hex tokens are equal-length so the stable length sort keeps text order; the cap keeps the first 8; top candidate is a hex-prefix → amplifier id-shape hint.) + +- [ ] **Step 4: Run the TS suite — expect PASS** + +Run: `npm run test:vitest -- --config config/vitest/vitest.config.ts test/unit/shared/resume-input-parser.test.ts --run` +Expected: PASS (all fixture cases + TS-only invariants). + +- [ ] **Step 5: Run the Rust parity suite — expect FAIL (the fixture now leads the Rust parser)** + +Run: `cargo test -p freshell-sessions --test resume_input_parser_parity` +Expected: FAIL on `arbitrary snake_case prefix is not a known id family` (old generic regex still matches `abc_12345678`), `arbitrary snake_case identifiers never match`, `known task_ id family with a long 46-char suffix` (old regex caps the suffix at 40), and `candidates are capped at 8` (no cap yet). + +- [ ] **Step 6: Port the hardened parser rules to Rust** + +In `crates/freshell-sessions/src/resume_input.rs`: + +6a. Replace the `PREFIXED_ID_RE` definition (and its comment) with: + +```rust +// Known xxx_-prefixed id families only (ses_ + 26 base62 is opencode's, +// first-class). Arbitrary snake_case identifiers must NOT match: they would +// rank FIRST and waste resolver passes on non-ids. Mirrors +// `shared/resume-input-parser.ts:37`. +static PREFIXED_ID_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?-u:\b)(?:ses|sess|session|thread|thr|run|msg|task|amp)_[0-9A-Za-z]{8,64}(?-u:\b)") + .expect("static regex") +}); +``` + +6b. Add the cap constant right below the type definitions (near `ResumeInputParse`): + +```rust +/// Work budget: candidates are capped so one pasted blob can never trigger +/// unbounded server-side scans/DB lookups in the resolve endpoint. +/// Mirrors `MAX_RESUME_CANDIDATES` (`shared/resume-input-parser.ts:29`). +pub const MAX_RESUME_CANDIDATES: usize = 8; +``` + +6c. In `parse_resume_input`, replace the final two lines + +```rust + let hint = derive_hint(&sanitized, &candidates); + ResumeInputParse { candidates, hint } +``` + +with: + +```rust + // Cap = work budget: bounds resolver scans + exact-id fallback lookups + // per request. The hint derives from the CAPPED list (TS parity: + // `deriveHint(sanitized, capped)`). + candidates.truncate(MAX_RESUME_CANDIDATES); + let hint = derive_hint(&sanitized, &candidates); + ResumeInputParse { candidates, hint } +``` + +Also update the doc comment on the `candidates` field of `ResumeInputParse` to say "capped at `MAX_RESUME_CANDIDATES`". + +- [ ] **Step 7: Run both sides — expect PASS** + +Run: +```bash +cargo test -p freshell-sessions --test resume_input_parser_parity +npm run test:vitest -- --config config/vitest/vitest.config.ts test/unit/shared/resume-input-parser.test.ts --run +``` +Expected: both PASS. Also run `cargo test -p freshell-sessions` and `cargo test -p freshell-server` — expect PASS (the old resolver consumes the parser output shape unchanged; if a resolver test relied on >8 candidates, fix the TEST input, not the cap). + +- [ ] **Step 8: Commit** + +```bash +git add test/fixtures/resume-input/parser-cases.json test/unit/shared/resume-input-parser.test.ts crates/freshell-sessions/src/resume_input.rs +git commit -m "feat(sessions): align resume-input parser to hardened #586 rules via the shared fixture + +Fixture extended to the hardened parser (known-family prefix regex, +MAX_RESUME_CANDIDATES=8 cap); TS test restored to fixture-driven form +(the rebase had kept main's inline version, silently unpinning the +keystone); Rust parser ported to the same rules. Both suites pass the +same table. (SYNC-06) + +🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) + +Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>" +``` + +--- + +### Task 3: Hardened resolve core — ranking, case rules, subagents, sessionType default, provider-health channel, budgeted fallbacks + +Rewrite `crates/freshell-sessions/src/resume_resolve.rs` as a step-for-step port of the HARDENED `server/coding-cli/resolve-session.ts` + the budget/shape-gate logic of `resolve-fallbacks.ts`. This is the core of the delta. + +**Files:** +- Rewrite: `crates/freshell-sessions/src/resume_resolve.rs` +- Rewrite: `crates/freshell-sessions/tests/resume_resolve.rs` (mirror the hardened `test/unit/server/coding-cli/resolve-session.test.ts`) +- Modify (compile only): `crates/freshell-server/src/resolve.rs` — minimal adaptation so the workspace compiles; the full wire upgrade is Task 6. + +**Interfaces:** +- Consumes: `parse_resume_input` + `MAX_RESUME_CANDIDATES` (Task 2), `IndexedSession` (`directory_index.rs`, has `is_subagent: bool`, `key() -> "{provider}:{session_id}"`). +- Produces (Tasks 4 and 6 depend on these EXACT names): + - `pub enum ResumeResolveStatus { Ready, Warming, Degraded }` (serde lowercase) + - `pub struct ResumeResolveProviderError { pub provider: String, pub code: Option, pub message: Option }` (camelCase wire, `code`/`message` omitted when `None`) + - `pub struct ProviderFailure { pub code: Option, pub message: String }` + - `pub struct ClaudeTranscriptHit { pub session_id: String, pub cwd: Option }` (unchanged) + - `pub struct OpencodeByIdHit { pub session_id: String, pub cwd: Option, pub title: Option, pub last_activity_at: Option }` + - `pub struct ResolveDeps<'a>` with fallback fields typed `Option<&'a (dyn Fn(&str) -> Result, ProviderFailure> + Send + Sync)>` + - `pub struct ResumeResolveOutcome { pub status: ResumeResolveStatus, pub matches: Vec, pub hint: Option, pub provider_errors: Vec }` + - `pub fn resolve_resume_input(input: &str, deps: &ResolveDeps<'_>) -> ResumeResolveOutcome` + - `pub const RESOLVE_MATCH_CAP: usize = 20;` and `pub const FALLBACK_BUDGET_PER_REQUEST: usize = 2;` + - The old `ResumeResolveResponse` struct is DELETED from this module (the wire response moves to `resolve.rs` in Task 6). + +- [ ] **Step 1: Write the failing tests (rewrite `crates/freshell-sessions/tests/resume_resolve.rs`)** + +Mirror the hardened Node core suite `test/unit/server/coding-cli/resolve-session.test.ts` test-for-test (23 `it(...)` tests, no `it.each` — count verified at this worktree's HEAD; read it side-by-side while writing). Start the file with this header and helpers: + +```rust +//! SYNC-06 logic-parity mirror of the HARDENED Node core suite +//! `test/unit/server/coding-cli/resolve-session.test.ts` (post-#586), +//! test-for-test. The HTTP wire (auth/validation/router merge) is pinned in +//! `crates/freshell-server/src/resolve.rs`. + +use std::collections::HashMap; + +use freshell_sessions::directory_index::IndexedSession; +use freshell_sessions::resume_resolve::{ + resolve_resume_input, ClaudeTranscriptHit, OpencodeByIdHit, ProviderFailure, ResolveDeps, + ResumeResolveOutcome, ResumeResolveStatus, RESOLVE_MATCH_CAP, +}; + +const CLAUDE_ID: &str = "ed2afda6-a340-443e-ba60-024a1b3554b4"; +const OTHER_UUID: &str = "aaaaaaaa-1111-4222-8333-444444444444"; +const SES_ID: &str = "ses_root0000000000000000000000"; + +fn session(provider: &str, id: &str, last: i64) -> IndexedSession { + IndexedSession { + session_id: id.to_string(), + provider: provider.to_string(), + project_path: format!("/repo/{provider}"), + title: Some(format!("{provider} title")), + summary: None, + first_user_message: Some("hello".to_string()), + last_activity_at: last, + created_at: None, + cwd: Some(format!("/repo/{provider}")), + is_subagent: false, + is_non_interactive: false, + source_file: None, + } +} + +fn resolve( + input: &str, + sessions: Option<&[IndexedSession]>, + claude: Option<&(dyn Fn(&str) -> Result, ProviderFailure> + Send + Sync)>, + opencode: Option<&(dyn Fn(&str) -> Result, ProviderFailure> + Send + Sync)>, +) -> ResumeResolveOutcome { + let session_types: HashMap = HashMap::new(); + resolve_resume_input( + input, + &ResolveDeps { + sessions, + session_types: &session_types, + locate_claude_transcript: claude, + opencode_session_by_id: opencode, + }, + ) +} +``` + +Then write the tests. The COMPLETE code for the tests covering NEW hardened behavior (write these verbatim); for behaviors that already had a green mirror test in the old file (exact-wins-over-prefix, priority order of candidates, cap-20, dedupe-most-recent, warming, ready-empty, hint-alongside-evidence), carry the old test bodies over, adapting only the deps construction to the `resolve(...)` helper above and expected `session_type` values per the new default rule (index matches now ALWAYS carry `sessionType`, defaulting to the provider name): + +```rust +#[test] +fn ses_ids_are_case_sensitive_a_case_variant_does_not_match() { + let sessions = vec![session("opencode", SES_ID, 100)]; + let variant = SES_ID.to_uppercase().replace("SES_", "ses_"); + let out = resolve(&variant, Some(&sessions), None, None); + assert_eq!(out.status, ResumeResolveStatus::Ready); + // Not exact — but it IS a prefix miss too (different chars), so empty. + assert!(out.matches.is_empty()); +} + +#[test] +fn exact_id_match_is_case_insensitive_for_uuid_hex_tokens() { + let sessions = vec![session("claude", CLAUDE_ID, 100)]; + let out = resolve(&CLAUDE_ID.to_uppercase(), Some(&sessions), None, None); + assert_eq!(out.matches.len(), 1); + assert_eq!(out.matches[0].match_kind, freshell_sessions::resume_resolve::ResumeMatchKind::Exact); +} + +#[test] +fn an_exact_id_finds_a_subagent_child_session() { + let mut child = session("claude", CLAUDE_ID, 100); + child.is_subagent = true; + let out = resolve(CLAUDE_ID, Some(&[child]), None, None); + assert_eq!(out.matches.len(), 1); +} + +#[test] +fn prefix_discovery_does_not_surface_subagent_sessions() { + let mut child = session("claude", CLAUDE_ID, 100); + child.is_subagent = true; + let top = session("claude", "ed2afda6-a340-443e-ba60-024a1b3554b5", 90); + let out = resolve("ed2afda6", Some(&[child, top]), None, None); + assert_eq!(out.matches.len(), 1); + assert_eq!(out.matches[0].session_id, "ed2afda6-a340-443e-ba60-024a1b3554b5"); +} + +#[test] +fn session_type_defaults_to_the_provider_name_when_the_overlay_has_none() { + let sessions = vec![session("claude", CLAUDE_ID, 100)]; + let out = resolve(CLAUDE_ID, Some(&sessions), None, None); + assert_eq!(out.matches[0].session_type.as_deref(), Some("claude")); +} + +#[test] +fn an_exact_fallback_hit_beats_an_indexed_prefix_match_of_the_same_token() { + // Index holds a session whose id merely STARTS WITH the pasted full id. + let longer = session("claude", &format!("{CLAUDE_ID}0"), 100); + let hits = |id: &str| -> Result, ProviderFailure> { + Ok(Some(ClaudeTranscriptHit { session_id: id.to_ascii_lowercase(), cwd: Some("/repo/x".into()) })) + }; + let out = resolve(CLAUDE_ID, Some(&[longer]), Some(&hits), None); + assert_eq!(out.matches.len(), 1); + assert_eq!(out.matches[0].session_id, CLAUDE_ID); + assert_eq!(out.matches[0].provider, "claude"); +} + +#[test] +fn a_fallback_exact_hit_for_a_higher_priority_token_beats_an_indexed_exact_of_a_lower_one() { + // Candidate order: ses_ (prefixed) outranks the uuid. The ses_ id resolves + // only via the opencode fallback; the uuid has an indexed exact hit. + let indexed = vec![session("claude", CLAUDE_ID, 100)]; + let oc = |id: &str| -> Result, ProviderFailure> { + Ok(Some(OpencodeByIdHit { session_id: id.to_string(), cwd: Some("/repo/oc".into()), title: None, last_activity_at: None })) + }; + let out = resolve(&format!("{SES_ID} {CLAUDE_ID}"), Some(&indexed), None, Some(&oc)); + assert_eq!(out.matches.len(), 1); + assert_eq!(out.matches[0].provider, "opencode"); + assert_eq!(out.matches[0].session_id, SES_ID); +} + +#[test] +fn a_failing_fallback_never_fails_the_resolve_it_degrades_with_a_provider_error() { + // Node production parity: the opencode worker boundary serializes only + // {name, message} (`opencode-by-id.worker.ts:41-42`) and the runner + // rebuilds the Error WITHOUT `.code` (`opencode-by-id-runner.ts:103-106`), + // so opencode provider errors are message-only on the wire — `code` is + // None here. (Code passthrough-when-present is exercised by the claude + // fallback's EACCES endpoint test in Task 6.) + let broken = |_id: &str| -> Result, ProviderFailure> { + Err(ProviderFailure { code: None, message: "unable to open database file".into() }) + }; + let out = resolve(SES_ID, Some(&[]), None, Some(&broken)); + assert_eq!(out.status, ResumeResolveStatus::Degraded); + assert!(out.matches.is_empty()); + assert_eq!(out.provider_errors.len(), 1); + assert_eq!(out.provider_errors[0].provider, "opencode"); + assert_eq!(out.provider_errors[0].code, None); + assert_eq!(out.provider_errors[0].message.as_deref(), Some("unable to open database file")); +} + +#[test] +fn a_failed_fallback_does_not_hide_a_later_lower_priority_match_but_marks_degraded() { + // ses_ token fails in the fallback; the later hex token prefix-matches the index. + let indexed = vec![session("amplifier", "417e8345aaaa", 50)]; + let broken = |_id: &str| -> Result, ProviderFailure> { + Err(ProviderFailure { code: None, message: "locked".into() }) + }; + let out = resolve(&format!("{SES_ID} 417e8345"), Some(&indexed), None, Some(&broken)); + assert_eq!(out.status, ResumeResolveStatus::Degraded); + assert_eq!(out.matches.len(), 1); + assert_eq!(out.matches[0].session_id, "417e8345aaaa"); + assert_eq!(out.provider_errors[0].provider, "opencode"); +} + +#[test] +fn a_healthy_resolve_reports_no_provider_errors_and_stays_ready() { + let sessions = vec![session("claude", CLAUDE_ID, 100)]; + let out = resolve(CLAUDE_ID, Some(&sessions), None, None); + assert_eq!(out.status, ResumeResolveStatus::Ready); + assert!(out.provider_errors.is_empty()); +} + +#[test] +fn shape_gates_wrong_shape_tokens_do_no_fallback_work() { + use std::sync::atomic::{AtomicUsize, Ordering}; + static CALLS: AtomicUsize = AtomicUsize::new(0); + let counting = |_id: &str| -> Result, ProviderFailure> { + CALLS.fetch_add(1, Ordering::SeqCst); + Ok(None) + }; + // "ses_short001" matches the parser's prefixed-id family but NOT the + // full-id shape ^ses_[0-9a-zA-Z]{26}$ — the fallback must not run. + let out = resolve("ses_short001", Some(&[]), None, Some(&counting)); + assert_eq!(CALLS.load(Ordering::SeqCst), 0); + assert_eq!(out.status, ResumeResolveStatus::Ready); +} + +#[test] +fn fallback_work_is_budgeted_to_two_calls_per_request_per_provider() { + use std::sync::atomic::{AtomicUsize, Ordering}; + static CALLS: AtomicUsize = AtomicUsize::new(0); + let counting = |_id: &str| -> Result, ProviderFailure> { + CALLS.fetch_add(1, Ordering::SeqCst); + Ok(None) + }; + // Four full-shape ses_ ids in one paste: only the first TWO may do work. + let ids = [ + "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa", + "ses_bbbbbbbbbbbbbbbbbbbbbbbbbb", + "ses_cccccccccccccccccccccccccc", + "ses_dddddddddddddddddddddddddd", + ]; + let _ = resolve(&ids.join(" "), Some(&[]), None, Some(&counting)); + assert_eq!(CALLS.load(Ordering::SeqCst), 2); +} + +#[test] +fn wrong_shape_tokens_consume_no_budget() { + use std::sync::atomic::{AtomicUsize, Ordering}; + static CALLS: AtomicUsize = AtomicUsize::new(0); + let counting = |_id: &str| -> Result, ProviderFailure> { + CALLS.fetch_add(1, Ordering::SeqCst); + Ok(None) + }; + // Two ses_ tokens (wrong shape for claude) then a valid uuid: the uuid + // must still reach the claude fallback (shape gate runs BEFORE budget). + let input = format!("ses_aaaaaaaaaaaaaaaaaaaaaaaaaa ses_bbbbbbbbbbbbbbbbbbbbbbbbbb {OTHER_UUID}"); + let _ = resolve(&input, Some(&[]), Some(&counting), None); + assert_eq!(CALLS.load(Ordering::SeqCst), 1); +} + +#[test] +fn opencode_fallback_hit_carries_title_and_floored_last_activity() { + let oc = |id: &str| -> Result, ProviderFailure> { + Ok(Some(OpencodeByIdHit { + session_id: id.to_string(), + cwd: Some("/repo/beta".into()), + title: Some("beta work".into()), + last_activity_at: Some(1234), + })) + }; + let out = resolve(SES_ID, Some(&[]), None, Some(&oc)); + let m = &out.matches[0]; + assert_eq!(m.provider, "opencode"); + assert_eq!(m.title.as_deref(), Some("beta work")); + assert_eq!(m.last_activity_at, Some(1234)); + assert_eq!(m.session_type.as_deref(), Some("opencode")); +} + +#[test] +fn provider_identity_travels_with_the_fallback_not_its_position() { + // BOTH fallbacks present; only claude's fails on a uuid token. + let broken_claude = |_id: &str| -> Result, ProviderFailure> { + Err(ProviderFailure { code: Some("EACCES".into()), message: "denied".into() }) + }; + let quiet_oc = |_id: &str| -> Result, ProviderFailure> { Ok(None) }; + let out = resolve(OTHER_UUID, Some(&[]), Some(&broken_claude), Some(&quiet_oc)); + assert_eq!(out.provider_errors.len(), 1); + assert_eq!(out.provider_errors[0].provider, "claude"); + assert_eq!(out.provider_errors[0].code.as_deref(), Some("EACCES")); +} +``` + +The COMPLETE 23-test mapping to the Node suite (every `it(...)` title in `test/unit/server/coding-cli/resolve-session.test.ts` at this worktree's HEAD, in file order — the Rust suite must cover ALL 23; legend: ✎ = verbatim body given above, ↻ = carry over/adapt the old Rust test body, ✚ = write new from the stated expectation): + +1. `exact match wins across all providers at once (claude UUID, no hint needed)` ✚ — index sessions for several providers, input = CLAUDE_ID → exactly 1 exact match; `hint` is the parser's id-shape derivation, `Some(ResumeHint { provider: Claude, source: IdShape })` — a bare v4 UUID ALWAYS derives the claude id-shape hint on both parsers (`shared/resume-input-parser.ts:88-94`, `resume_input.rs:194-202`). Do NOT assert `hint == None`: the Node title's "no hint needed" means no explicit command hint is required in the INPUT (the id shape alone suffices); the Node test body never asserts on `hint` at all. +2. `short hex prefix matches the amplifier session (spec row: 417e8345)` ✚ — index an amplifier session `417e8345aaaa`, input `417e8345` → 1 prefix match. +3. `exact-id match is case-insensitive for UUID/hex tokens` ✎ `exact_id_match_is_case_insensitive_for_uuid_hex_tokens` +4. `ses_ ids are case-SENSITIVE (base62): a case-variant does NOT match` ✎ `ses_ids_are_case_sensitive_a_case_variant_does_not_match` +5. `opencode ses_ id resolves to opencode even though other providers exist` ✚ — index sessions for all four providers incl. the SES_ID opencode row, input = SES_ID → 1 exact opencode match. +6. `exact match takes precedence over prefix matches of the same token` ↻ +7. `ambiguous prefix returns all matches most-recent first, capped` ↻ — build 25 sessions with a shared prefix, assert 20 back (`RESOLVE_MATCH_CAP`), sorted by `last_activity_at` desc. +8. `tries candidates in priority order until one resolves` ↻ +9. `an EXACT id finds a subagent/child session (spec: scan ALL sessions)` ✎ `an_exact_id_finds_a_subagent_child_session` +10. `prefix DISCOVERY does not surface subagent sessions` ✎ `prefix_discovery_does_not_surface_subagent_sessions` +11. `an exact FALLBACK hit beats an indexed PREFIX match of the same token` ✎ `an_exact_fallback_hit_beats_an_indexed_prefix_match_of_the_same_token` +12. `sessionType defaults to the provider name when the index has none` ✎ `session_type_defaults_to_the_provider_name_when_the_overlay_has_none` +13. `index miss consults exact-id fallbacks (claude transcript locator)` ✚ — empty index, input = OTHER_UUID, claude fallback returns a hit with cwd → 1 exact `claude` match carrying the cwd. +14. `index miss consults opencode by-id fallback` ✎ `opencode_fallback_hit_carries_title_and_floored_last_activity` (covers it with richer asserts — keep them) +15. `zero matches when nothing resolves anywhere` ↻ — ready-empty for garbage input. +16. `a THROWING fallback never fails the request: it degrades with a provider error summary` ✎ `a_failing_fallback_never_fails_the_resolve_it_degrades_with_a_provider_error` +17. `provider identity in providerErrors comes from the fallback PAIR, not its position` ✎ `provider_identity_travels_with_the_fallback_not_its_position` +18. `a typed ClaudeTranscriptLocatorError surfaces its errno code in the provider error` ✚ — claude fallback returns `Err(ProviderFailure { code: Some("EACCES"), .. })` on a uuid token → `provider_errors[0].code == Some("EACCES")` (Rust models Node's typed error as `ProviderFailure.code`). +19. `a healthy resolve reports NO provider errors` ✎ `a_healthy_resolve_reports_no_provider_errors_and_stays_ready` +20. `a failed exact-id fallback does NOT hide a later lower-priority match — but marks the response degraded` ✎ `a_failed_fallback_does_not_hide_a_later_lower_priority_match_but_marks_degraded` +21. `a fallback exact hit for a HIGHER-priority token beats an indexed exact hit of a LOWER-priority token` ✎ `a_fallback_exact_hit_for_a_higher_priority_token_beats_an_indexed_exact_of_a_lower_one` +22. `dedupes duplicate (provider, sessionId) snapshot entries, keeping the most recent` ↻ +23. `returns warming (not "not found") while the index is not ready` ↻ — `sessions: None`, assert `provider_errors` empty too. + +ALSO keep the Rust-only additions given verbatim above that have no Node twin (`shape_gates_wrong_shape_tokens_do_no_fallback_work`, `fallback_work_is_budgeted_to_two_calls_per_request_per_provider`, `wrong_shape_tokens_consume_no_budget`) and the old file's hint-alongside-evidence carryover — the suite therefore ends up with MORE than 23 tests; the 23 above are the mirror contract. + +- [ ] **Step 2: Run — expect compile FAILURE (new API does not exist yet)** + +Run: `cargo test -p freshell-sessions --test resume_resolve` +Expected: compile errors (`OpencodeByIdHit`, `ProviderFailure`, `ResumeResolveOutcome`, `Degraded` unknown). + +> **POST-EXECUTION NOTE (2026-07-31):** historical RED gate — the compile +> failure occurred as expected during execution, and Step 3's implementation +> then landed (commit `5a3332be3`). It is NOT reproducible at HEAD: the named +> APIs all exist now, and this suite compiles and passes. + +- [ ] **Step 3: Rewrite `crates/freshell-sessions/src/resume_resolve.rs`** + +Replace the whole file with (keep the existing module doc, updating its second paragraph to note the hardened contract): + +```rust +//! Rust port of the HARDENED (#586) `server/coding-cli/resolve-session.ts` + +//! the shape-gate/budget logic of `resolve-fallbacks.ts` — the resume-by-id +//! resolve core. Pure and synchronous: the HTTP layer +//! (`crates/freshell-server/src/resolve.rs`) supplies the index snapshot, the +//! sessionType overlay map, and the two exact-id fallback closures, then +//! merges router-level fields (scan failures, unsearchedProviders, homeDir) +//! and serializes. +//! +//! Wire parity notes: +//! - Field ORDER in `ResumeResolveMatch` matches the Node object literals — +//! `serde_json` `preserve_order` + struct field order drive output order. +//! - Optional match fields are OMITTED when `None` (Node drops `undefined`); +//! `hint` is `null` when absent (zod `.nullable()`), so NOT skip-serialized. +//! - Per-token resolution order (resolve-session.ts:56-70): exact index hits +//! (ALL sessions, subagents included) → exact-id fallbacks → prefix +//! discovery (top-level only). A prefix match must NEVER outrank any exact +//! resolution of the same or a higher-priority token. +//! - UUID/hex-family tokens (hex digits + dashes only) match +//! case-INSENSITIVELY; everything else — notably ses_ base62 ids — matches +//! case-SENSITIVELY (base62 case-folding could resolve the WRONG session). +//! - Provider failure ≠ not found: a failing fallback records a per-provider +//! error and the result becomes `degraded` — never a silent empty miss. + +use std::collections::{HashMap, HashSet}; +use std::sync::LazyLock; + +use regex::Regex; + +use crate::directory_index::IndexedSession; +use crate::resume_input::{parse_resume_input, ResumeHint}; + +/// `RESOLVE_MATCH_CAP` (`resolve-session.ts:12`). +pub const RESOLVE_MATCH_CAP: usize = 20; + +/// `FALLBACK_BUDGET_PER_REQUEST` (`resolve-fallbacks.ts:34`): each fallback +/// may do REAL work at most this many times per request; beyond that it +/// reports a miss without doing work. Shape gates run FIRST and consume no +/// budget (`resolve-fallbacks.ts:46-48` — order is load-bearing). +pub const FALLBACK_BUDGET_PER_REQUEST: usize = 2; + +/// `FALLBACK_ID_SHAPES` (`resolve-fallbacks.ts:22-25`): FULL-id gates. +static CLAUDE_FALLBACK_ID_SHAPE: LazyLock = LazyLock::new(|| { + Regex::new(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + .expect("static regex") +}); +static OPENCODE_FALLBACK_ID_SHAPE: LazyLock = + LazyLock::new(|| Regex::new(r"^ses_[0-9a-zA-Z]{26}$").expect("static regex")); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ResumeResolveStatus { + Ready, + Warming, + Degraded, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ResumeMatchKind { + Exact, + Prefix, +} + +/// One resolve match (`ResumeResolveMatchSchema`). Field order = Node's +/// `toMatch` / fallback literals. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResumeResolveMatch { + pub provider: String, + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub first_user_message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_activity_at: Option, + pub match_kind: ResumeMatchKind, +} + +/// `ResumeResolveProviderErrorSchema`: a provider that could not be searched +/// is 'degraded' — NEVER "not found". Node builds `{provider, ...code, message}`. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResumeResolveProviderError { + pub provider: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +/// A fallback failure as reported by the closure (the Rust analog of a Node +/// fallback rejection; typed locator errors carry an errno-ish `code`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderFailure { + pub code: Option, + pub message: String, +} + +/// The claude transcript fallback's answer. `session_id` is the LOWERCASED +/// id (the locator lowercases before scanning, Node parity). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaudeTranscriptHit { + pub session_id: String, + pub cwd: Option, +} + +/// The opencode by-id fallback's answer (hardened Node: the full sqlite row +/// from `opencode-by-id-query.ts`, archived + child sessions included). +/// `last_activity_at` is already floored to integer ms by the producer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpencodeByIdHit { + pub session_id: String, + pub cwd: Option, + pub title: Option, + pub last_activity_at: Option, +} + +/// Dependencies for one resolve call (`ResolveResumeDeps`). Fallbacks return +/// `Err(ProviderFailure)` when the provider store could not be searched — +/// the core records it and continues (provider unavailable ≠ not found). +pub struct ResolveDeps<'a> { + /// Deleted-filtered index snapshot; `None` = never published ⇒ warming. + pub sessions: Option<&'a [IndexedSession]>, + /// sessionType overlay keyed `"{provider}:{session_id}"`. + pub session_types: &'a HashMap, + /// claude transcript exact-id fallback (`locateClaudeTranscript`). + #[allow(clippy::type_complexity)] + pub locate_claude_transcript: Option< + &'a (dyn Fn(&str) -> Result, ProviderFailure> + Send + Sync), + >, + /// opencode `ses_*` exact-id fallback (hardened by-id row query). + #[allow(clippy::type_complexity)] + pub opencode_session_by_id: Option< + &'a (dyn Fn(&str) -> Result, ProviderFailure> + Send + Sync), + >, +} + +/// Core result (`ResolveResumeResult` in `resolve-session.ts:31-36`). +/// `provider_errors` carries FALLBACK failures only; the HTTP layer merges in +/// index scan failures and adds `unsearchedProviders`/`homeDir`. +#[derive(Debug, Clone, PartialEq)] +pub struct ResumeResolveOutcome { + pub status: ResumeResolveStatus, + pub matches: Vec, + pub hint: Option, + pub provider_errors: Vec, +} + +/// `isCaseInsensitiveToken` (`resolve-session.ts:51-53`). +fn is_case_insensitive_token(token: &str) -> bool { + !token.is_empty() + && token + .bytes() + .all(|b| b.is_ascii_hexdigit() || b == b'-') +} + +/// `resolveResumeInput` (`resolve-session.ts:72-170`), step for step. +pub fn resolve_resume_input(input: &str, deps: &ResolveDeps<'_>) -> ResumeResolveOutcome { + // Parse BEFORE the warming gate: the warming response still carries the hint. + let parsed = parse_resume_input(input); + let hint = parsed.hint; + + let Some(sessions) = deps.sessions else { + return ResumeResolveOutcome { + status: ResumeResolveStatus::Warming, + matches: Vec::new(), + hint, + provider_errors: Vec::new(), + }; + }; + if parsed.candidates.is_empty() { + return ResumeResolveOutcome { + status: ResumeResolveStatus::Ready, + matches: Vec::new(), + hint, + provider_errors: Vec::new(), + }; + } + + // First-error-per-provider, insertion order (Node's Map semantics). + let mut errors: Vec = Vec::new(); + // Per-REQUEST budgets (`withRequestBudget` wraps once, before the loop). + let mut claude_used = 0usize; + let mut opencode_used = 0usize; + + for candidate in &parsed.candidates { + let ci = is_case_insensitive_token(&candidate.token); + let norm = |value: &str| { + if ci { + value.to_ascii_lowercase() + } else { + value.to_string() + } + }; + let target = norm(&candidate.token); + + // 1. Exact index hits — scan ALL sessions, subagent children included. + let exact: Vec = sessions + .iter() + .filter(|session| norm(&session.session_id) == target) + .map(|session| to_match(session, ResumeMatchKind::Exact, deps.session_types)) + .collect(); + if !exact.is_empty() { + return finish(exact, hint, errors); + } + + // 2. Exact-id fallbacks BEFORE prefix matching. Shape FIRST, budget + // SECOND (wrong-shape tokens are free no-ops); iterated claude-then- + // opencode so a failure is attributed to the RIGHT provider. + let mut hits: Vec = Vec::new(); + if let Some(locate) = deps.locate_claude_transcript { + if CLAUDE_FALLBACK_ID_SHAPE.is_match(&candidate.token) + && claude_used < FALLBACK_BUDGET_PER_REQUEST + { + claude_used += 1; + match locate(&candidate.token) { + Ok(Some(hit)) => hits.push(ResumeResolveMatch { + provider: "claude".to_string(), + session_id: hit.session_id.clone(), + // cwd may legitimately be missing — the CLIENT then + // asks for a working directory instead of auto-opening. + cwd: hit.cwd, + session_type: Some(overlay_or( + deps.session_types, + "claude", + &hit.session_id, + )), + title: None, + first_user_message: None, + last_activity_at: None, + match_kind: ResumeMatchKind::Exact, + }), + Ok(None) => {} + Err(failure) => record_error("claude", failure, &mut errors), + } + } + } + if let Some(lookup) = deps.opencode_session_by_id { + if OPENCODE_FALLBACK_ID_SHAPE.is_match(&candidate.token) + && opencode_used < FALLBACK_BUDGET_PER_REQUEST + { + opencode_used += 1; + match lookup(&candidate.token) { + Ok(Some(hit)) => hits.push(ResumeResolveMatch { + provider: "opencode".to_string(), + session_id: hit.session_id.clone(), + // opencode resumes in the SPAWN cwd (the row's own + // `directory`); empty ⇒ omitted (Node `row.cwd || undefined`). + cwd: hit.cwd.filter(|c| !c.is_empty()), + session_type: Some(overlay_or( + deps.session_types, + "opencode", + &hit.session_id, + )), + title: hit.title.filter(|t| !t.is_empty()), + first_user_message: None, + last_activity_at: hit.last_activity_at, + match_kind: ResumeMatchKind::Exact, + }), + Ok(None) => {} + Err(failure) => record_error("opencode", failure, &mut errors), + } + } + } + if !hits.is_empty() { + return finish(hits, hint, errors); + } + + // 3. Prefix DISCOVERY — top-level sessions only; exact ids above + // still reach subagent children. + let prefix: Vec = sessions + .iter() + .filter(|session| { + !session.is_subagent && norm(&session.session_id).starts_with(&target) + }) + .map(|session| to_match(session, ResumeMatchKind::Prefix, deps.session_types)) + .collect(); + if !prefix.is_empty() { + return finish(prefix, hint, errors); + } + } + + finish(Vec::new(), hint, errors) +} + +/// Node's `finish` closure (`resolve-session.ts:100-109`): sort most-recent +/// first (stable, like JS), dedupe keeping the survivor with the most recent +/// activity, cap, and derive degraded-ness from recorded errors. +fn finish( + mut matches: Vec, + hint: Option, + errors: Vec, +) -> ResumeResolveOutcome { + matches.sort_by(|a, b| { + b.last_activity_at + .unwrap_or(0) + .cmp(&a.last_activity_at.unwrap_or(0)) + }); + let matches: Vec = + dedupe(matches).into_iter().take(RESOLVE_MATCH_CAP).collect(); + ResumeResolveOutcome { + status: if errors.is_empty() { + ResumeResolveStatus::Ready + } else { + // Even with matches: a failed HIGHER-priority exact search may + // have hidden the right session — the client must not auto-resume. + ResumeResolveStatus::Degraded + }, + matches, + hint, + provider_errors: errors, + } +} + +/// First error per provider wins (Node: `if (!errorsByProvider.has(provider))`). +fn record_error( + provider: &str, + failure: ProviderFailure, + errors: &mut Vec, +) { + if errors.iter().any(|e| e.provider == provider) { + return; + } + errors.push(ResumeResolveProviderError { + provider: provider.to_string(), + code: failure.code, + message: Some(failure.message), + }); +} + +/// sessionType resolution shared by index and fallback matches: overlay map +/// (keyed `"{provider}:{id}"`) → provider-name default +/// (`toMatch`'s `session.sessionType ?? session.provider` and +/// `resolve-fallbacks.ts`'s `sessionTypeFor`). +fn overlay_or(session_types: &HashMap, provider: &str, id: &str) -> String { + session_types + .get(&format!("{provider}:{id}")) + .cloned() + .unwrap_or_else(|| provider.to_string()) +} + +/// `toMatch` (`resolve-session.ts:172-183`). +fn to_match( + session: &IndexedSession, + match_kind: ResumeMatchKind, + session_types: &HashMap, +) -> ResumeResolveMatch { + ResumeResolveMatch { + provider: session.provider.clone(), + session_id: session.session_id.clone(), + cwd: Some( + session + .cwd + .clone() + .unwrap_or_else(|| session.project_path.clone()), + ), + session_type: Some(overlay_or( + session_types, + &session.provider, + &session.session_id, + )), + title: session.title.clone(), + first_user_message: session.first_user_message.clone(), + last_activity_at: Some(session.last_activity_at), + match_kind, + } +} + +/// `dedupe` (`resolve-session.ts:189-197`): first `provider:sessionId` wins — +/// which, post-sort, is the most recent entry. +fn dedupe(matches: Vec) -> Vec { + let mut seen: HashSet = HashSet::new(); + matches + .into_iter() + .filter(|m| seen.insert(format!("{}:{}", m.provider, m.session_id))) + .collect() +} +``` + +Note the key `overlay_or` uses `IndexedSession.key()`'s format; if `key()` exists use `session.key()` for the index path (it does — `directory_index.rs:86`) — either spelling is fine as long as the format is `"{provider}:{session_id}"`. + +- [ ] **Step 4: Minimal compile adaptation of the HTTP layer (behavior parity deferred to Task 6)** + +`crates/freshell-server/src/resolve.rs` no longer compiles (old `ResumeResolveResponse`, `OpencodeDirLookup` closure types). Make the smallest change that keeps current wire behavior while compiling against the new core, so this task stays reviewable on its own: + +- Change the two lookup type aliases to the new fallible forms: + ```rust + pub type OpencodeByIdLookup = + Arc Result, ProviderFailure> + Send + Sync>; + pub type ClaudeLocator = + Arc Result, ProviderFailure> + Send + Sync>; + ``` + and rename the state field `opencode_dir_by_id` → `opencode_session_by_id` (imports: `OpencodeByIdHit`, `ProviderFailure`, `ResumeResolveOutcome` from `freshell_sessions::resume_resolve`; drop the now-unused `OpencodeSessionDirectory` import). +- In the handler, build `ResolveDeps` with the renamed fields and serialize a TEMPORARY wire struct locally so today's `{status, matches, hint}` shape is preserved until Task 6: + ```rust + #[derive(serde::Serialize)] + struct LegacyWire { + status: freshell_sessions::resume_resolve::ResumeResolveStatus, + matches: Vec, + hint: Option, + } + ``` + mapping the outcome's fields into it (drop `provider_errors` for now) — and mark it `// TASK-6: replaced by the full hardened wire response`. +- In `crates/freshell-server/src/main.rs`, update the two closures to the new signatures MINIMALLY (same silent-miss behavior for now, full health channel in Task 6): + ```rust + opencode_session_by_id: Some(std::sync::Arc::new(|session_id: &str| { + let data_home = freshell_sessions::parse::default_opencode_data_home(); + Ok(freshell_sessions::parse::opencode_session_directory_by_id(&data_home, session_id) + .ok() + .flatten() + .map(|hit| freshell_sessions::resume_resolve::OpencodeByIdHit { + session_id: session_id.to_string(), + cwd: hit.directory, + title: None, + last_activity_at: None, + })) + })), + locate_claude_transcript: Some(std::sync::Arc::new(|session_id: &str| { + let lowered = session_id.to_ascii_lowercase(); + Ok(freshell_freshagent::locate_transcript(&lowered).map(|path| { + freshell_sessions::resume_resolve::ClaudeTranscriptHit { + session_id: lowered.clone(), + cwd: freshell_freshagent::transcript_cwd(&path), + } + })) + })), + ``` +- Update `resolve.rs`'s in-module tests only as far as compilation requires (closure signatures gain `Ok(...)`; the exact-match test's expected JSON gains `"sessionType": "claude"` — the new provider-name default is intentionally visible on the wire NOW, it matches hardened Node). + +- [ ] **Step 5: Run the suites** + +Run: `cargo test -p freshell-sessions --test resume_resolve && cargo test -p freshell-server && cargo test -p freshell-sessions` +Expected: ALL PASS. Then `cargo fmt --all` and `cargo clippy --workspace --all-targets -- -D warnings` — clean. + +- [ ] **Step 6: Commit** + +```bash +git add crates/freshell-sessions/src/resume_resolve.rs crates/freshell-sessions/tests/resume_resolve.rs crates/freshell-server/src/resolve.rs crates/freshell-server/src/main.rs +git commit -m "feat(sessions): hardened #586 resolve core — per-token ranking, case rules, provider-health channel, budgeted fallbacks + +Per-token exact→fallback→prefix order (a prefix match never outranks an +exact resolution); ses_ ids case-SENSITIVE, uuid/hex case-folded; +subagents excluded from prefix discovery; sessionType defaults to the +provider name; fallbacks are shape-gated + budgeted (2/request/provider) +and their failures surface as degraded + providerErrors — never a silent +empty not-found. Mirrors test/unit/server/coding-cli/resolve-session.test.ts. +(SYNC-06) + +🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) + +Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>" +``` + +--- + +### Task 4: Hardened opencode exact-id lookup — direct by-id row query replacing the parent walk + +Hardened Node replaced the #583 `resolveOpencodeSessionRoots` parent-walk with a direct by-id sqlite row query (`server/coding-cli/providers/opencode-by-id-query.ts`): archived and CHILD sessions included, full row returned (title/timestamps), errors PROPAGATE (provider unavailable ≠ not found). Port it. + +**Files:** +- Modify: `crates/freshell-sessions/src/parse/opencode.rs` (add `OpencodeByIdRow` + `opencode_session_row_by_id`; DELETE `opencode_session_directory_by_id`, its `SessionRow`/`fetch_session_row` helpers, and the `OpencodeSessionDirectory` struct once nothing references them — grep first: `grep -rn "OpencodeSessionDirectory\|opencode_session_directory_by_id" crates/ --include=*.rs`) +- Rewrite: `crates/freshell-sessions/tests/opencode_directory_by_id.rs` → delete and create `crates/freshell-sessions/tests/opencode_row_by_id.rs` +- Modify: `crates/freshell-sessions/src/parse/mod.rs` (export the new names, drop the old) + +**Interfaces:** +- Consumes: rusqlite (already a dependency; the pinned 0.31.0 exposes `Error::sqlite_error_code()`) and the existing `to_opt_string`/`to_opt_i64` helpers in the same file. +- Produces (Task 6 wires this): `pub fn opencode_session_row_by_id(data_home: &Path, session_id: &str) -> Result, OpencodeByIdError>`, `pub struct OpencodeByIdError { pub code: Option, pub message: String }` (code-preserving — see Step 2; the existing `OpencodeReadError` stays untouched for its other consumers), and `pub struct OpencodeByIdRow { pub session_id: String, pub cwd: Option, pub title: Option, pub created_at: Option, pub last_activity_at: Option, pub project_path: Option }`. + +- [ ] **Step 1: Write the failing tests (`crates/freshell-sessions/tests/opencode_row_by_id.rs`)** + +Reuse the DB-fixture helpers from the old `opencode_directory_by_id.rs` (it builds real sqlite files in temp dirs — copy its `temp dir` + schema-setup helpers verbatim, adjusting the schema to include `time_created`, `time_updated`, `time_archived`, `title` columns and a `project` table). Test set (complete expectations; adapt helper names to what you copied): + +> **POST-EXECUTION NOTE (2026-07-31):** this block is a behavioral SPEC, not a +> paste-ready verifier — every body below ends in `unimplemented!()` so a literal +> paste FAILS loudly instead of passing vacuously. The real assertions landed, +> under these exact test names, in +> `crates/freshell-sessions/tests/opencode_row_by_id.rs`; read that file for the +> executable versions. + +```rust +//! Hardened (#586) opencode exact-id lookup parity: mirrors +//! `server/coding-cli/providers/opencode-by-id-query.ts` — a DIRECT by-id +//! row query. Unlike the #583 parent-walk it includes ARCHIVED and CHILD +//! sessions, returns the full row (title/timestamps), and PROPAGATES read +//! errors (provider unavailable ≠ not found). + +use freshell_sessions::parse::{opencode_session_row_by_id, OpencodeByIdRow}; + +// helpers: create_db(dir) -> PathBuf building +// CREATE TABLE session (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT, +// title TEXT, project_id TEXT, time_created INTEGER, time_updated INTEGER, +// time_archived INTEGER); +// CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT); +// plus insert_session(...) / insert_project(...) row insert helpers. + +#[test] +fn resolves_a_root_row_with_full_metadata() { + // insert root row with title "beta", directory "/repo/beta", time_updated + // 1234, project worktree "/repo"; expect Ok(Some(row)) with session_id, + // cwd Some("/repo/beta"), title Some("beta"), last_activity_at Some(1234), + // project_path Some("/repo") — the landed test asserts the FULL + // OpencodeByIdRow struct equality. + unimplemented!("spec only — landed as this test name in opencode_row_by_id.rs"); +} + +#[test] +fn resolves_a_child_row_the_listing_hides() { + // insert parent + child with parent_id set; query the CHILD id; expect + // Ok(Some(..)) — NO parent walk. + unimplemented!("spec only — landed as this test name in opencode_row_by_id.rs"); +} + +#[test] +fn resolves_an_archived_row() { + // time_archived NOT NULL still resolves. + unimplemented!("spec only — landed as this test name in opencode_row_by_id.rs"); +} + +#[test] +fn missing_row_is_ok_none() { + // valid db, unknown id → Ok(None). + unimplemented!("spec only — landed as this test name in opencode_row_by_id.rs"); +} + +#[test] +fn db_without_a_session_table_is_ok_none() { + // db with only an unrelated table → Ok(None) + // (Node: `if (!tableNames.has('session')) return null`). + unimplemented!("spec only — landed as this test name in opencode_row_by_id.rs"); +} + +#[test] +fn db_without_a_project_table_still_resolves_with_null_project_path() { + // session table only; expect Ok(Some(row)) with project_path None. + unimplemented!("spec only — landed as this test name in opencode_row_by_id.rs"); +} + +#[test] +fn missing_db_file_is_an_error_not_a_silent_miss() { + // empty temp dir → Err(OpencodeByIdError) with code + // Some("SQLITE_CANTOPEN") (Node: DatabaseSync open throws + // SQLITE_CANTOPEN; the provider is present-but-unreadable, and silence + // here is the incident class). The code is INTERNAL — kept for + // structured logs and message fidelity; the wire deliberately omits it + // for opencode (Node's worker boundary strips `.code` before the wire — + // see Task 6 Step 3b). + unimplemented!("spec only — landed as this test name in opencode_row_by_id.rs"); +} + +#[test] +fn corrupt_db_file_is_an_error() { + // write 64 bytes of garbage to opencode.db → Err with code + // Some("SQLITE_NOTADB"). + unimplemented!("spec only — landed as this test name in opencode_row_by_id.rs"); +} + +#[test] +fn locked_db_is_an_error_after_the_busy_timeout() { + // REAL contention proof for the load-bearing 500 ms busy timeout: build + // a valid fixture db with one row, open a SECOND rusqlite Connection to + // the same file and run `BEGIN EXCLUSIVE` (hold the txn open, do not + // commit); now call opencode_session_row_by_id → expect Err with code + // Some("SQLITE_BUSY") (the busy error surfaces as OpencodeByIdError once + // the 500 ms busy_timeout expires — the read-only open cannot acquire + // the shared lock). Optionally assert the call took >= ~400 ms to show + // the timeout (not an instant failure), then ROLLBACK/drop the writer + // connection so the temp dir cleans up. + unimplemented!("spec only — landed as this test name in opencode_row_by_id.rs"); +} + +#[test] +fn real_time_updated_is_floored_to_integer_ms() { + // insert with time_updated = 1234.9 (REAL) → last_activity_at Some(1234). + unimplemented!("spec only — landed as this test name in opencode_row_by_id.rs"); +} +``` + +Write each body out fully using the copied helpers (they are short rusqlite calls; the old test file shows the pattern), REPLACING every `unimplemented!()` marker with the real fixture setup + assertions described in its comment — a body left as `unimplemented!()` fails the test run, by design. Run: `cargo test -p freshell-sessions --test opencode_row_by_id` — expected: compile FAILURE (function does not exist). (Post-execution: all ten bodies landed with real assertions in `crates/freshell-sessions/tests/opencode_row_by_id.rs`.) + +- [ ] **Step 2: Implement `opencode_session_row_by_id`** + +In `crates/freshell-sessions/src/parse/opencode.rs`, add: + +```rust +/// SHORT busy timeout (`opencode-by-id-query.ts:12`): a locked DB must fail +/// FAST — the failure surfaces as provider-unavailable, never "not found". +const OPENCODE_BYID_BUSY_TIMEOUT_MS: u64 = 500; + +/// Code-PRESERVING error for the by-id query (the plain `OpencodeReadError` +/// stays for its other consumers). Node's thrown sqlite errors carry a +/// `.code` like `SQLITE_CANTOPEN` at the QUERY layer — but Node's production +/// worker boundary then STRIPS it (`opencode-by-id.worker.ts:41-42` +/// serializes only `{name, message}`; `opencode-by-id-runner.ts:103-106` +/// rebuilds the Error without `.code`), so the code never reaches the wire. +/// We keep the code HERE for structured logging and precise messages; the +/// production closure (Task 6 Step 3b) deliberately maps it to +/// `ProviderFailure { code: None, .. }` — wire parity is message-only for +/// opencode. +#[derive(Debug, Clone, PartialEq)] +pub struct OpencodeByIdError { + pub code: Option, + pub message: String, +} + +/// Map a rusqlite error to the Node-style `SQLITE_*` code name via +/// `rusqlite::Error::sqlite_error_code()` (available in the pinned 0.31.0). +fn by_id_err(e: rusqlite::Error) -> OpencodeByIdError { + use rusqlite::ffi::ErrorCode as C; + let code = e.sqlite_error_code().and_then(|c| match c { + C::CannotOpen => Some("SQLITE_CANTOPEN"), + C::DatabaseBusy => Some("SQLITE_BUSY"), + C::DatabaseLocked => Some("SQLITE_LOCKED"), + C::NotADatabase => Some("SQLITE_NOTADB"), + C::PermissionDenied => Some("SQLITE_PERM"), + C::ReadOnly => Some("SQLITE_READONLY"), + _ => None, + }); + OpencodeByIdError { code: code.map(str::to_string), message: e.to_string() } +} + +/// The hardened exact-id row (`OpencodeSessionRow` subset the by-id query +/// selects). `last_activity_at` floored to integer ms (REAL columns possible). +#[derive(Debug, Clone, PartialEq)] +pub struct OpencodeByIdRow { + pub session_id: String, + pub cwd: Option, + pub title: Option, + pub created_at: Option, + pub last_activity_at: Option, + pub project_path: Option, +} + +/// Hardened (#586) exact-id lookup — 1:1 port of +/// `runOpencodeSessionByIdQuery` (`opencode-by-id-query.ts`). Deliberately +/// includes ARCHIVED and CHILD sessions: an exact id pasted by the user must +/// resolve even when the listing hides it. Errors PROPAGATE (a missing or +/// unreadable DB file is `Err`, matching Node's throwing `DatabaseSync` +/// open — provider unavailable ≠ not found). +pub fn opencode_session_row_by_id( + data_home: &Path, + session_id: &str, +) -> Result, OpencodeByIdError> { + let db_path = data_home.join("opencode.db"); + let conn = Connection::open_with_flags( + &db_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, + ) + .map_err(by_id_err)?; + conn.busy_timeout(std::time::Duration::from_millis( + OPENCODE_BYID_BUSY_TIMEOUT_MS, + )) + .map_err(by_id_err)?; + + let table_names: std::collections::HashSet = { + let mut stmt = conn + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .map_err(by_id_err)?; + let rows = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map_err(by_id_err)?; + let mut set = std::collections::HashSet::new(); + for r in rows { + set.insert(r.map_err(by_id_err)?); + } + set + }; + if !table_names.contains("session") { + return Ok(None); + } + let has_project = table_names.contains("project"); + let project_select = if has_project { "p.worktree" } else { "NULL" }; + let project_join = if has_project { + "LEFT JOIN project p ON p.id = s.project_id" + } else { + "" + }; + let sql = format!( + "SELECT s.id, s.directory, s.title, s.time_created, s.time_updated, \ + {project_select} FROM session s {project_join} WHERE s.id = ?1 LIMIT 1" + ); + match conn.query_row(&sql, rusqlite::params![session_id], |row| { + Ok(OpencodeByIdRow { + session_id: match row.get::<_, SqlValue>(0)? { + SqlValue::Text(s) => s, + other => to_opt_string(&other).unwrap_or_default(), + }, + cwd: to_opt_string(&row.get::<_, SqlValue>(1)?), + title: to_opt_string(&row.get::<_, SqlValue>(2)?), + created_at: to_opt_i64(&row.get::<_, SqlValue>(3)?), + last_activity_at: to_opt_i64(&row.get::<_, SqlValue>(4)?), + project_path: to_opt_string(&row.get::<_, SqlValue>(5)?), + }) + }) { + Ok(row) => Ok(Some(row)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(by_id_err(e)), + } +} +``` + +(`to_opt_i64` already truncates REAL toward zero via `as i64`; epoch-ms values are positive so truncation == `Math.floor` — same note the listing query carries.) + +- [ ] **Step 3: Delete the old walk** + +Grep consumers: `grep -rn "OpencodeSessionDirectory\|opencode_session_directory_by_id" crates/ --include=*.rs`. Expected remaining consumers after Task 3: only `parse/mod.rs` exports and possibly a leftover import in `resolve.rs`'s doc comments/tests. Delete `opencode_session_directory_by_id`, `fetch_session_row`, the `SessionRow` alias, and `OpencodeSessionDirectory`; delete `crates/freshell-sessions/tests/opencode_directory_by_id.rs`; update `parse/mod.rs` exports (`opencode_session_row_by_id`, `OpencodeByIdRow` in; old names out). Update the Task-3 temporary closure in `main.rs` to the new query: + +```rust +opencode_session_by_id: Some(std::sync::Arc::new(|session_id: &str| { + let data_home = freshell_sessions::parse::default_opencode_data_home(); + match freshell_sessions::parse::opencode_session_row_by_id(&data_home, session_id) { + Ok(row) => Ok(row.map(|r| freshell_sessions::resume_resolve::OpencodeByIdHit { + session_id: r.session_id, + cwd: r.cwd, + title: r.title, + last_activity_at: r.last_activity_at, + })), + // TASK-6 upgrades this to Err(ProviderFailure{..}) once the wire + // carries providerErrors; until then a read failure stays a miss. + Err(_) => Ok(None), + } +})), +``` + +Also update `resolve.rs`'s in-module opencode fallback test to return an `OpencodeByIdHit` (the old `OpencodeSessionDirectory` literal no longer exists) and extend its expected match JSON with the row-borne fields it now passes (`title`, `lastActivityAt`) if the test supplies them. + +- [ ] **Step 4: Run** + +Run: `cargo test -p freshell-sessions && cargo test -p freshell-server && cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings` +Expected: ALL PASS, clean. + +- [ ] **Step 5: Commit** + +```bash +git add crates/freshell-sessions/src/parse/ crates/freshell-sessions/tests/ crates/freshell-server/src/ +git commit -m "feat(sessions): hardened opencode exact-id lookup — direct by-id row query (archived+child included, errors propagate) + +Ports opencode-by-id-query.ts, replacing the #583 parent-walk. Full row +(title/timestamps) feeds the resolve match; a missing/locked/corrupt DB +is Err — provider unavailable ≠ not found. (SYNC-06) + +🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) + +Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>" +``` + +--- + +### Task 5: SessionIndex scan-failure channel + fire-and-forget refresh + settings enabled-providers getter + +Node's route merges `codingCliIndexer.getScanFailures()` into `providerErrors` and fire-and-forgets `requestRefresh()` on degraded (`sessions-router.ts:293-305`). Give the Rust `SessionIndex` the same two capabilities, and `SettingsStore` an enabled-providers reader. + +**Files:** +- Modify: `crates/freshell-sessions/src/directory_index.rs` +- Modify: `crates/freshell-sessions/src/amplifier.rs` (provider_name + discover_checked on the amplifier source) +- Modify: `crates/freshell-server/src/settings_store.rs` (getter below, AND: the legacy-default migration's hard-coded `const DEFAULTS: [&str; 3] = ["claude", "codex", "opencode"]` (`settings_store.rs`, load-time migration ~lines 180-196) widens to `[&str; 4]` adding `"amplifier"` — it must mirror Node's four-provider `DEFAULT_ENABLED_CLI_PROVIDERS` (`shared/coding-cli-defaults.ts:1-3`), otherwise a persisted legacy `["claude", "codex"]` list gains amplifier on Node but stays amplifier-less on Rust, leaving the provider unsearched and its indexed sessions filtered out; the migration's existing exact-legacy-match + gating logic is otherwise UNCHANGED) +- Modify: `crates/freshell-server/src/settings.rs` (default `enabled_providers` gains `"amplifier"` — Node-default parity) +- Test: unit tests inside `directory_index.rs`'s existing `#[cfg(test)]` module (follow its current test patterns) and `settings_store.rs`'s. + +**Interfaces:** +- Consumes: existing `SessionSource` trait, `refresh_snapshot` free function, `spawn_background_refresh`. +- Produces (Task 6 depends on these EXACT names): + - `SessionSource::provider_name(&self) -> Option<&'static str>` (default `None`; the FOUR real sources all participate: `ClaudeSource` → `Some("claude")`, `CodexSource` → `Some("codex")`, `OpencodeSource` → `Some("opencode")`, the amplifier source in `amplifier.rs` → `Some("amplifier")`) + - `SessionSource::discover_checked(&self) -> Result, std::io::Error>` (default `Ok(self.discover())`; the file-backed sources override it to PROPAGATE a root-listing failure — see Step 2) + - `SessionIndex::scan_failures(&self) -> Vec` (sorted, deduped) + - `SessionIndex::request_refresh(&self)` (non-blocking, no-op if a sweep is already running) + - `SettingsStore::coding_cli_enabled_providers(&self) -> Vec` — **async** (`pub async fn`): `ServerSettings` lives in a `tokio::sync::RwLock`, so the getter mirrors `get()` (`self.inner.read().await...`); a sync getter is impossible without `blocking_read`, which can panic inside the runtime. (`session_overrides()` is NOT the pattern here — it reads a separate `std::sync::Mutex`.) + +- [ ] **Step 1: Write the failing tests** + +In `directory_index.rs`'s test module (reuse its existing fixture-source pattern — the file has test sources; model on them): + +```rust +#[tokio::test] +async fn a_failing_direct_list_records_a_scan_failure_and_recovery_clears_it() { + // A direct-listed source whose direct_list() can be toggled to Err. + struct FlakySource(std::sync::Arc); + impl SessionSource for FlakySource { + fn discover(&self) -> Vec { Vec::new() } + fn parse(&self, _p: &Path) -> Option { None } + fn provider_name(&self) -> Option<&'static str> { Some("opencode") } + // A CHANGING token each call, so every sweep re-queries. + fn direct_change_token(&self) -> Option { + use std::sync::atomic::{AtomicI64, Ordering}; + static N: AtomicI64 = AtomicI64::new(0); + Some(N.fetch_add(1, Ordering::SeqCst)) + } + fn direct_list(&self) -> Result, String> { + if self.0.load(std::sync::atomic::Ordering::SeqCst) { + Err("unable to open database file".to_string()) + } else { + Ok(Vec::new()) + } + } + } + let broken = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + let index = SessionIndex::with_ttl_and_cache_path( + vec![std::sync::Arc::new(FlakySource(std::sync::Arc::clone(&broken))) as _], + std::time::Duration::ZERO, // every snapshot() sweeps + None, + ); + // COLD cache: the first snapshot() sweeps INLINE, so this assert is + // deterministic. + let _ = index.snapshot().await; + assert_eq!(index.scan_failures(), vec!["opencode".to_string()]); + broken.store(false, std::sync::atomic::Ordering::SeqCst); + // WARM-but-stale cache: snapshot() returns stale data immediately and + // refreshes DETACHED (stale-while-revalidate) — recovery must be observed + // by POLLING. Reuse the module's existing `wait_until` test helper. + let _ = index.snapshot().await; + assert!( + wait_until(std::time::Duration::from_secs(2), || index.scan_failures().is_empty()).await, + "scan failure must clear once the source recovers" + ); +} + +#[tokio::test] +async fn a_failing_file_backed_root_listing_records_a_scan_failure_too() { + // FILE-BACKED parity (Node records listSessionFiles() throws for claude/ + // codex/amplifier in scanFailures — session-indexer.ts:1250-1262 — and the + // route turns them into degraded providerErrors): a source whose + // discover_checked() errs must be recorded, NOT silently treated as an + // empty listing. + struct FlakyFileSource(std::sync::Arc); + impl SessionSource for FlakyFileSource { + fn discover(&self) -> Vec { Vec::new() } + fn discover_checked(&self) -> Result, std::io::Error> { + if self.0.load(std::sync::atomic::Ordering::SeqCst) { + Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied")) + } else { + Ok(Vec::new()) + } + } + fn parse(&self, _p: &Path) -> Option { None } + fn provider_name(&self) -> Option<&'static str> { Some("claude") } + } + let broken = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + let index = SessionIndex::with_ttl_and_cache_path( + vec![std::sync::Arc::new(FlakyFileSource(std::sync::Arc::clone(&broken))) as _], + std::time::Duration::ZERO, + None, + ); + let _ = index.snapshot().await; // cold sweep is INLINE — deterministic + assert_eq!(index.scan_failures(), vec!["claude".to_string()]); + broken.store(false, std::sync::atomic::Ordering::SeqCst); + let _ = index.snapshot().await; // stale-while-revalidate: poll for recovery + assert!( + wait_until(std::time::Duration::from_secs(2), || index.scan_failures().is_empty()).await, + "file-backed scan failure must clear once the root is listable again" + ); +} +``` + +And for the settings getter (in `settings_store.rs` tests, following its temp-home pattern; `#[tokio::test]` since the getter is async): load a store from a temp home whose `/.freshell/config.json` contains the WRAPPED document `{"version":1,"settings":{"codingCli":{"enabledProviders":["claude","opencode"]}}}` (SettingsStore reads `config.json` and unwraps the top-level `settings` key — see `load_full_settings`; there is NO `settings.json`), assert `coding_cli_enabled_providers().await` returns exactly that; and for a FRESH temp home (no config file) assert the returned list is exactly `["claude", "codex", "opencode", "amplifier"]` — Node's authoritative `DEFAULT_ENABLED_CLI_PROVIDERS` (`shared/coding-cli-defaults.ts:3`). This assertion FAILS against today's Rust default, which omits `amplifier` (`crates/freshell-server/src/settings.rs:38-44`) — that is a live parity defect this task closes in Step 2; do NOT weaken the assert to whatever the store currently returns. ALSO add a legacy-MIGRATION test in the same module (same temp-home pattern): persist a config whose `enabledProviders` is exactly the legacy pair `["claude", "codex"]`, load the store, and assert the migrated list gains `"amplifier"` under exactly the same conditions it already gains `"opencode"` (same availability gating the existing migration applies — mirror whatever the current opencode migration test asserts, extended to amplifier). This test FAILS against today's three-item `const DEFAULTS` in `settings_store.rs` — the second half of the same parity defect (Node migrates legacy lists using the four-provider `DEFAULT_ENABLED_CLI_PROVIDERS`, `server/settings-migrate.ts:35-46`). + +Run: `cargo test -p freshell-sessions directory_index && cargo test -p freshell-server settings_store` — expected: compile FAILURE (methods missing). + +- [ ] **Step 2: Implement** + +- `SessionSource` trait: add + ```rust + /// Provider identity for scan-failure reporting (`getScanFailures` parity). + /// `None` (default) = this source does not participate in failure tracking. + fn provider_name(&self) -> Option<&'static str> { + None + } + + /// Discovery with ROOT-listing failure propagation (Node parity: a + /// throwing `listSessionFiles()` is RECORDED in scanFailures — + /// `session-indexer.ts:1250-1262` — never silently treated as empty). + /// Default wraps the infallible `discover()` for test sources. + fn discover_checked(&self) -> Result, std::io::Error> { + Ok(self.discover()) + } + ``` + Implement `provider_name` = `Some("claude")`/`Some("codex")`/`Some("opencode")`/`Some("amplifier")` on the FOUR real sources (the amplifier source lives in `amplifier.rs`). Override `discover_checked` on the file-backed sources (claude `directory_index.rs:204-208`, codex `directory_index.rs:374-377`, amplifier `amplifier.rs:108-111`) to PROPAGATE the top-level root `read_dir` error instead of the current `else { return Vec::new() }` swallow (a missing root — `NotFound`/`NotADirectory` — stays `Ok(vec![])`: an absent provider is a genuine empty, matching Node's ENOENT tolerance; EACCES/EIO propagate). Per-file/nested errors stay tolerant — corruption-tolerance within a listable root is preserved. +- `SessionIndex`: add field `scan_failures: Arc>>` (init empty in `with_ttl_and_cache_path`); pass `Arc::clone` of it into both `refresh_snapshot` call sites (inline + background — extend the free function's parameter list). Inside `refresh_snapshot`, for every source whose `provider_name()` is `Some(name)`: in the direct-listed branch, on `Ok` `set.remove(name)` / on `Err` `set.insert(name.to_string())`; in the file-backed branch, call `discover_checked()` instead of `discover()` — on `Ok(stats)` `set.remove(name)` and proceed as today, on `Err(_)` `set.insert(name.to_string())` and treat the listing as empty for this sweep. NODE PARITY NOTE (document in a comment on `scan_failures()`): Node behaves exactly this way — a throwing `listSessionFiles()` also yields an empty file list and lets the full-scan prune drop that provider's cached entries (`session-indexer.ts:1467-1475`, `:1499-1504`); what makes the outage VISIBLE is the recorded scan failure, which the route merges into `providerErrors` and marks the response `degraded` — never a silent healthy `ready + matches: []`. Both direct-listed (opencode) and file-backed (claude/codex/amplifier) outages must therefore be recorded. +- Public accessors on `SessionIndex`: + ```rust + /// Providers whose MOST RECENT listing attempt failed (unsearchable, not + /// empty) — `codingCliIndexer.getScanFailures()` parity. + pub fn scan_failures(&self) -> Vec { + let mut names: Vec = self.scan_failures.lock().unwrap().iter().cloned().collect(); + names.sort(); + names + } + + /// Fire-and-forget refresh (`requestRefresh` parity): gives a degraded + /// response's Retry a chance to converge once a failed provider recovers. + /// No-op if a sweep is already running. + pub fn request_refresh(&self) { + if let Ok(guard) = Arc::clone(&self.refresh_lock).try_lock_owned() { + self.spawn_background_refresh(guard); + } + } + ``` +- `SettingsStore` getter — ASYNC, mirroring `get()` (`ServerSettings` lives in `Arc>`; `session_overrides()` is NOT the pattern — it reads a separate `std::sync::Mutex`, and `blocking_read` in an async context can panic): + ```rust + /// The enabled coding-CLI provider names (`settings.codingCli.enabledProviders`) + /// — the resolve route's unsearched-provider computation reads this. Async + /// because the settings tree is behind a tokio RwLock (same as `get()`). + pub async fn coding_cli_enabled_providers(&self) -> Vec { + self.inner.read().await.coding_cli.enabled_providers.clone() + } + ``` + (Adjust the field path to `ServerSettings`' actual field names for `codingCli.enabledProviders` — verify in `crates/freshell-server/src/settings.rs`.) + +- [ ] **Step 3: Run** + +Run: `cargo test -p freshell-sessions && cargo test -p freshell-server && cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings` +Expected: ALL PASS, clean. (Every test `SessionSource` impl in the workspace compiles unchanged thanks to the defaulted trait method.) + +- [ ] **Step 4: Commit** + +```bash +git add crates/freshell-sessions/src/directory_index.rs crates/freshell-sessions/src/amplifier.rs crates/freshell-server/src/settings_store.rs crates/freshell-server/src/settings.rs +git commit -m "feat(sessions): scan-failure tracking + fire-and-forget refresh on SessionIndex; enabled-providers reader + Node-parity default on SettingsStore + +getScanFailures()/requestRefresh() parity plumbing for the hardened +resolve route (SYNC-06). All four real sources participate in failure +tracking: direct-listed (opencode) and file-backed (claude/codex/ +amplifier) root-listing failures are recorded, never silently treated +as an empty listing. Default enabledProviders now includes amplifier +(DEFAULT_ENABLED_CLI_PROVIDERS parity). + +🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) + +Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>" +``` + +--- + +### Task 6: Hardened wire response + route merge + production fallback wiring (degraded-path proof) + +Upgrade `POST /api/sessions/resolve` to the full hardened wire shape and route semantics (`sessions-router.ts:255-316`), wire the production fallbacks to REPORT failures instead of swallowing them, and prove the degraded path on the wire. Includes the async-hygiene verification (context §5). + +**Files:** +- Modify: `crates/freshell-server/src/resolve.rs` (wire struct, route merge, tests) +- Modify: `crates/freshell-server/src/main.rs` (state wiring: home_dir from the OS user home, ALWAYS-wired failure-reporting fallback closures — settings do not gate fallbacks) +- Modify: `crates/freshell-freshagent/src/claude_snapshot.rs` + `lib.rs` (add `locate_transcript_checked`) + +**Interfaces:** +- Consumes: Task 3's `ResumeResolveOutcome`/`ProviderFailure`, Task 4's `opencode_session_row_by_id`, Task 5's `scan_failures()`/`request_refresh()`/`coding_cli_enabled_providers()`. +- Produces: the final wire response `{status, matches, hint, providerErrors, unsearchedProviders, homeDir}`; `freshell_freshagent::locate_transcript_checked(projects_roots: &[PathBuf], session_id: &str) -> Result, std::io::Error>` (roots supplied by the caller — see Step 3). + +- [ ] **Step 1: Write the failing endpoint tests (in `resolve.rs`'s `#[cfg(test)]`)** + +Add these; also UPDATE the existing full-body asserts (`warming_with_hint_when_index_never_published`, `exact_match_returns_full_metadata_via_the_index`, fallback tests) to the new shape — every 200 body now carries `providerErrors` (array, default empty), `unsearchedProviders` (array), and `homeDir` (present when the state carries one). To keep expectations deterministic, extend the test `state()` helper: construct `SettingsStore::load(Some(dir), vec!["claude".into(), "codex".into(), "opencode".into(), "amplifier".into()])` and set `home_dir: Some(Arc::new("/home/tester".to_string()))`. Baseline `unsearchedProviders` is `[]`: Task 5 aligned the fresh-store default to Node's four-provider `DEFAULT_ENABLED_CLI_PROVIDERS` (and its settings test asserts exactly that), so with all four enabled nothing is unsearched — assert `body["unsearchedProviders"] == serde_json::json!([])` explicitly in the first new test so a regression fails loudly. + +```rust +#[tokio::test] +async fn wire_shape_carries_the_hardened_provider_health_fields() { + let dir = temp_dir("wire"); + let index = fixture_index(vec![claude_fixture()]).await; + let (status, body) = post(state(&dir, Some(index)), serde_json::json!({ "input": CLAUDE_ID }), true).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "ready"); + assert_eq!(body["providerErrors"], serde_json::json!([])); + assert!(body["unsearchedProviders"].is_array()); + assert_eq!(body["homeDir"], "/home/tester"); +} + +#[tokio::test] +async fn broken_opencode_store_degrades_with_a_provider_error_never_silent_not_found() { + // THE acceptance test (context §4): an unreadable provider store yields + // degraded + providerErrors on the wire — matches stay empty, status is + // NOT "ready". + let dir = temp_dir("degraded"); + let index = fixture_index(vec![claude_fixture()]).await; + let mut st = state(&dir, Some(index)); + // Node production parity (`sessions-resolve-router.test.ts:308-320`): the + // opencode worker boundary strips `.code`, so the wire entry is + // message-only — `code` must be ABSENT, not null-with-key. The production + // closure (Step 3b) maps OpencodeByIdError to code: None accordingly. + st.opencode_session_by_id = Some(Arc::new(|_id: &str| { + Err(freshell_sessions::resume_resolve::ProviderFailure { + code: None, + message: "unable to open database file".into(), + }) + })); + let (status, body) = post(st, serde_json::json!({ "input": "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa" }), true).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "degraded"); + assert_eq!(body["matches"], serde_json::json!([])); + assert_eq!( + body["providerErrors"], + serde_json::json!([{ "provider": "opencode", "message": "unable to open database file" }]) + ); +} + +#[tokio::test] +async fn degraded_even_with_matches_when_a_higher_priority_fallback_failed() { + // ses_ fallback fails; the later hex token still prefix-matches the index + // — the response carries the match AND stays degraded (no auto-resume). + let dir = temp_dir("degmatch"); + let mut amp = claude_fixture(); + amp.provider = "amplifier".to_string(); + amp.session_id = "417e8345aaaa".to_string(); + let index = fixture_index(vec![amp]).await; + let mut st = state(&dir, Some(index)); + st.opencode_session_by_id = Some(Arc::new(|_id: &str| { + Err(freshell_sessions::resume_resolve::ProviderFailure { code: None, message: "locked".into() }) + })); + let (_, body) = post(st, serde_json::json!({ "input": "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa 417e8345" }), true).await; + assert_eq!(body["status"], "degraded"); + assert_eq!(body["matches"][0]["sessionId"], "417e8345aaaa"); +} + +// POST-EXECUTION NOTE (2026-07-31): the five bodies below are behavioral +// SPECS, not paste-ready verifiers — each ends in `unimplemented!()` so a +// literal paste fails loudly instead of passing vacuously. The real +// assertions landed, under these exact test names, in the test module of +// `crates/freshell-server/src/resolve.rs`; read that module for the +// executable versions. + +#[tokio::test] +async fn a_provider_scan_failure_reports_degraded_with_the_scan_failed_literal() { + // Index whose direct-listed source errs → scan_failures ["opencode"] → + // degraded + {provider:"opencode", message:"session scan failed"} even + // though no fallback ran. Build the index from a FailingDirectSource + // (provider_name Some("opencode"), direct_list Err) alongside the claude + // fixture source, warm it, then post a claude-uuid input. Assert status + // "degraded" and providerErrors == + // [{"provider":"opencode","message":"session scan failed"}]; the landed + // test additionally asserts the exact index hit still rides along + // (matches[0].sessionId == CLAUDE_ID — degraded ≠ empty). + unimplemented!("spec only — landed as this test name in resolve.rs tests"); +} + +#[tokio::test] +async fn disabled_providers_are_reported_unsearched_never_as_errors() { + // Settings with enabledProviders ["claude"]: unsearchedProviders lists + // the other three; a scan failure for DISABLED opencode is excluded from + // providerErrors and the response stays "ready". Build the settings file + // under dir/.freshell/ the way settings_store tests do, with + // codingCli.enabledProviders = ["claude"]; reuse the FailingDirectSource + // index; assert status "ready", providerErrors [], unsearchedProviders + // containing "codex","opencode","amplifier". + unimplemented!("spec only — landed as this test name in resolve.rs tests"); +} + +#[tokio::test] +async fn disabled_provider_indexed_sessions_do_not_resolve() { + // Node's INDEX excludes disabled providers (session-indexer.ts:1454-1467), + // so its resolution never sees their sessions (resolve-session.ts:85). + // Rust must filter the snapshot by the live enabled set BEFORE core + // resolution — a disabled provider's session resolving while that provider + // is listed in unsearchedProviders would be self-contradictory. + // Settings file with codingCli.enabledProviders = ["claude"]; index a + // CODEX session under a v4 UUID; post that UUID (no fallbacks wired) → + // status "ready", matches [], unsearchedProviders contains "codex". + unimplemented!("spec only — landed as this test name in resolve.rs tests"); +} + +#[tokio::test] +async fn a_disabled_provider_exact_id_still_resolves_via_fallback_node_parity() { + // Node wires ALL FOUR providers' exact-id fallbacks unconditionally + // (server/index.ts wiring; resolve-session.ts:127-156 invokes them + // regardless of settings) — settings gate INDEXING only. A disabled + // opencode's exact ses_ id must therefore still resolve via the fallback, + // while "opencode" stays listed in unsearchedProviders. + // Settings file with codingCli.enabledProviders = ["claude"]; empty index; + // wire st.opencode_session_by_id returning a hit for SES_ID; post SES_ID → + // status "ready", matches[0].sessionId == SES_ID, + // unsearchedProviders contains "opencode". + unimplemented!("spec only — landed as this test name in resolve.rs tests"); +} + +#[tokio::test] +async fn degraded_response_schedules_a_refresh_and_retry_converges() { + // request_refresh() wiring proof END-TO-END (sessions-router.ts:293-305 + // parity): a degraded response fire-and-forgets a refresh, so once the + // provider recovers, a client Retry converges back to ready. + // Reuse the FailingDirectSource index with its AtomicBool `broken` handle; + // post once → assert status "degraded" (this response called + // request_refresh()); set broken=false; then POLL: re-post the same input + // (each degraded response re-schedules a refresh) until status == "ready" + // with providerErrors [] within 2s (wait_until-style loop over posts); + // assert convergence rather than sleeping once. + unimplemented!("spec only — landed as this test name in resolve.rs tests"); +} +``` + +For the five commented tests (`a_provider_scan_failure_reports_degraded_with_the_scan_failed_literal`, `disabled_providers_are_reported_unsearched_never_as_errors`, `disabled_provider_indexed_sessions_do_not_resolve`, `a_disabled_provider_exact_id_still_resolves_via_fallback_node_parity`, `degraded_response_schedules_a_refresh_and_retry_converges`), write the bodies fully in the same style as the snippets above — the `FailingDirectSource` is the Task-5 `FlakySource` test source (keep its toggleable `AtomicBool` so the refresh-convergence test can flip it to recovered; the tests that only need a fixed failure just leave it broken), and the settings file seeding writes `dir/.freshell/config.json` containing the WRAPPED document `{"version":1,"settings":{"codingCli":{"enabledProviders":["claude"]}}}` (`SettingsStore` reads `/.freshell/config.json` and unwraps the top-level `settings` key — see `load_full_settings` in `settings_store.rs`; there is NO `settings.json` and a bare `codingCli` object would be ignored, silently reading defaults). + +Run: `cargo test -p freshell-server` — expected FAIL/compile-error (new state fields, wire fields missing). + +> **POST-EXECUTION NOTE (2026-07-31):** historical RED gate — the failure +> occurred as expected during execution, and Steps 2–3's implementation then +> landed (commit `1480e2a71`). It is NOT reproducible at HEAD: the state and +> wire fields all exist now, and `cargo test -p freshell-server` passes. + +- [ ] **Step 2: Implement the wire + route merge in `resolve.rs`** + +- Extend `ResolveState`: + ```rust + pub home_dir: Option>, + ``` +- Replace the Task-3 `LegacyWire` with the final wire struct (field order = the Node route's object literal, `sessions-router.ts:306-314`): + ```rust + /// Wire response (`ResumeResolveResponseSchema`): the core outcome plus the + /// router-level provider-health fields. `providerErrors`/`unsearchedProviders` + /// are always present (zod defaults exist for legacy tolerance, but Node + /// always sends them); `homeDir` is omitted only when the server has no + /// resolvable home. + #[derive(serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct ResolveWireResponse { + status: ResumeResolveStatus, + matches: Vec, + hint: Option, + provider_errors: Vec, + unsearched_providers: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + home_dir: Option, + } + ``` +- After the `spawn_blocking` join: a `JoinError` means the resolver PANICKED — return `500 INTERNAL_SERVER_ERROR` with a JSON error body (the router's standard error shape), NOT a fabricated ready-empty result. The hardened contract forbids presenting an unsearchable state as a healthy "not found"; masking a crashed locator/query as ready-empty is exactly the incident class this plan closes. RECORDED DEVIATION (state it in the module doc): Node has no defined behavior here — a top-level resolver throw becomes an unhandled rejection in the async Express 4 handler (no response at all), so the explicit 500 is the honest port, not a wire mismatch. Update the pre-existing panic-fallback test (it currently pins ready-empty-on-JoinError) to assert the 500. Then, on the `Ok(outcome)` path, merge exactly like the Node route: + ```rust + /// `KNOWN_RESUME_PROVIDERS` = `DEFAULT_ENABLED_CLI_PROVIDERS` + /// (`shared/coding-cli-defaults.ts:3`). + const KNOWN_RESUME_PROVIDERS: [&str; 4] = ["claude", "codex", "opencode", "amplifier"]; + + // Read the enabled set BEFORE dispatching the core resolve, and FILTER the + // snapshot with it: Node's index EXCLUDES disabled providers at scan time + // (session-indexer.ts:1454-1467), so its resolution never sees their + // sessions (resolve-session.ts:85). The Rust SessionIndex is built with all + // four sources unconditionally, so the route must apply the equivalent gate + // — otherwise a disabled provider's indexed session resolves while the same + // response lists that provider under unsearchedProviders. Fallbacks stay + // UNGATED (Node invokes all wired exact-id fallbacks regardless of + // settings — resolve-session.ts:127-156). + let enabled: std::collections::HashSet = + state.settings.coding_cli_enabled_providers().await.into_iter().collect(); + // ... before the spawn_blocking call: if the snapshot is Some(sessions), + // retain only sessions whose provider is in `enabled` (warming stays None); + // pass the FILTERED list into resolve_resume_input ... + let unsearched_providers: Vec = KNOWN_RESUME_PROVIDERS + .iter() + .filter(|name| !enabled.contains(**name)) + .map(|name| (*name).to_string()) + .collect(); + // Scan failures: enabled-only, fallback errors win the dedupe (more + // specific code/message). Disabled+failed must NOT stick degraded forever. + let mut provider_errors = outcome.provider_errors; + if let Some(index) = state.session_index.as_ref() { + for name in index.scan_failures() { + if !enabled.contains(&name) || provider_errors.iter().any(|e| e.provider == name) { + continue; + } + provider_errors.push(ResumeResolveProviderError { + provider: name, + code: None, + message: Some("session scan failed".to_string()), + }); + } + } + let status = match outcome.status { + ResumeResolveStatus::Warming => ResumeResolveStatus::Warming, + _ if !provider_errors.is_empty() => ResumeResolveStatus::Degraded, + _ => ResumeResolveStatus::Ready, + }; + if status == ResumeResolveStatus::Degraded { + if let Some(index) = state.session_index.as_ref() { + index.request_refresh(); + } + } + Json(ResolveWireResponse { + status, + matches: outcome.matches, + hint: outcome.hint, + provider_errors, + unsearched_providers, + home_dir: state.home_dir.as_ref().map(|h| h.as_str().to_string()), + }) + .into_response() + ``` +- Update the module doc's behavior-contract bullet list with the new fields and the degraded semantics (one bullet each). + +- [ ] **Step 3: Failure-reporting production closures + checked claude locator** + +3a. `crates/freshell-freshagent/src/claude_snapshot.rs` — add alongside `locate_transcript` (which stays, other consumers depend on it): + +```rust +/// Error-AWARE variant of [`locate_transcript`] for the resolve endpoint's +/// provider-health channel (#586 parity): an unreadable claude store must +/// surface as a provider error, never a silent miss. A missing projects dir +/// (`NotFound`) is a genuine miss for that root; any OTHER io error +/// propagates. +/// +/// The projects roots are a PARAMETER, exactly like Node's locator takes +/// `projectsDir` (`claude-transcript-locator.ts:65-67`) — the CALLER resolves +/// the environment. Do NOT resolve roots via `claude_home_candidates()` +/// here: that helper adds `CLAUDE_CONFIG_DIR` and bare-`CLAUDE_HOME` roots +/// that Node's resolver (`getSessionRoots()` = `getClaudeHome()/projects`, +/// `providers/claude.ts:524-535`, `server/claude-home.ts:4-7`) and the Rust +/// session index intentionally exclude — with an explicit `CLAUDE_HOME` +/// override it would expose transcripts from a root Node never searches. +/// Parameterizing the roots also keeps the unit tests hermetic: they pass +/// temp dirs and never mutate process-global env. +/// +/// Traversal order is Node's GLOBAL two-pass order +/// (`claude-transcript-locator.ts:69-88`): PASS 1 probes the DIRECT layout +/// across ALL roots, then PASS 2 probes the subagent layout across all roots +/// — NOT per-root direct+subagent. (With roots `[A, B]`: A direct, B direct, +/// A subagent, B subagent.) +pub fn locate_transcript_checked( + projects_roots: &[PathBuf], + session_id: &str, +) -> Result, std::io::Error> { + // PASS 1 — direct layout across all roots. + for projects in projects_roots { + if let Some(path) = find_transcript_checked_direct(projects, session_id)? { + return Ok(Some(path)); + } + } + // PASS 2 — subagent layout, only when the direct layout missed everywhere. + for projects in projects_roots { + if let Some(path) = find_transcript_checked_subagent(projects, session_id)? { + return Ok(Some(path)); + } + } + Ok(None) +} +``` + +and `find_transcript_checked_direct` / `find_transcript_checked_subagent` helpers (one per Node layout pass) that are `find_transcript` with error propagation: same id-shape guard (returns `Ok(None)`), then + +```rust +/// Node parity (`claude-transcript-locator.ts:33-37`): expected absence is +/// `ENOENT || ENOTDIR` — a missing dir OR a non-directory path component is +/// a genuine miss; everything else is a provider failure. +fn is_expected_absence(e: &std::io::Error) -> bool { + matches!( + e.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) +} + +let entries = match std::fs::read_dir(&projects) { + Ok(entries) => entries, + Err(e) if is_expected_absence(&e) => return Ok(None), + Err(e) => return Err(e), +}; +``` + +and the scan must construct the AUTHORITATIVE Node candidate layouts (`server/coding-cli/claude-transcript-locator.ts:39-48`): the direct helper probes `//.jsonl`, the subagent helper probes `///subagents/.jsonl` — and the caller runs the direct pass across ALL roots before ANY subagent probing (Node's global two-pass order above). CAUTION: the existing `find_transcript` probes `//.jsonl` WITHOUT the `subagents` segment — that diverges from Node and misses child sessions; do NOT mirror it. The checked variant uses the Node layout (leave `find_transcript` itself untouched for its other consumers). Propagate errors that are not expected-absence (`is_expected_absence` above — NotFound OR NotADirectory, Node's `ENOENT || ENOTDIR`) from every `read_dir`, and probe candidate files with `std::fs::metadata` (expected absence ⇒ miss for that candidate; any OTHER error propagates) instead of the error-swallowing `Path::is_file()`. + +Also add `transcript_cwd_checked(path: &Path) -> Result, std::io::Error>` beside `transcript_cwd` (which stays for other consumers): open error of expected-absence kind ⇒ `Ok(None)` (a raced deletion keeps the hit, cwd-less — Node behaves the same); any OTHER open/read error PROPAGATES (Node wraps these in `ClaudeTranscriptLocatorError`); malformed JSON lines are still skipped. BOUNDED READ (Node parity — `CWD_SCAN_BYTES = 64 * 1024`, `claude-transcript-locator.ts:30-31,131-135`): read AT MOST the first 64 KiB of the file (e.g. `std::io::Read::take(64 * 1024)` into a buffer), split that prefix on `\n`, and attempt to parse EVERY segment INCLUDING the final one — Node's `head.split('\n')` loop (`claude-transcript-locator.ts:141-149`) has no discard-the-truncated-tail rule: a fragment cut off at the 64 KiB boundary simply fails `JSON.parse` and is skipped by the `catch`, while a COMPLETE final line with no trailing newline (e.g. the last line of a small transcript) still parses. Do NOT drop the final segment. Parse each segment as JSON and return the first non-empty string `cwd`. Do NOT mirror the existing `transcript_cwd`'s unbounded `BufRead::lines()` loop — one resolve request against a multi-GB transcript (or a single enormous line) must not allocate or scan past the 64 KiB prefix. The 3b wiring below uses the checked variant — without it the "no longer swallowed" commit claim would be false, since `transcript_cwd` converts read errors to `None`. + +Re-export `locate_transcript_checked` and `transcript_cwd_checked` from `lib.rs` next to `locate_transcript`. Unit tests in the same file's test module — HERMETIC BY CONSTRUCTION: every test builds a temp projects dir and passes it via the `projects_roots` parameter; NO test mutates process-global env (`CLAUDE_HOME`/`CLAUDE_CONFIG_DIR`/`HOME`), so there is nothing to race against the crate's many existing env-mutating claude tests. (If a future test ever DOES need env mutation, it must hold the crate's shared `CLAUDE_ENV_LOCK` (`claude.rs`) and use the panic-safe `EnvVarsRestore` Drop-guard pattern (`claude_snapshot.rs:531-569`) — but none of the tests below need it.) The tests: (a) gate the permission test with `#[cfg(unix)]` (`std::os::unix::fs::PermissionsExt` does not exist on Windows — an ungated test would not COMPILE there): chmod the projects dir to `0o000`, then FIRST probe `std::fs::read_dir(&projects)` directly — if the probe unexpectedly SUCCEEDS (running as root / CAP_DAC_OVERRIDE bypasses mode bits), restore permissions, `eprintln!("skipping: euid bypasses permission checks");` and `return`; otherwise assert `locate_transcript_checked` yields `Err` with `kind() == PermissionDenied`; restore permissions afterward so cleanup works; (b) a missing projects dir yields `Ok(None)`; (c) a transcript placed at `///subagents/.jsonl` IS found by `locate_transcript_checked` (the child-session layout); (d) ENOTDIR absence parity: a candidate path whose component is a REGULAR FILE (e.g. `/` created as a file, so descending into it fails with `NotADirectory`) yields `Ok(None)`, not `Err` — Node reports a normal miss for `ENOTDIR` (`claude-transcript-locator.ts:33-37`); (e) bounded cwd scan: a transcript whose only `cwd`-bearing JSON line starts BEYOND the first 64 KiB (pad with ~65 KiB of valid no-cwd JSONL first) makes `transcript_cwd_checked` return `Ok(None)` — proving the 64 KiB prefix bound, Node parity; (f) two-pass precedence: the SAME id present at BOTH the direct layout in one root AND the subagent layout — with a single root, and again with two roots where root A holds only the subagent copy and root B holds the direct copy — `locate_transcript_checked` returns the DIRECT path (B's direct copy beats A's subagent copy: pass 1 exhausts ALL roots first, Node's global order); (g) final-fragment parse parity: a transcript SMALLER than 64 KiB whose ONLY `cwd`-bearing JSON line is the LAST line and has NO trailing newline → `transcript_cwd_checked` returns `Ok(Some(cwd))` (Node's `split('\n')` parses the final segment); (h) truncated-tail tolerance: a JSON object that STRADDLES the 64 KiB boundary (starts inside the prefix, ends beyond it) is skipped without error — `Ok(None)` when no earlier line carries a cwd. + +3b. `crates/freshell-server/src/main.rs` — final wiring (replaces the Task-3/4 temporaries). Above the router construction: + +```rust +// Resolve fallbacks mirror Node's buildResolveFallbacks over the FIXED +// provider registry (server/index.ts wires ALL FOUR codingCliProviders into +// it unconditionally): settings do NOT gate the exact-id fallbacks — they +// only gate INDEXING and feed unsearchedProviders. Both closures are +// therefore ALWAYS wired; gating them on boot-time settings would produce +// false misses after a live settings change and diverge from Node for +// disabled-provider exact IDs. + +/// Wire error code for a provider-error summary. Node preserves the ORIGINAL +/// `cause.code` VERBATIM (`ClaudeTranscriptLocatorError`, +/// `claude-transcript-locator.ts:19-27`): EPERM stays EPERM, EIO stays EIO. +/// So derive the symbolic errno name from the RAW OS errno — do NOT map from +/// `ErrorKind`, which would collapse EPERM into EACCES and drop EIO/EMFILE +/// entirely. `libc` is already a freshell-server dependency +/// (`crates/freshell-server/Cargo.toml`). +#[cfg(unix)] +fn errno_code(err: &std::io::Error) -> Option { + let raw = err.raw_os_error()?; + let name = match raw { + libc::EACCES => "EACCES", + libc::EPERM => "EPERM", + libc::ENOENT => "ENOENT", + libc::ENOTDIR => "ENOTDIR", + libc::EIO => "EIO", + libc::EMFILE => "EMFILE", + libc::ENFILE => "ENFILE", + libc::ELOOP => "ELOOP", + libc::ENAMETOOLONG => "ENAMETOOLONG", + libc::EBADF => "EBADF", + libc::EINVAL => "EINVAL", + _ => return None, // unknown errno ⇒ omit code, keep the message + }; + Some(name.to_string()) +} + +/// Non-unix fallback: `raw_os_error()` is a Win32 code there, not an errno; +/// map the coarse kinds Node's libuv also names. (The resolve fallbacks' +/// primary target is unix; parity of the fine-grained codes is a unix +/// concern.) +#[cfg(not(unix))] +fn errno_code(err: &std::io::Error) -> Option { + match err.kind() { + std::io::ErrorKind::PermissionDenied => Some("EACCES".to_string()), + std::io::ErrorKind::NotFound => Some("ENOENT".to_string()), + _ => None, + } +} +``` + +Unit tests for `errno_code` in `main.rs`'s (or the module's) `#[cfg(test)]` module, `#[cfg(unix)]`-gated: `io::Error::from_raw_os_error(libc::EPERM)` → `Some("EPERM")` (NOT `"EACCES"` — both map to `ErrorKind::PermissionDenied`, which is exactly why the kind-based mapping was wrong), `from_raw_os_error(libc::EACCES)` → `Some("EACCES")`, `from_raw_os_error(libc::EIO)` → `Some("EIO")`, and a synthetic `io::Error::new(ErrorKind::PermissionDenied, "no raw errno")` → `None`. + +State fields: + +```rust +// Node sends os.homedir() (`sessions-router.ts:306-314`) — the USER's home, +// USERPROFILE-backed on native Windows where Tauri deliberately leaves HOME +// unset. Resolve it via the SAME `session_directory::provider_home()` helper +// the session index sources use: HOME then USERPROFILE, an EMPTY var treated +// as unset (a raw `var_os("HOME").or_else(USERPROFILE)` chain would accept +// an empty HOME verbatim and never reach USERPROFILE). Landed as the +// `resolve_wire_home_dir()` helper in `main.rs`. Do NOT reuse +// resolve_home(): it prefers FRESHELL_HOME, a config/storage root that can +// differ from the real home, and the dialog would prefill a cwd-less resume +// into the wrong directory. +home_dir: resolve_wire_home_dir(), +opencode_session_by_id: Some({ + std::sync::Arc::new(|session_id: &str| { + let data_home = freshell_sessions::parse::default_opencode_data_home(); + freshell_sessions::parse::opencode_session_row_by_id(&data_home, session_id) + .map(|row| { + row.map(|r| freshell_sessions::resume_resolve::OpencodeByIdHit { + session_id: r.session_id, + cwd: r.cwd, + title: r.title, + last_activity_at: r.last_activity_at, + }) + }) + .map_err(|e| { + // Node production parity: the opencode worker boundary STRIPS + // `.code` — the worker serializes only {name, message} + // (`opencode-by-id.worker.ts:41-42`) and the runner rebuilds + // the Error without it (`opencode-by-id-runner.ts:103-106`), + // so Node's wire entry is message-only + // (`sessions-resolve-router.test.ts:308-320`). Emitting + // SQLITE_* codes here would DIVERGE from Node. Task 4's + // OpencodeByIdError still carries the code — log it + // (structured, with provider + code) for diagnosability, + // then drop it from the wire. + tracing::warn!(provider = "opencode", code = ?e.code, message = %e.message, "opencode by-id lookup failed"); + freshell_sessions::resume_resolve::ProviderFailure { + code: None, + message: e.message, + } + }) + }) as crate::resolve::OpencodeByIdLookup +}), +locate_claude_transcript: Some({ + std::sync::Arc::new(|session_id: &str| { + let lowered = session_id.to_ascii_lowercase(); + // Node-parity root (`server/claude-home.ts:4-7` + + // `providers/claude.ts:524-535`): CLAUDE_HOME (non-empty) else + // `/.claude`, joined with "projects", where `` resolves + // HOME then USERPROFILE with an EMPTY var treated as unset + // (`session_directory::provider_home()` — the SAME root the Rust + // session index uses). Node's `os.homedir()` is USERPROFILE-backed + // on native Windows, where Tauri deliberately leaves HOME unset; a + // HOME-only fallback would silently miss every transcript there. + // Note CLAUDE_HOME alone suffices even when no home resolves (Node's + // getClaudeHome() honors it directly); no root ⇒ Ok(None), a miss. + let claude_home = match std::env::var("CLAUDE_HOME").ok().filter(|v| !v.is_empty()) { + Some(v) => Some(std::path::PathBuf::from(v)), + None => session_directory::provider_home().map(|h| h.join(".claude")), + }; + let roots: Vec = match claude_home { + Some(h) => vec![h.join("projects")], + None => return Ok(None), + }; + match freshell_freshagent::locate_transcript_checked(&roots, &lowered) { + Ok(Some(path)) => match freshell_freshagent::transcript_cwd_checked(&path) { + Ok(cwd) => Ok(Some(freshell_sessions::resume_resolve::ClaudeTranscriptHit { + cwd, + session_id: lowered, + })), + Err(e) => Err(freshell_sessions::resume_resolve::ProviderFailure { + code: errno_code(&e), + message: format!("Claude transcript read failed: {e}"), + }), + }, + Ok(None) => Ok(None), + Err(e) => Err(freshell_sessions::resume_resolve::ProviderFailure { + code: errno_code(&e), + message: format!("Claude transcript scan failed: {e}"), + }), + } + }) as crate::resolve::ClaudeLocator +}), +``` + +> **POST-EXECUTION NOTE (2026-07-31):** the wiring above landed with the closure +> bodies extracted to named helpers in `crates/freshell-server/src/main.rs` — +> `resolve_wire_home_dir()` (the `homeDir` field) and +> `resolve_claude_exact_id_fallback()` (the `locate_claude_transcript` body) — +> both resolving the home through `session_directory::provider_home()` +> (HOME then USERPROFILE, empty treated as unset). An earlier revision of this +> plan instructed a `CLAUDE_HOME` → `HOME`-only fallback here, which could not +> achieve native-Windows parity (Tauri leaves HOME unset; Node's `os.homedir()` +> is USERPROFILE-backed there). + +Home-resolution verifiers for this wiring (landed, in `main.rs`'s test module and `session_directory.rs`'s test module): +- `claude_exact_id_fallback_finds_transcript_in_a_userprofile_only_environment` (`crates/freshell-server/src/main.rs`) — the USERPROFILE-only exact-id fallback test: HOME and CLAUDE_HOME unset, USERPROFILE pointing at a temp home containing `.claude/projects//.jsonl`; the fallback ITSELF (not just `provider_home()`) must return the transcript hit with the lowercased id and its cwd. +- `wire_home_dir_treats_empty_home_as_unset_falling_back_to_userprofile` (`crates/freshell-server/src/main.rs`) — an EMPTY `HOME` must fall through to `USERPROFILE` for the `homeDir` wire field. +- `provider_home_falls_back_to_userprofile_when_home_unset`, `provider_home_prefers_home_over_userprofile`, `provider_home_none_when_home_and_userprofile_unset` (`crates/freshell-server/src/session_directory.rs`) — the shared helper's precedence and empty-as-unset semantics. + +- [ ] **Step 4: Async-hygiene verification (context §5 — verify, don't assume)** + +Confirm and record (in the commit message body): the ENTIRE `resolve_resume_input` call — including both blocking fallback closures (rusqlite query, transcript directory walk) — runs inside `tokio::task::spawn_blocking` (`resolve.rs`, the Task-3-preserved block), so no DB/FS wait ever blocks the async runtime; per-request work is bounded by `MAX_RESUME_CANDIDATES (8) × FALLBACK_BUDGET_PER_REQUEST (2 per provider)` fallback calls + one index scan per token. Grep that no OTHER call path invokes these closures outside `spawn_blocking`: `grep -rn "opencode_session_by_id\|locate_claude_transcript" crates/freshell-server/src/ --include=*.rs`. Add one sentence to `resolve.rs`'s module doc stating this invariant so future edits keep it. + +- [ ] **Step 5: Run** + +Run: +```bash +set -o pipefail +cargo test -p freshell-server && cargo test -p freshell-freshagent +cargo test --workspace 2>&1 | grep -E '^test result:' +cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings +``` +Expected: ALL PASS (every `test result:` line shows `0 failed`), fmt/clippy clean. (`set -o pipefail` keeps a failing `cargo test` from exiting through `grep`'s success status.) + +- [ ] **Step 6: Commit** + +```bash +git add crates/freshell-server/src/resolve.rs crates/freshell-server/src/main.rs crates/freshell-freshagent/src/ +git commit -m "feat(server): hardened resolve wire — providerErrors/unsearchedProviders/homeDir, scan-failure merge, degraded fire-and-forget refresh + +POST /api/sessions/resolve now emits the full #586 contract; production +fallbacks report failures (checked claude locator, propagating opencode +by-id) instead of swallowing them; degraded-path proven on the wire +(broken store -> degraded + providerErrors, never silent not-found). +Async hygiene verified: all blocking fallback IO runs inside the +endpoint's spawn_blocking; work bounded by cap-8 candidates x budget-2 +fallbacks. (SYNC-06) + +🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) + +Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>" +``` + +--- + +### Task 7: Full verification, shared e2e 2× both projects, SYNC-06 checklist evidence, push + +The `sessionResolve` flag and the shared client dialog's resume HAPPY PATH are proven by the SHARED e2e spec running against BOTH server kinds. Scope honesty: `resume-button.spec.ts` has exactly 3 tests (pinned-button visibility at scroll positions, mobile visibility, paste-then-Enter exact resume) — it does NOT exercise degraded UI, manual retry, or homeDir prefill. Those hardened behaviors are proven at the WIRE level by Task 6's endpoint tests, and by the shared client's own #586 coverage (unchanged by this branch, but EXECUTED here — Step 2 runs `test:client`, which includes `ResumeSessionDialog.test.tsx`'s degraded/retry/homeDir/unsearched tests, and the shared contract test). Then record the evidence and push. + +**Files:** +- Modify: `docs/plans/2026-07-14-rust-tauri-parity-completion-checklist.md` (SYNC-06 entry, line ~803) +- No source changes expected; any failure discovered here is fixed by amending the responsible earlier area with its own test-first micro-cycle. + +**Interfaces:** +- Consumes: all previous tasks' work, committed. +- Produces: green full-matrix evidence + updated checklist + pushed branch. + +- [ ] **Step 1: Rust full gate** + +Run: +```bash +set -o pipefail +cargo test --workspace 2>&1 | grep -E '^test result:' +cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings +``` +Expected: every `test result:` line shows `0 failed`; fmt/clippy clean. (`set -o pipefail` keeps a failing `cargo test` from exiting through the pipe with `grep`'s success status.) Cargo prints one `test result:` line PER TEST BINARY and no workspace aggregate — record the checklist's total passed count by SUMMING the `N passed` figures across the printed lines. + +- [ ] **Step 2: Coordinated TS suites** + +Run: +```bash +npm run test:status +npm run test:vitest -- --config config/vitest/vitest.config.ts test/unit/shared/resume-input-parser.test.ts test/unit/shared/resume-resolve-contract.test.ts --run +npm run test:vitest -- --config config/vitest/vitest.server.config.ts test/unit/server/coding-cli/resolve-session.test.ts test/unit/server/coding-cli/resolve-fallbacks.test.ts test/integration/server/sessions-resolve-router.test.ts --run +npm run test:client +``` +Expected: ALL PASS (the branch did not modify Node server or client code; these prove no accidental TS regressions). The last two runs are VERIFICATION EVIDENCE for the hardened UI claims, not optional: `resume-resolve-contract.test.ts` proves the shared wire contract (degraded/providerErrors/unsearchedProviders/homeDir/legacy tolerance) the Rust server now emits, and `test:client` (the coordinator's `test/unit/client` suite) EXECUTES `ResumeSessionDialog.test.tsx` — warming/manual retry, degraded display + no auto-resume, homeDir prefill, unsearched-provider messaging. Citing those tests without running them is not evidence. If `test:status` reports another session's run in progress, WAIT — never kill processes you did not spawn. + +- [ ] **Step 3: Shared e2e, both projects, twice** + +Sanity-check first that the flag declaration still stands: `grep -n "sessionResolve" crates/freshell-server/src/main.rs` — expected: present in `build_platform_payload`. + +Run TWICE (the config's own server management uses ephemeral ports/HOMEs — verify no `--port 3001/3002` style overrides leak in via env before running). The explicit `--project` filters are REQUIRED: without them the default `chromium` project also matches this spec and each run would be 9 tests, not 6: +```bash +npm run test:e2e -- resume-button.spec.ts --project=legacy-chromium --project=rust-chromium +npm run test:e2e -- resume-button.spec.ts --project=legacy-chromium --project=rust-chromium +``` +Expected: all 3 tests × both projects (legacy-chromium AND rust-chromium) pass, both runs — 6/6 each run. This is the acceptance proof that the shared dialog's resume happy path (paste-then-Enter exact resume) and the `sessionResolve` flag work identically against the Rust server; the hardened degraded/retry/homeDir behaviors are proven at the wire level by Task 6's endpoint tests (this spec does not exercise them). + +- [ ] **Step 4: Update the SYNC-06 checklist entry (PARTIAL convention)** + +In `docs/plans/2026-07-14-rust-tauri-parity-completion-checklist.md`, append a NEW `PARTIAL` bullet under the SYNC-06 item, directly after the existing `PARTIAL (2026-07-30, commit c38422a0)` bullet, following that bullet's exact style: + +> **POST-EXECUTION NOTE (2026-07-31):** the `c38422a0` reference above is a +> PRE-REWORD SHA that no longer exists in this branch's history (the branch +> was reworded in place; commit `6976f1caf` remapped the checklist's SHAs). +> The bullet it points at now reads `PARTIAL / REOPENED (2026-07-30)` with no +> commit reference, and the new bullet this step appends landed in the +> checklist citing the reworded implementation commit `22022a848`. + +```markdown + - PARTIAL (2026-07-30, hardened-contract follow-up, commit ``): rebased onto `f903e8a6` (#586) and closed the hardening delta. Contract: `status` gains `degraded`; `providerErrors`/`unsearchedProviders`/`homeDir` on the wire (`crates/freshell-server/src/resolve.rs`). Ranking: per-token exact→fallback→prefix, ses_ case-SENSITIVE, subagents excluded from prefix discovery, sessionType provider-default. Parser: known-family prefix regex + MAX_RESUME_CANDIDATES=8, pinned by the EXTENDED shared fixture `test/fixtures/resume-input/parser-cases.json` ( cases; TS test restored to fixture-driven form) green on BOTH parsers. Provider health: broken opencode store → degraded + providerErrors on the wire (never silent not-found), scan-failure channel + disabled→unsearched, degraded fire-and-forget refresh; hardened by-id row query (archived+child, errors propagate). Async hygiene: all fallback IO inside spawn_blocking, work bounded by cap-8×budget-2. `cargo test --workspace`: passed, 0 failed; fmt+clippy clean. E2E: `resume-button.spec.ts` green on BOTH projects, 2 runs each (/ per run). MISSING: the `PW-TAURI-WIN` (native Windows WebView2) half remains out of scope, per the SYNC-05/SAFE-11 PARTIAL convention. +``` + +Replace every ``/`` with the REAL numbers/sha from Steps 1–3 (they are evidence, not boilerplate — copy them from the actual command output). + +- [ ] **Step 5: Commit and push (NO PR)** + +```bash +git add docs/plans/2026-07-14-rust-tauri-parity-completion-checklist.md +git commit -m "docs: record SYNC-06 hardened-contract parity evidence in completion checklist + +🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) + +Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>" +git push -u origin feat/rust-resolve-parity +``` + +Expected: push succeeds (the branch was local-only; this creates the remote branch). Do NOT open a pull request — that requires explicit user approval. + +> **POST-EXECUTION NOTE (2026-07-31):** OVERTAKEN BY EVENTS — the push above +> DID run during execution (creating `origin/feat/rust-resolve-parity`), but +> the local history was subsequently REWORDED in place (two commit messages +> corrected), so origin now holds divergent PRE-REWORD history. Re-running the +> plain `git push -u origin feat/rust-resolve-parity` would fail +> non-fast-forward. Publishing the corrected history requires the USER's +> deliberate `git push --force-with-lease origin feat/rust-resolve-parity` +> (safety tag `pre-reword-backup` preserves the pre-reword tip). Do not push +> without explicit user direction. + +--- + +## Self-Review Record + +**1. Spec coverage** (context §"The delta to close" → tasks): §1 CONTRACT (degraded/providerErrors/unsearchedProviders/homeDir, camelCase, backward-tolerant) → Tasks 3+6. §2 RANKING (per-token exact→fallback→prefix, ses_ case-sensitive, subagents, sessionType default) → Task 3. §3 PARSER (known-family regex, cap-8, fixture extended, both sides pass) → Task 2. §4 PROVIDER HEALTH (degraded never-silent, disabled→unsearched, degraded-even-with-matches, match-cap verified: hardened Node keeps `RESOLVE_MATCH_CAP = 20`, so the branch's cap-20 pin stands) → Tasks 3+5+6. §5 ASYNC HYGIENE → Task 6 Step 4. §6 WARMING (core + wire tests, Tasks 3+6) + shared dialog happy-path via shared e2e → Task 7 (degraded/retry/homeDir UI proven at the wire by Task 6 AND by the EXECUTED shared client suite — Task 7 Step 2 runs `test:client`, which includes `ResumeSessionDialog.test.tsx`, plus the shared contract test; the e2e spec covers visibility + exact resume only). Acceptance items: rebase done (verified Task 1), fixture both-sides (Task 2), mirror suite updated (Task 3), degraded-path wire test (Task 6), e2e 2× both (Task 7), cargo+TS green (Tasks 1–7), SYNC-06 PARTIAL update (Task 7), branch pushed / no PR (Task 7). No unresolved coverage gaps. + +**1b. No silent deferrals:** Injected-closure tests in Tasks 3/6 are complemented by production-behavior proof: Task 4 tests hit REAL sqlite files (corrupt/missing/locked classes, with the SQLITE_* codes asserted on the INTERNAL `OpencodeByIdError` — the wire deliberately carries message-only opencode errors, matching Node's worker boundary which strips `.code` in production (`opencode-by-id.worker.ts:41-42`, `opencode-by-id-runner.ts:103-106`); the endpoint test asserts the code-ABSENT wire shape and the internal code feeds structured logs), Task 6 Step 3 wires the REAL closures with failure reporting and tests the checked locator against a real unreadable directory, and Task 7's shared e2e exercises the full production path against the real Rust server. The one intentionally-remaining gap (PW-TAURI-WIN) is the checklist's long-standing recorded convention, explicitly restated — not a new deferral introduced by this plan. + +**2. Placeholder scan (CORRECTED 2026-07-31):** the original self-review understated this: Task 4 Step 1 shipped TEN comment-only test bodies and Task 6 Step 1 shipped FIVE — fifteen syntactically valid tests whose bodies were only comments, i.e. unresolved placeholders that would have PASSED VACUOUSLY if pasted as-is (an independent review flagged this, and flagged that this line falsely declared no placeholders remained). Each did name the fixture pattern, exact inputs, and expected JSON/values, but the blocks themselves were not enforcing verifiers. Both blocks now carry `unimplemented!()` markers (a literal paste fails loudly) plus post-execution notes pointing at the real verifiers, which landed with full assertions in `crates/freshell-sessions/tests/opencode_row_by_id.rs` and the test module of `crates/freshell-server/src/resolve.rs` under the same test names. Checklist `` slots are run-time evidence by design. + +**3. Type consistency check:** `ResumeResolveOutcome{status,matches,hint,provider_errors}` produced in Task 3 = consumed in Task 6. `OpencodeByIdHit{session_id,cwd,title,last_activity_at}` (Task 3) is built from `OpencodeByIdRow` (Task 4) in Task 6's closure — field names verified 1:1. `ProviderFailure{code,message}` used identically in Tasks 3/4/6. `scan_failures()->Vec`, `request_refresh()`, `async coding_cli_enabled_providers()->Vec` (Task 5) match Task 6's call sites (awaited in the async route/main). `MAX_RESUME_CANDIDATES` (Task 2) referenced in Task 6's hygiene note. State field `opencode_session_by_id` renamed once in Task 3 and used consistently after. diff --git a/port/oracle/fixtures/handshake-transcript.json b/port/oracle/fixtures/handshake-transcript.json index 534a9fb96..b5f994f46 100644 --- a/port/oracle/fixtures/handshake-transcript.json +++ b/port/oracle/fixtures/handshake-transcript.json @@ -33,7 +33,7 @@ { "dir": "in", "type": "settings.updated", - "raw": "{\"type\":\"settings.updated\",\"settings\":{\"logging\":{\"debug\":false},\"safety\":{\"autoKillIdleMinutes\":15},\"terminal\":{\"scrollback\":10000},\"panes\":{\"defaultNewPane\":\"ask\"},\"sidebar\":{\"excludeFirstChatSubstrings\":[],\"excludeFirstChatMustStart\":false,\"autoGenerateTitles\":true},\"ai\":{},\"codingCli\":{\"enabledProviders\":[\"claude\",\"codex\",\"opencode\"],\"providers\":{\"claude\":{\"permissionMode\":\"default\"},\"codex\":{}},\"mcpServer\":true,\"knownProviders\":[]},\"editor\":{\"externalEditor\":\"auto\"},\"freshAgent\":{\"enabled\":false,\"defaultPlugins\":[],\"providers\":{}},\"extensions\":{\"disabled\":[]},\"network\":{\"host\":\"127.0.0.1\",\"configured\":true}}}", + "raw": "{\"type\":\"settings.updated\",\"settings\":{\"logging\":{\"debug\":false},\"safety\":{\"autoKillIdleMinutes\":15},\"terminal\":{\"scrollback\":10000},\"panes\":{\"defaultNewPane\":\"ask\"},\"sidebar\":{\"excludeFirstChatSubstrings\":[],\"excludeFirstChatMustStart\":false,\"autoGenerateTitles\":true},\"ai\":{},\"codingCli\":{\"enabledProviders\":[\"claude\",\"codex\",\"opencode\",\"amplifier\"],\"providers\":{\"claude\":{\"permissionMode\":\"default\"},\"codex\":{}},\"mcpServer\":true,\"knownProviders\":[]},\"editor\":{\"externalEditor\":\"auto\"},\"freshAgent\":{\"enabled\":false,\"defaultPlugins\":[],\"providers\":{}},\"extensions\":{\"disabled\":[]},\"network\":{\"host\":\"127.0.0.1\",\"configured\":true}}}", "parsed": { "type": "settings.updated", "settings": { @@ -59,7 +59,8 @@ "enabledProviders": [ "claude", "codex", - "opencode" + "opencode", + "amplifier" ], "providers": { "claude": { diff --git a/server/platform-router.ts b/server/platform-router.ts index 7ea116d98..cbddb7753 100644 --- a/server/platform-router.ts +++ b/server/platform-router.ts @@ -21,9 +21,9 @@ export function detectFeatureFlags(): Record { return { kilroy: isTruthy(process.env.KILROY_ENABLED), aiEnabled: AI_CONFIG.enabled(), - // Resume-by-id UI: only the Node server implements POST /api/sessions/resolve. - // The Rust server's featureFlags parity (crates/freshell-server/src/boot.rs) - // intentionally omits this key, hiding the Sidebar Resume button there. + // Resume-by-id UI (SYNC-06): BOTH servers implement POST + // /api/sessions/resolve and declare this flag — the Rust side in + // build_platform_payload (crates/freshell-server/src/main.rs). sessionResolve: true, } } diff --git a/test/e2e-browser/playwright.config.ts b/test/e2e-browser/playwright.config.ts index 801b883cc..47e4d11fc 100644 --- a/test/e2e-browser/playwright.config.ts +++ b/test/e2e-browser/playwright.config.ts @@ -51,6 +51,11 @@ const MATRIX_SPECS = [ // kinds. See term13-scrollback-boundary.spec.ts. /term13-scrollback-boundary\.spec\.ts$/, /ws-ping-pong-matrix\.spec\.ts$/, + // SYNC-06 -- resume-by-id parity: the pinned sidebar Resume button and the + // paste-then-Enter resume path against BOTH servers (POST /api/sessions/resolve + // + sessionResolve flag now exist on the Rust server too, with the hardened + // response surface -- degraded/providerErrors/unsearchedProviders/homeDir). + /resume-button\.spec\.ts$/, // SESSION-01 narrowed-MISSING closure -- sidebar-click resume (Codex leg // runs on both kinds; the Amplifier leg self-skips on legacy via an // explicit `test.skip` KNOWN DIVERGENCE call). See sidebar-click-resume.spec.ts. diff --git a/test/e2e-browser/specs/resume-button.spec.ts b/test/e2e-browser/specs/resume-button.spec.ts index 00eafa6c1..84fe7f6f2 100644 --- a/test/e2e-browser/specs/resume-button.spec.ts +++ b/test/e2e-browser/specs/resume-button.spec.ts @@ -44,11 +44,6 @@ const FAKE_APP_SERVER_SOURCE = path.resolve( '../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs', ) -const RUST_SKIP = - 'KNOWN DIVERGENCE: the Rust server has no POST /api/sessions/resolve and does not ' + - 'declare the sessionResolve feature flag (button hidden there by design) — ' + - 'out of scope, see docs/plans/2026-07-29-resume-session-button.md.' - /** The known target id among the seeded sessions (a real uuid so the parser extracts it). */ const RESUME_ID = '4e3f2a10-9d1c-4b7e-8a55-0c9f6b2d7e31' /** Filler sessions so the sidebar list genuinely scrolls. */ @@ -230,8 +225,6 @@ async function bootResumeScenario(e2eServerKind: 'legacy' | 'rust'): Promise { - test.skip(e2eServerKind !== 'legacy', RUST_SKIP) - const scenario = await bootResumeScenario(e2eServerKind) try { await bootAndConnect(page, scenario.info) @@ -261,8 +254,6 @@ test('resume button stays visible at top/middle/bottom scroll', async ({ page, e }) test('resume button is visible in fullWidth mobile mode', async ({ page, e2eServerKind }) => { - test.skip(e2eServerKind !== 'legacy', RUST_SKIP) - const scenario = await bootResumeScenario(e2eServerKind) try { await page.setViewportSize({ width: 390, height: 844 }) @@ -281,8 +272,6 @@ test('resume button is visible in fullWidth mobile mode', async ({ page, e2eServ }) test('paste-then-Enter resumes the session with the right agent', async ({ page, e2eServerKind }) => { - test.skip(e2eServerKind !== 'legacy', RUST_SKIP) - const scenario = await bootResumeScenario(e2eServerKind) try { const harness = await bootAndConnect(page, scenario.info) diff --git a/test/fixtures/resume-input/parser-cases.json b/test/fixtures/resume-input/parser-cases.json new file mode 100644 index 000000000..8fc43a1a1 --- /dev/null +++ b/test/fixtures/resume-input/parser-cases.json @@ -0,0 +1,228 @@ +{ + "$comment": "SYNC-06 shared parser fixture. Consumed by test/unit/shared/resume-input-parser.test.ts AND crates/freshell-sessions/tests/resume_input_parser_parity.rs. Both implementations of the resume-input parser must pass every case. Add cases here, never inline, so the TS and Rust parsers cannot drift.", + "cases": [ + { + "name": "bare short hex", + "input": "417e8345", + "candidates": [{ "token": "417e8345", "kind": "hex-prefix" }], + "hint": { "provider": "amplifier", "source": "id-shape" } + }, + { + "name": "bare v4 uuid", + "input": "ed2afda6-a340-443e-ba60-024a1b3554b4", + "candidates": [{ "token": "ed2afda6-a340-443e-ba60-024a1b3554b4", "kind": "uuid" }], + "hint": { "provider": "claude", "source": "id-shape" } + }, + { + "name": "bare v7 uuid", + "input": "019fac27-69d7-78a0-b972-b339d551042e", + "candidates": [{ "token": "019fac27-69d7-78a0-b972-b339d551042e", "kind": "uuid" }], + "hint": { "provider": "codex", "source": "id-shape" } + }, + { + "name": "bare opencode id", + "input": "ses_root0000000000000000000000", + "candidates": [{ "token": "ses_root0000000000000000000000", "kind": "prefixed-id" }], + "hint": { "provider": "opencode", "source": "id-shape" } + }, + { + "name": "codex resume command", + "input": "codex resume 019fac27-69d7-78a0-b972-b339d551042e", + "candidates": [{ "token": "019fac27-69d7-78a0-b972-b339d551042e", "kind": "uuid" }], + "hint": { "provider": "codex", "source": "command" } + }, + { + "name": "claude --resume command", + "input": "claude --resume ed2afda6-a340-443e-ba60-024a1b3554b4", + "candidates": [{ "token": "ed2afda6-a340-443e-ba60-024a1b3554b4", "kind": "uuid" }], + "hint": { "provider": "claude", "source": "command" } + }, + { + "name": "claude -r command with prompt", + "input": "$ claude -r ed2afda6-a340-443e-ba60-024a1b3554b4", + "candidates": [{ "token": "ed2afda6-a340-443e-ba60-024a1b3554b4", "kind": "uuid" }], + "hint": { "provider": "claude", "source": "command" } + }, + { + "name": "opencode --session command", + "input": "opencode --session ses_root0000000000000000000000", + "candidates": [{ "token": "ses_root0000000000000000000000", "kind": "prefixed-id" }], + "hint": { "provider": "opencode", "source": "command" } + }, + { + "name": "amplifier --resume short id", + "input": "amplifier --resume 417e8345", + "candidates": [{ "token": "417e8345", "kind": "hex-prefix" }], + "hint": { "provider": "amplifier", "source": "command" } + }, + { + "name": "quoted and padded", + "input": " \"claude --resume ed2afda6-a340-443e-ba60-024a1b3554b4\" ", + "candidates": [{ "token": "ed2afda6-a340-443e-ba60-024a1b3554b4", "kind": "uuid" }], + "hint": { "provider": "claude", "source": "command" } + }, + { + "name": "backticks", + "input": "`417e8345`", + "candidates": [{ "token": "417e8345", "kind": "hex-prefix" }], + "hint": { "provider": "amplifier", "source": "id-shape" } + }, + { + "name": "id embedded in a path", + "input": "/home/x/.claude/projects/foo/ed2afda6-a340-443e-ba60-024a1b3554b4.jsonl", + "candidates": [{ "token": "ed2afda6-a340-443e-ba60-024a1b3554b4", "kind": "uuid" }], + "hint": { "provider": "claude", "source": "word" } + }, + { + "name": "trailing punctuation", + "input": "session 417e8345.", + "candidates": [{ "token": "417e8345", "kind": "hex-prefix" }], + "hint": { "provider": "amplifier", "source": "id-shape" } + }, + { + "name": "ansi codes", + "input": "\u001b[32m417e8345\u001b[0m", + "candidates": [{ "token": "417e8345", "kind": "hex-prefix" }], + "hint": { "provider": "amplifier", "source": "id-shape" } + }, + { + "name": "multi-line noise", + "input": "To continue:\n$ codex resume 019fac27-69d7-78a0-b972-b339d551042e\nor open the app", + "candidates": [{ "token": "019fac27-69d7-78a0-b972-b339d551042e", "kind": "uuid" }], + "hint": { "provider": "codex", "source": "command" } + }, + { + "name": "english hex-looking word", + "input": "decade", + "candidates": [], + "hint": null + }, + { + "name": "facade sentence", + "input": "I spent a decade behind a facade", + "candidates": [], + "hint": null + }, + { + "name": "hex without digits", + "input": "deadbeef", + "candidates": [], + "hint": null + }, + { + "name": "garbage", + "input": "hello world!! no ids here", + "candidates": [], + "hint": null + }, + { + "name": "empty", + "input": "", + "candidates": [], + "hint": null + }, + { + "name": "orders prefixed ids, then uuids, then hex prefixes longest-first", + "input": "417e8345 ed2afda6-a340-443e-ba60-024a1b3554b4 ses_root0000000000000000000000 417e8345abcd", + "candidates": [ + { "token": "ses_root0000000000000000000000", "kind": "prefixed-id" }, + { "token": "ed2afda6-a340-443e-ba60-024a1b3554b4", "kind": "uuid" }, + { "token": "417e8345abcd", "kind": "hex-prefix" }, + { "token": "417e8345", "kind": "hex-prefix" } + ], + "hint": { "provider": "opencode", "source": "id-shape" } + }, + { + "name": "dedupes repeated tokens case-insensitively keeping the first casing", + "input": "ed2afda6-a340-443e-ba60-024a1b3554b4 ED2AFDA6-A340-443E-BA60-024A1B3554B4", + "candidates": [{ "token": "ed2afda6-a340-443e-ba60-024a1b3554b4", "kind": "uuid" }], + "hint": { "provider": "claude", "source": "id-shape" } + }, + { + "name": "caps hex tokens at 32 chars so git shas do not match", + "input": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "candidates": [], + "hint": null + }, + { + "name": "agent word only", + "input": "the claude session ed2afda6-a340-443e-ba60-024a1b3554b4", + "candidates": [{ "token": "ed2afda6-a340-443e-ba60-024a1b3554b4", "kind": "uuid" }], + "hint": { "provider": "claude", "source": "word" } + }, + { + "name": "returns null hint for prose without ids or agent words", + "input": "nothing to see", + "candidates": [], + "hint": null + }, + { + "name": "equal-length hex tokens keep text order (stable sort)", + "input": "417e8345 88997766", + "candidates": [ + { "token": "417e8345", "kind": "hex-prefix" }, + { "token": "88997766", "kind": "hex-prefix" } + ], + "hint": { "provider": "amplifier", "source": "id-shape" } + }, + { + "name": "unknown xxx_ prefix family is not a candidate", + "input": "abc_12345678", + "candidates": [], + "hint": null + }, + { + "name": "caps candidates at the work budget of 8, keeping the first eight in priority order", + "input": "00000000-1111-4222-8333-444444444440 00000001-1111-4222-8333-444444444441 00000002-1111-4222-8333-444444444442 00000003-1111-4222-8333-444444444443 00000004-1111-4222-8333-444444444444 00000005-1111-4222-8333-444444444445 00000006-1111-4222-8333-444444444446 00000007-1111-4222-8333-444444444447 00000008-1111-4222-8333-444444444448 00000009-1111-4222-8333-444444444449", + "candidates": [ + { "token": "00000000-1111-4222-8333-444444444440", "kind": "uuid" }, + { "token": "00000001-1111-4222-8333-444444444441", "kind": "uuid" }, + { "token": "00000002-1111-4222-8333-444444444442", "kind": "uuid" }, + { "token": "00000003-1111-4222-8333-444444444443", "kind": "uuid" }, + { "token": "00000004-1111-4222-8333-444444444444", "kind": "uuid" }, + { "token": "00000005-1111-4222-8333-444444444445", "kind": "uuid" }, + { "token": "00000006-1111-4222-8333-444444444446", "kind": "uuid" }, + { "token": "00000007-1111-4222-8333-444444444447", "kind": "uuid" } + ], + "hint": { "provider": "claude", "source": "id-shape" } + }, + { + "name": "uuid version other than 4 or 7 yields no id-shape hint", + "input": "ed2afda6-a340-143e-ba60-024a1b3554b4", + "candidates": [{ "token": "ed2afda6-a340-143e-ba60-024a1b3554b4", "kind": "uuid" }], + "hint": null + }, + { + "name": "uppercase uuid token is preserved as written", + "input": "ED2AFDA6-A340-443E-BA60-024A1B3554B4", + "candidates": [{ "token": "ED2AFDA6-A340-443E-BA60-024A1B3554B4", "kind": "uuid" }], + "hint": { "provider": "claude", "source": "id-shape" } + }, + { + "name": "seven hex chars are not a candidate", + "input": "417e834", + "candidates": [], + "hint": null + }, + { + "name": "claude -rf does not match the -r command shape but the word still hints", + "input": "claude -rf ed2afda6-a340-443e-ba60-024a1b3554b4", + "candidates": [{ "token": "ed2afda6-a340-443e-ba60-024a1b3554b4", "kind": "uuid" }], + "hint": { "provider": "claude", "source": "word" } + }, + { + "name": "known thread_ id family", + "input": "thread_abc123456", + "candidates": [{ "token": "thread_abc123456", "kind": "prefixed-id" }], + "hint": null + }, + { + "name": "known task_ id family with a long 46-char suffix", + "input": "task_a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8s9T0u1V2w3", + "candidates": [ + { "token": "task_a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8s9T0u1V2w3", "kind": "prefixed-id" } + ], + "hint": null + } + ] +} diff --git a/test/unit/shared/resume-input-parser.test.ts b/test/unit/shared/resume-input-parser.test.ts index fbfa2f6fa..772956f2f 100644 --- a/test/unit/shared/resume-input-parser.test.ts +++ b/test/unit/shared/resume-input-parser.test.ts @@ -1,5 +1,8 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' import { describe, expect, it } from 'vitest' import { parseResumeInput, MAX_RESUME_CANDIDATES } from '@shared/resume-input-parser' +import type { ResumeCandidate, ResumeHint } from '@shared/resume-input-parser' const V4 = 'ed2afda6-a340-443e-ba60-024a1b3554b4' const V7 = '019fac27-69d7-78a0-b972-b339d551042e' @@ -98,3 +101,29 @@ describe('parseResumeInput — advisory hint', () => { expect(parseResumeInput('nothing to see').hint).toBeNull() }) }) + +// SYNC-06 cross-language anti-drift: the SAME fixture table pins this parser +// and the Rust port (crates/freshell-sessions/tests/resume_input_parser_parity.rs). +// Behavior changes go through the fixture first — add cases there, never inline. +const FIXTURE = JSON.parse( + readFileSync(path.join(__dirname, '../../fixtures/resume-input/parser-cases.json'), 'utf-8'), +) as { + cases: Array<{ + name: string + input: string + candidates: ResumeCandidate[] + hint: ResumeHint | null + }> +} + +describe('parseResumeInput — shared cross-language fixture', () => { + it('has at least the pinned number of cases (anti-deletion gate, mirrors the Rust floor)', () => { + expect(FIXTURE.cases.length).toBeGreaterThanOrEqual(32) + }) + + it.each(FIXTURE.cases.map((c) => [c.name, c] as const))('%s', (_name, c) => { + const parsed = parseResumeInput(c.input) + expect(parsed.candidates).toEqual(c.candidates) + expect(parsed.hint ?? null).toEqual(c.hint ?? null) + }) +})