diff --git a/Cargo.lock b/Cargo.lock index bd9d19354..20eb279dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1302,6 +1302,7 @@ dependencies = [ "freshell-terminal", "libc", "serde_json", + "tempfile", "tokio", "tower", "tracing", @@ -1431,8 +1432,10 @@ dependencies = [ "freshell-sessions", "freshell-terminal", "futures-util", + "libc", "notify", "rusqlite", + "serde", "serde_json", "sha2", "socket2", diff --git a/crates/freshell-freshagent/Cargo.toml b/crates/freshell-freshagent/Cargo.toml index 7ac8bd1c4..e40af6a28 100644 --- a/crates/freshell-freshagent/Cargo.toml +++ b/crates/freshell-freshagent/Cargo.toml @@ -89,3 +89,4 @@ tower = { version = "0.5", features = ["util"] } # to assert lifecycle events fire, without depending on the global subscriber # `freshell-server` owns. tracing-subscriber = "0.3" +tempfile = "3" diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index d321c4861..b4700bfe2 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -79,14 +79,24 @@ pub struct FreshClaudeState { broadcast_tx: Arc>, /// placeholder-nanoid → live claude session (sidecar stdin + owned child + consumer). sessions: Arc>>, + /// durable Claude UUID (`cliSessionId` from `sdk.session.init`) -> sessions-map key. + /// THE restart-parity index (plan §2.8 item 2): lets attach/snapshot find a live + /// session by its durable identity instead of the process-ephemeral placeholder. + /// pub(crate) so in-crate tests (and snapshot wiring) can inspect it. + pub(crate) cli_index: Arc>>, /// `freshAgent.create` requestId dedup (parity gap fix -- see the module doc on /// [`crate::FreshAgentCreateDedup`]): single-flight + replay cache so a client /// resending the SAME `requestId` on every reconnect while a pane is /// `status==creating` reattaches to the ONE session it already created instead of /// spawning a fresh claude sidecar per resend. Cleared for a session's entries only /// on an explicit `freshAgent.kill` ([`Self::handle_kill`]); an unrequested sidecar - /// exit does NOT evict (mirrors legacy, see the type doc). + /// exit does NOT evict from THIS dedup cache (mirrors legacy, see the type doc) -- + /// it DOES evict the dead entry from the `sessions` map (consumer-exit eviction). create_dedup: Arc>, + /// Single-flight guard for resume-on-attach, keyed by DURABLE id (codex's + /// `resuming` analog, simplified: contenders return immediately instead of + /// waiting -- the winner's frames broadcast to every client anyway). + resuming: Arc>>, } /// The cached result of a completed claude/kilroy `freshAgent.create`, keyed by @@ -109,6 +119,18 @@ struct ClaudeSession { ownership_id: String, /// The stdout-consumer task (aborted on shutdown). consumer: tokio::task::JoinHandle<()>, + /// The id the SIDECAR keys this session by (`created.sessionId`). Equal to the + /// sessions-map key for created sessions; DIFFERENT for resumed-on-attach sessions + /// (Task 6), where the map key is the CLIENT's original id. `handle_send`/ + /// `handle_interrupt` MUST address the sidecar with this id, never the map key. + sidecar_session_id: String, + /// Best-effort copy of the durable Claude UUID, recorded from `sdk.session.init` + /// by the stdout consumer. Nothing in production reads it: attach/eviction resolve + /// durable ids through [`FreshClaudeState::cli_index`], and the snapshot adapter + /// is disk-only. Currently read only by in-crate tests; kept as a diagnostic/ + /// forward slot. + #[allow(dead_code)] + cli_session_id: Option, } impl FreshClaudeState { @@ -117,7 +139,9 @@ impl FreshClaudeState { Self { broadcast_tx, sessions: Arc::new(TokioMutex::new(HashMap::new())), + cli_index: Arc::new(TokioMutex::new(HashMap::new())), create_dedup: Arc::new(FreshAgentCreateDedup::new()), + resuming: Arc::new(TokioMutex::new(std::collections::HashSet::new())), } } @@ -143,6 +167,7 @@ impl FreshClaudeState { let _ = child.start_kill(); reap_owned_claude_sidecars(&session.ownership_id); } + self.cli_index.lock().await.clear(); } fn broadcast(&self, msg: &ServerMessage) { @@ -219,7 +244,12 @@ impl FreshClaudeState { }; // Start the stdout consumer (the completion edge normalization lives here). - let consumer = self.spawn_consumer(reader, created.clone(), session_type.to_string()); + let consumer = self.spawn_consumer( + reader, + created.clone(), + session_type.to_string(), + created.clone(), + ); self.sessions.lock().await.insert( created.clone(), @@ -228,6 +258,8 @@ impl FreshClaudeState { child, ownership_id, consumer, + sidecar_session_id: created.clone(), + cli_session_id: None, }, ); @@ -302,6 +334,13 @@ impl FreshClaudeState { .clear_for_session(|record| record.session_id == session_id) .await; + // Evict the killed session's durable-id index entries (retain covers the case + // where `sdk.session.init` raced in before the session-map insert). + self.cli_index + .lock() + .await + .retain(|_, mapped| mapped != &session_id); + self.broadcast(&ServerMessage::FreshAgentKilled(FreshAgentKilled { provider: PROVIDER.to_string(), session_id, @@ -326,13 +365,15 @@ impl FreshClaudeState { pub async fn handle_interrupt(&self, msg: FreshAgentInterrupt) { let session_id = msg.session_id.clone(); - let interrupt_req = json!({ "type": "interrupt", "sessionId": session_id }); let mut guard = self.sessions.lock().await; let Some(session) = guard.get_mut(&session_id) else { drop(guard); self.send_error(&None, "SESSION_NOT_FOUND", "claude session not found"); return; }; + // Address the sidecar by ITS id for this session (== the map key for created + // sessions; differs for resumed-on-attach sessions, Task 6). + let interrupt_req = json!({ "type": "interrupt", "sessionId": session.sidecar_session_id }); if let Err(err) = write_line(&mut session.stdin, &interrupt_req).await { drop(guard); self.send_error(&None, "CLAUDE_INTERRUPT_FAILED", &err); @@ -351,13 +392,16 @@ impl FreshClaudeState { let session_id = msg.session_id.clone(); let session_type = session_type_str(msg.session_type); - let send_req = json!({ "type": "send", "sessionId": session_id, "text": msg.text }); let mut guard = self.sessions.lock().await; let Some(session) = guard.get_mut(&session_id) else { drop(guard); self.send_error(&request_id, "SESSION_NOT_FOUND", "claude session not found"); return; }; + // Address the sidecar by ITS id for this session (== the map key for created + // sessions; differs for resumed-on-attach sessions, Task 6). + let send_req = + json!({ "type": "send", "sessionId": session.sidecar_session_id, "text": msg.text }); if let Err(err) = write_line(&mut session.stdin, &send_req).await { drop(guard); self.send_error(&request_id, "CLAUDE_SEND_FAILED", &err); @@ -377,27 +421,144 @@ impl FreshClaudeState { )); } - // ── freshAgent.attach (restart-resilience P0.2, slice 1: stop the swallow) ────────── + // ── freshAgent.attach (restart parity: resume untracked sessions in place) ────────── - /// Handle a `freshAgent.attach` for claude/kilroy. Decision table (this slice): + /// Handle a `freshAgent.attach` for claude/kilroy. Decision table (restart parity): /// /// | State | Action | /// |---|---| - /// | tracked | no-op -- NO frame (wire-shape parity with codex tracked-and-alive) | - /// | NOT tracked | `lost_session_frame` (`INVALID_SESSION_ID`) -> the client marks the pane `.lost` and `triggerRecovery` re-creates with `resumeSessionId` | - /// - /// Unlike codex (`ensure_session_resumable`) and opencode (`resume_durable_session`) - /// there is deliberately NO in-place resume here yet: claude has no server-side resume - /// path in the Rust port, and the restart-resilience plan (§2.8) slices that as - /// follow-on work (record cliSessionId, real attach arm, snapshot adapter). In this - /// slice recovery is CLIENT-driven -- the lost frame un-wedges a pane stuck BUSY - /// after a server restart instead of the prior silent swallow. + /// | tracked under `msg.session_id` | no-op -- NO frame (wire-shape parity, unchanged). Safe against dead sidecars ONLY because the consumer-exit eviction removes dead entries (ledger A9) | + /// | untracked, no canonical durable id on the message | `lost_session_frame` (`INVALID_SESSION_ID`) -- unchanged fallback (also covers the verified A2 edge: a pane that never learned its UUID pre-kill attaches bare; lost -> client re-create is the designed, non-destructive outcome) | + /// | untracked, durable id already in `cli_index` | no-op (a concurrent attach already resumed it; its frames broadcast to all). Index rows for dead sessions are evicted by the consumer-exit path | + /// | untracked, transcript EXISTS (in ANY candidate root) | spawn sidecar and resume with the session's ORIGINAL cwd from `transcript_cwd` (ledger A15: the CLI's resume lookup is cwd-slug-scoped); if that cwd no longer exists, resume by the transcript's `.jsonl` PATH (verified cli.js escape hatch that bypasses slug scoping) with the attach cwd. Register under the CLIENT's `msg.session_id`, emit idle `freshAgent.session.snapshot` whose `timelineSessionId` is the durable UUID -- NEVER a nanoid (the frozen client persists it unvalidated, ledger A14/N3) | + /// | untracked, transcript ABSENT in EVERY candidate root | `lost_session_frame` -- positive denial: the store is the authority (honest even under the 30-day GC, ledger A4) | + /// | untracked, spawn/pipe/created failure (incl. no store root resolvable) | top-level `error` `CLAUDE_ATTACH_RESUME_FAILED` -- NEVER the lost frame | pub async fn handle_attach(&self, msg: FreshAgentAttach) { - let tracked = self.sessions.lock().await.contains_key(&msg.session_id); - if tracked { + if self.sessions.lock().await.contains_key(&msg.session_id) { + return; // tracked-and-alive: no frame (wire-shape parity with codex) + } + let Some(durable) = attach_durable_id(&msg) else { + // No durable identity to resume from: the pre-parity fallback (PR #529). + self.broadcast(&lost_session_frame(&msg.session_id, msg.session_type)); return; + }; + if self.cli_index.lock().await.contains_key(&durable) { + return; // already resumed under another placeholder; frames broadcast anyway + } + { + let mut resuming = self.resuming.lock().await; + if !resuming.insert(durable.clone()) { + return; // a concurrent attach is resuming this exact durable id + } + } + let outcome = self.resume_for_attach(&msg, &durable).await; + self.resuming.lock().await.remove(&durable); + match outcome { + Ok(()) => {} + Err(ResumeClaudeError::NotFound) => { + self.broadcast(&lost_session_frame(&msg.session_id, msg.session_type)); + } + Err(ResumeClaudeError::Transient(err)) => { + self.send_error(&None, "CLAUDE_ATTACH_RESUME_FAILED", &err); + } + } + } + + /// The not-tracked resume (codex `ensure_session_resumable` analog, file-store + /// flavored): transcript-present gate -> spawn sidecar with `resumeSessionId` -> + /// register under the CLIENT's id -> idle snapshot. + async fn resume_for_attach( + &self, + msg: &FreshAgentAttach, + durable: &str, + ) -> Result<(), ResumeClaudeError> { + if crate::claude_snapshot::claude_home_candidates().is_empty() { + // No store root resolvable at all: we cannot CHECK, so we must not DENY. + return Err(ResumeClaudeError::Transient( + "no claude store root resolvable (CLAUDE_CONFIG_DIR/CLAUDE_HOME/HOME unset)" + .to_string(), + )); + } + // Positive denial only when the transcript is absent in EVERY candidate + // root (CLAUDE_CONFIG_DIR > CLAUDE_HOME > $HOME/.claude -- ledger A3). + let Some(transcript) = crate::claude_snapshot::locate_transcript(durable) else { + return Err(ResumeClaudeError::NotFound); + }; + // The CLI's resume lookup is scoped to the ORIGINAL cwd's project slug + // (ledger A15) -- resume with the cwd recorded in the transcript itself. + // If that directory is gone, fall back to path-based resume + // (`--resume .jsonl` bypasses slug scoping -- verified cli.js 2.1.220), + // keeping the attach cwd for the process itself. + let original_cwd = crate::claude_snapshot::transcript_cwd(&transcript) + .filter(|c| std::path::Path::new(c).is_dir()); + let (resume_value, resume_cwd) = match original_cwd { + Some(cwd) => (json!(durable), json!(cwd)), + None => (json!(transcript.to_string_lossy()), json!(msg.cwd)), + }; + + let (mut child, mut stdin, stdout, ownership_id) = spawn_sidecar() + .await + .map_err(ResumeClaudeError::Transient)?; + let request_id = format!("attach-resume-{}", uuid::Uuid::new_v4()); + let create_req = json!({ + "type": "create", + "requestId": request_id, + "cwd": resume_cwd, + "model": Value::Null, + "permissionMode": Value::Null, + "effort": Value::Null, + "resumeSessionId": resume_value, + }); + if let Err(err) = write_line(&mut stdin, &create_req).await { + let _ = child.start_kill(); + reap_owned_claude_sidecars(&ownership_id); + return Err(ResumeClaudeError::Transient(err)); } - self.broadcast(&lost_session_frame(&msg.session_id, msg.session_type)); + let mut reader = BufReader::new(stdout).lines(); + let sidecar_session_id = match read_created(&mut reader, SIDECAR_CREATE_BUDGET).await { + Ok(id) => id, + Err(err) => { + let _ = child.start_kill(); + reap_owned_claude_sidecars(&ownership_id); + return Err(ResumeClaudeError::Transient(err)); + } + }; + + let session_type = session_type_str(msg.session_type).to_string(); + // Register under the CLIENT's id: the consumer stamps the map key on every + // envelope and the frozen client routes by envelope sessionId + // (fresh-agent-ws.ts:180-183) -- a fresh key would strand the pane. + // 4-arg spawn_consumer: the sidecar's created id is the eviction identity + // guard for this consumer. + let consumer = self.spawn_consumer( + reader, + msg.session_id.clone(), + session_type.clone(), + sidecar_session_id.clone(), + ); + self.sessions.lock().await.insert( + msg.session_id.clone(), + ClaudeSession { + stdin, + child, + ownership_id, + consumer, + sidecar_session_id, + cli_session_id: Some(durable.to_string()), + }, + ); + self.cli_index + .lock() + .await + .insert(durable.to_string(), msg.session_id.clone()); + + self.broadcast(&status_snapshot_frame( + &msg.session_id, + durable, + "idle", + &session_type, + )); + Ok(()) } fn send_error(&self, request_id: &Option, code: &str, message: &str) { @@ -417,14 +578,21 @@ impl FreshClaudeState { /// Consume the sidecar's stdout event stream (one `sdk.*` JSON per line), normalize /// each `sdk.* → freshAgent.*` and broadcast it wrapped in a `freshAgent.event`. On EOF - /// (a clean end OR a mid-turn death) the loop just stops — never a false completion. + /// (a clean end OR a mid-turn death) the loop stops — never a false completion — and + /// the dead session is evicted from both maps (identity-guarded, ledger A9). + /// `sidecar_session_id` is the id THIS consumer's sidecar is keyed by; it guards the + /// eviction so a stale consumer can never evict a newer session re-registered under + /// the same map key (attach-resume, Task 6). fn spawn_consumer( &self, mut reader: tokio::io::Lines>, session_id: String, session_type: String, + sidecar_session_id: String, ) -> tokio::task::JoinHandle<()> { let broadcast_tx = self.broadcast_tx.clone(); + let sessions = self.sessions.clone(); + let cli_index = self.cli_index.clone(); tokio::spawn(async move { while let Ok(Some(line)) = reader.next_line().await { let trimmed = line.trim(); @@ -434,10 +602,45 @@ impl FreshClaudeState { let Ok(value) = serde_json::from_str::(trimmed) else { continue; }; + // Restart-parity (plan §2.8 item 2): record the durable Claude UUID. + // The index insert is load-bearing; the session-field copy is + // best-effort (the map entry may not exist yet during create). + if value.get("type").and_then(Value::as_str) == Some("sdk.session.init") { + if let Some(cli_id) = value.get("cliSessionId").and_then(Value::as_str) { + cli_index + .lock() + .await + .insert(cli_id.to_string(), session_id.clone()); + if let Some(session) = sessions.lock().await.get_mut(&session_id) { + session.cli_session_id = Some(cli_id.to_string()); + } + } + } if let Some(frame) = sdk_line_to_frame(&value, &session_id, &session_type) { let _ = broadcast_tx.send(frame); } } + // Consumer exit == this sidecar's stdout closed == sidecar death + // (ledger A9). Evict the dead session and its cli_index entries, + // identity-guarded: a newer session re-registered under the same + // map key (attach-resume, Task 6) has a DIFFERENT + // sidecar_session_id and must not be evicted by this stale consumer. + let evicted = { + let mut map = sessions.lock().await; + match map.get(&session_id) { + Some(s) if s.sidecar_session_id == sidecar_session_id => { + map.remove(&session_id); + true + } + _ => false, + } + }; + if evicted { + cli_index + .lock() + .await + .retain(|_, mapped| mapped != &session_id); + } }) } } @@ -507,6 +710,61 @@ fn session_type_str(session_type: SessionType) -> &'static str { /// `codex.rs`/`opencode_ws.rs` (both document the duplication) -- but unlike those two this /// one cannot hardcode the session type: provider `claude` covers BOTH `freshclaude` and /// `kilroy`, so the envelope's sessionType comes from the attach message. +/// The durable claude id an attach carries: `resumeSessionId` first, then +/// `sessionRef.sessionId` -- both written by the FROZEN client +/// (`FreshAgentView.tsx:303-313`). Only canonical UUIDs qualify +/// (`shared/session-contract.ts:34`) -- a nanoid here would just miss the store. +fn attach_durable_id(msg: &FreshAgentAttach) -> Option { + let candidate = msg + .resume_session_id + .clone() + .or_else(|| msg.session_ref.as_ref().map(|r| r.session_id.clone()))?; + is_canonical_claude_uuid(&candidate).then_some(candidate) +} + +fn is_canonical_claude_uuid(s: &str) -> bool { + let bytes = s.as_bytes(); + bytes.len() == 36 + && bytes.iter().enumerate().all(|(i, b)| match i { + 8 | 13 | 18 | 23 => *b == b'-', + _ => b.is_ascii_hexdigit(), + }) +} + +/// The codex-shape idle status snapshot (`freshAgent.session.snapshot`) claude emits +/// after a resume-on-attach: provider-agnostic client-side (`fresh-agent-ws.ts:196-206`), +/// it un-wedges a BUSY pane and hands the durable UUID over via `timelineSessionId`. +fn status_snapshot_frame( + session_id: &str, + timeline_session_id: &str, + status: &str, + session_type: &str, +) -> ServerMessage { + ServerMessage::FreshAgentEvent(FreshAgentEvent { + event: json!({ + "type": "freshAgent.session.snapshot", + "sessionId": session_id, + "latestTurnId": Value::Null, + "status": status, + "timelineSessionId": timeline_session_id, + }), + provider: PROVIDER.to_string(), + session_id: session_id.to_string(), + session_type: session_type.to_string(), + }) +} + +/// Why a resume-on-attach could not produce a live session (codex's +/// `ResumeSessionError` analog). +#[derive(Debug)] +enum ResumeClaudeError { + /// The transcript store positively has no file for this durable id. + NotFound, + /// Spawn/pipe/`created` failure -- the session may be perfectly resumable; + /// NEVER declared lost (opencode_ws.rs discipline). + Transient(String), +} + fn lost_session_frame(session_id: &str, session_type: SessionType) -> ServerMessage { ServerMessage::FreshAgentEvent(FreshAgentEvent { event: json!({ @@ -735,7 +993,7 @@ fn now_iso() -> String { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; fn state() -> FreshClaudeState { @@ -778,6 +1036,8 @@ mod tests { child, ownership_id: format!("test-{session_id}"), consumer, + sidecar_session_id: session_id.to_string(), + cli_session_id: None, }, ); } @@ -838,6 +1098,209 @@ mod tests { ); } + // ── freshAgent.attach: resume-on-attach (restart parity, Task 6) ────────────── + + fn attach_msg_with_resume(session_id: &str, durable: &str) -> FreshAgentAttach { + let mut msg = attach_msg(session_id); + msg.resume_session_id = Some(durable.to_string()); + msg + } + + fn write_fake_transcript(home: &std::path::Path, durable: &str) { + let dir = home.join("projects").join("-t"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join(format!("{durable}.jsonl")), + // `cwd` present + existing (ledger A15): the resume request must carry + // the transcript's ORIGINAL cwd, and resume by UUID (primary path). + r#"{"type":"user","cwd":"/tmp","message":{"role":"user","content":[{"type":"text","text":"hi"}]}}"#, + ) + .unwrap(); + } + + /// Bounded drain of the broadcast receiver until a `freshAgent.event` envelope with + /// the given INNER type arrives (mirrors [`await_claude_created`]'s 15s shape). + async fn await_frame_of_inner_type( + rx: &mut tokio::sync::broadcast::Receiver, + inner_type: &str, + ) -> Value { + tokio::time::timeout(std::time::Duration::from_secs(15), async { + loop { + let frame: Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap(); + if frame["type"] == "freshAgent.event" && frame["event"]["type"] == inner_type { + return frame; + } + } + }) + .await + .unwrap_or_else(|_| panic!("freshAgent.event with inner type {inner_type} within budget")) + } + + /// Bounded drain until a TOP-LEVEL `error` ServerMessage arrives; returns its message. + async fn await_top_level_error(rx: &mut tokio::sync::broadcast::Receiver) -> String { + tokio::time::timeout(std::time::Duration::from_secs(15), async { + loop { + let frame: Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap(); + if frame["type"] == "error" { + return frame["message"].as_str().unwrap_or_default().to_string(); + } + } + }) + .await + .unwrap_or_else(|_| panic!("top-level error frame within budget")) + } + + #[tokio::test] + async fn attach_untracked_with_transcript_resumes_and_emits_idle_snapshot() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + let home = tempfile::tempdir().unwrap(); + let durable = "77777777-7777-4777-8777-777777777777"; + write_fake_transcript(home.path(), durable); + std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); + + let (st, mut rx) = state_with_bus(); + st.handle_attach(attach_msg_with_resume("client-nanoid-1", durable)) + .await; + std::env::remove_var("CLAUDE_CONFIG_DIR"); + + // Registered under the CLIENT's id (envelope tagging + send routing depend on it). + assert!(st.sessions.lock().await.contains_key("client-nanoid-1")); + assert_eq!( + st.cli_index.lock().await.get(durable), + Some(&"client-nanoid-1".to_string()) + ); + // The fake received resumeSessionId (spawn log now records the create request). + let log = std::fs::read_to_string(env.spawn_log_path()).unwrap(); + assert!( + log.contains(durable), + "sidecar create must carry resumeSessionId" + ); + // Idle snapshot frame, tagged with the client's id + the durable timeline id. + let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; + assert_eq!(frame["sessionId"], "client-nanoid-1"); + assert_eq!(frame["event"]["status"], "idle"); + assert_eq!(frame["event"]["timelineSessionId"], durable); + drop(env); + } + + #[tokio::test] + async fn attach_untracked_with_missing_transcript_emits_lost_frame() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let home = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(home.path().join("projects")).unwrap(); + std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); + let (st, mut rx) = state_with_bus(); + st.handle_attach(attach_msg_with_resume( + "client-nanoid-2", + "88888888-8888-4888-8888-888888888888", + )) + .await; + std::env::remove_var("CLAUDE_CONFIG_DIR"); + let frame = await_frame_of_inner_type(&mut rx, "freshAgent.error").await; + assert_eq!(frame["event"]["code"], "INVALID_SESSION_ID"); + } + + #[tokio::test] + async fn attach_transient_spawn_failure_is_not_a_lost_frame() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let home = tempfile::tempdir().unwrap(); + let durable = "99999999-9999-4999-8999-999999999999"; + write_fake_transcript(home.path(), durable); + std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); + std::env::set_var("FRESHELL_CLAUDE_NODE", "/nonexistent-node-binary"); + let (st, mut rx) = state_with_bus(); + st.handle_attach(attach_msg_with_resume("client-nanoid-3", durable)) + .await; + std::env::remove_var("FRESHELL_CLAUDE_NODE"); + std::env::remove_var("CLAUDE_CONFIG_DIR"); + // Mirrors codex: transient => top-level error with the provider code, + // explicitly NOT INVALID_SESSION_ID. + let err = await_top_level_error(&mut rx).await; + assert!(err.contains("CLAUDE_ATTACH_RESUME_FAILED")); + assert!(st.sessions.lock().await.is_empty()); + } + + #[tokio::test] + async fn attach_untracked_without_any_durable_id_still_emits_lost_frame() { + // The pre-parity fallback (PR #529) is preserved verbatim. + let (st, mut rx) = state_with_bus(); + st.handle_attach(attach_msg("no-resume-anywhere")).await; + let frame = await_frame_of_inner_type(&mut rx, "freshAgent.error").await; + assert_eq!(frame["event"]["code"], "INVALID_SESSION_ID"); + } + + #[tokio::test] + async fn concurrent_attaches_for_the_same_durable_id_spawn_at_most_one_sidecar() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + let home = tempfile::tempdir().unwrap(); + let durable = "12121212-1212-4121-8121-121212121212"; + write_fake_transcript(home.path(), durable); + std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); + let (st, _rx) = state_with_bus(); + let a = st.clone(); + let b = st.clone(); + let m1 = attach_msg_with_resume("nano-a", durable); + let m2 = attach_msg_with_resume("nano-b", durable); + tokio::join!(a.handle_attach(m1), b.handle_attach(m2)); + std::env::remove_var("CLAUDE_CONFIG_DIR"); + assert_eq!(env.spawn_count(), 1, "single-flight per durable id"); + drop(env); + } + + /// Decision-table row 3: a durable id ALREADY in `cli_index` means an earlier + /// attach resumed it under another placeholder -- this attach must neither + /// spawn nor broadcast anything (the winner's frames reach every client). + #[tokio::test] + async fn attach_with_durable_id_already_indexed_is_a_no_op() { + let (st, mut rx) = state_with_bus(); + let durable = "56565656-5656-4656-8656-565656565656"; + st.cli_index + .lock() + .await + .insert(durable.to_string(), "earlier-winner".to_string()); + st.handle_attach(attach_msg_with_resume("late-attacher", durable)) + .await; + assert!( + rx.try_recv().is_err(), + "indexed-durable attach must broadcast nothing" + ); + assert!(st.sessions.lock().await.is_empty()); + } + + /// Ledger A15's failure case: the transcript's recorded cwd no longer exists on + /// disk, so the resume request must carry the transcript's `.jsonl` PATH (the + /// verified cli.js escape hatch bypassing slug scoping) instead of the bare UUID. + #[tokio::test] + async fn attach_resume_falls_back_to_transcript_path_when_original_cwd_is_gone() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + let home = tempfile::tempdir().unwrap(); + let durable = "34343434-3434-4343-8343-343434343434"; + let dir = home.path().join("projects").join("-t"); + std::fs::create_dir_all(&dir).unwrap(); + let transcript = dir.join(format!("{durable}.jsonl")); + std::fs::write( + &transcript, + r#"{"type":"user","cwd":"/nonexistent-original-cwd-freshell-task6","message":{"role":"user","content":[{"type":"text","text":"hi"}]}}"#, + ) + .unwrap(); + std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); + let (st, mut rx) = state_with_bus(); + st.handle_attach(attach_msg_with_resume("client-nanoid-4", durable)) + .await; + std::env::remove_var("CLAUDE_CONFIG_DIR"); + let log = std::fs::read_to_string(env.spawn_log_path()).unwrap(); + assert!( + log.contains(transcript.to_string_lossy().as_ref()), + "cwd-gone resume must carry the transcript PATH, got: {log}" + ); + let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; + assert_eq!(frame["event"]["timelineSessionId"], durable); + drop(env); + } + #[test] fn normalize_maps_the_known_sdk_set_and_ignores_others() { assert_eq!( @@ -994,7 +1457,10 @@ mod tests { /// Serializes every test in this file that mutates process-global env vars /// (`FRESHELL_CLAUDE_SIDECAR` / `FRESHELL_CLAUDE_NODE`), mirroring codex's /// `ENV_LOCK` (`codex.rs`). - static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + // `pub(crate)` so snapshot.rs's claude-store tests share this ONE lock (mirroring + // how snapshot.rs reuses `crate::codex::tests::ENV_LOCK`) -- two independent + // per-file locks would NOT serialize against each other. + pub(crate) static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); /// A minimal scripted fake claude sidecar (no real `@anthropic-ai/claude-agent-sdk`, /// no network, no cost): on `{"type":"create",...}` it appends a marker line to @@ -1010,9 +1476,6 @@ import fs from 'node:fs' import readline from 'node:readline' const spawnLog = process.env.FRESHELL_TEST_CLAUDE_SPAWN_LOG -if (spawnLog) { - fs.appendFileSync(spawnLog, `${process.pid}\n`) -} let counter = 0 const rl = readline.createInterface({ input: process.stdin, terminal: false }) @@ -1026,9 +1489,23 @@ rl.on('line', (line) => { return } if (msg.type === 'create') { + // Log the WHOLE create request (one line per create) so tests can both count + // spawns AND assert what the sidecar received (e.g. resumeSessionId). + if (spawnLog) { + fs.appendFileSync(spawnLog, `${JSON.stringify(msg)}\n`) + } counter += 1 const sessionId = `fake-claude-session-${process.pid}-${counter}` process.stdout.write(JSON.stringify({ type: 'created', sessionId }) + '\n') + // Mirror the real sidecar's post-create init: echo resumeSessionId as the durable + // id when present (resume continuity), else a fixed fake uuid. + const cliSessionId = msg.resumeSessionId || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + console.log(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] })) + console.log(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' })) + } else if (msg.type === 'send') { + // Test hook: lets tests kill the sidecar THROUGH the public API to exercise + // the consumer-exit eviction path (ledger A9). + if (msg.text === '__exit__') process.exit(0) } else if (msg.type === 'interrupt') { const interruptLog = process.env.FRESHELL_TEST_CLAUDE_INTERRUPT_LOG if (interruptLog) fs.appendFileSync(interruptLog, `${msg.sessionId}\n`) @@ -1071,6 +1548,12 @@ rl.on('line', (line) => { } } + /// Path of the spawn log (one full JSON create-request line per spawn) so tests + /// can assert what the sidecar received (e.g. `resumeSessionId`). + fn spawn_log_path(&self) -> &std::path::Path { + &self.spawn_log + } + /// Number of times the fake sidecar has been spawned so far (one marker line per /// process start). fn spawn_count(&self) -> usize { @@ -1094,6 +1577,19 @@ rl.on('line', (line) => { } } + fn send_msg(session_id: &str, text: &str) -> FreshAgentSend { + FreshAgentSend { + provider: freshell_protocol::AgentProvider::Claude, + session_id: session_id.to_string(), + session_type: SessionType::Freshclaude, + text: text.to_string(), + cwd: None, + images: None, + request_id: None, + settings: None, + } + } + fn dedup_create_msg(request_id: &str) -> FreshAgentCreate { FreshAgentCreate { request_id: request_id.to_string(), @@ -1293,6 +1789,87 @@ rl.on('line', (line) => { assert_eq!(frame["sessionId"], "unknown-session"); } + // ── cliSessionId recording (restart-parity plan §2.8 item 2) ────────────────── + + /// The stdout consumer must record `sdk.session.init`'s durable `cliSessionId` in + /// [`FreshClaudeState::cli_index`] (durable id → sessions-map key), and an explicit + /// kill must evict the index entry along with the session. + #[tokio::test] + async fn session_init_records_cli_session_id_in_the_index() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + let (st, mut rx) = state_with_bus(); + st.handle_create(dedup_create_msg("req-cli-idx-1")).await; + let created_frame = await_claude_created(&mut rx, "req-cli-idx-1").await; + let created = created_frame["sessionId"].as_str().unwrap().to_string(); + + // The fake emits sdk.session.init with the durable uuid; poll until the + // consumer has recorded it (bounded). + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); + loop { + { + let idx = st.cli_index.lock().await; + if idx.get("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") == Some(&created) { + break; + } + } + assert!( + tokio::time::Instant::now() < deadline, + "cli_index never recorded the durable id" + ); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + // The session carries the best-effort copy of the durable id. + assert_eq!( + st.sessions + .lock() + .await + .get(&created) + .unwrap() + .cli_session_id + .as_deref(), + Some("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + ); + // Kill evicts the index entry. + st.handle_kill(FreshAgentKill { + provider: freshell_protocol::AgentProvider::Claude, + session_id: created.clone(), + session_type: SessionType::Freshclaude, + cwd: None, + }) + .await; + assert!(st.cli_index.lock().await.is_empty()); + drop(env); + } + + /// Ledger A9: consumer exit (== sidecar death) must evict the dead session AND its + /// `cli_index` entries — kill/shutdown are NOT the only eviction paths. Without this, + /// a dead-but-tracked session makes the tracked⇒no-op attach row strand panes forever. + #[tokio::test] + async fn consumer_exit_evicts_dead_session_and_index() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + let (st, mut rx) = state_with_bus(); + st.handle_create(dedup_create_msg("req-evict-1")).await; + let created_frame = await_claude_created(&mut rx, "req-evict-1").await; + let created = created_frame["sessionId"].as_str().unwrap().to_string(); + // Kill the sidecar through the public API (fake exits on text "__exit__"), + // then poll (bounded) until the consumer-exit eviction clears BOTH maps. + st.handle_send(send_msg(&created, "__exit__")).await; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); + loop { + if st.sessions.lock().await.is_empty() && st.cli_index.lock().await.is_empty() { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "consumer exit must evict the dead session and its cli_index entries" + ); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + drop(env); + } + // ── freshAgent.interrupt (parity gap fix -- see terminal.rs's dispatch arm) ──── /// A missing session mirrors the `SESSION_NOT_FOUND` convention already @@ -1363,10 +1940,20 @@ rl.on('line', (line) => { } tokio::time::sleep(Duration::from_millis(20)).await; } - // Success is silent -- no `error`/other frame was broadcast for this interrupt. - assert!( - rx.try_recv().is_err(), - "a successful interrupt must not broadcast" - ); + // Success is silent -- no `error` frame was broadcast for this interrupt. The + // fake sidecar also emits `sdk.session.init`/`sdk.status` after `created` + // (Task 1); those unrelated `freshAgent.event` frames are skipped rather than + // miscounted as an interrupt response. + while let Ok(raw) = rx.try_recv() { + let frame: Value = serde_json::from_str(&raw).unwrap(); + assert_eq!( + frame["type"], "freshAgent.event", + "a successful interrupt must not broadcast: {frame}" + ); + assert_ne!( + frame["event"]["type"], "freshAgent.error", + "a successful interrupt must not broadcast an error: {frame}" + ); + } } } diff --git a/crates/freshell-freshagent/src/claude_snapshot.rs b/crates/freshell-freshagent/src/claude_snapshot.rs new file mode 100644 index 000000000..5ab7d5c25 --- /dev/null +++ b/crates/freshell-freshagent/src/claude_snapshot.rs @@ -0,0 +1,572 @@ +//! Claude fresh-agent snapshot adapter (restart-resilience plan §2.8 item 4). +//! +//! Reads the Claude CLI's own transcript store (`/projects// +//! .jsonl`) directly -- the first file-reading snapshot source in the Rust port. +//! Design choice over codex's resume-and-ask: the sidecar protocol has no history op, +//! the SDK's own `getSessionMessages` is itself just a local JSONL read with the same +//! root resolution (ledger A16), a sidecar resume burns a real SDK process per +//! snapshot GET, and the legacy Node server already proved direct-read viable +//! (`server/session-history-loader.ts` -- with real-store parsing fixes, ledger A5). +//! Store-root resolution is ORDERED CANDIDATES (`CLAUDE_CONFIG_DIR` > `CLAUDE_HOME` > +//! `$HOME/.claude`) because the real CLI honors CLAUDE_CONFIG_DIR and IGNORES +//! CLAUDE_HOME (ledger A3) -- reading a single root risks false positive denial. +//! The transcript store is also the AUTHORITY for lost-vs-alive on attach +//! ([`crate::FreshClaudeState::handle_attach`]): file present => resumable, file +//! absent in EVERY candidate root => positively gone (mirrors opencode's 404 rule; +//! honest even under claude's 30-day `cleanupPeriodDays` GC -- an expired transcript +//! is unresumable by the CLI too, ledger A4). + +use std::path::{Path, PathBuf}; + +use serde_json::{json, Map, Value}; + +/// Ordered candidate store roots. The real CLI resolves its store as +/// `CLAUDE_CONFIG_DIR ?? $HOME/.claude` and IGNORES `CLAUDE_HOME` (verified against +/// cli.js 2.1.220 -- ledger A3); `CLAUDE_HOME` is freshell's legacy knob +/// (`server/claude-home.ts`, `session_directory.rs` -- `pub(crate)` to that crate). +/// We read ALL candidates so a reader/writer root mismatch can never turn a live +/// session into a false positive denial. +pub(crate) fn claude_home_candidates() -> Vec { + let mut out: Vec = Vec::new(); + let mut push = |p: PathBuf| { + if !out.contains(&p) { + out.push(p); + } + }; + if let Ok(v) = std::env::var("CLAUDE_CONFIG_DIR") { + if !v.is_empty() { + push(PathBuf::from(v)); + } + } + if let Ok(v) = std::env::var("CLAUDE_HOME") { + if !v.is_empty() { + push(PathBuf::from(v)); + } + } + if let Ok(h) = std::env::var("HOME") { + if !h.is_empty() { + push(PathBuf::from(h).join(".claude")); + } + } + out +} + +/// `find_transcript` across every candidate root, in resolution order. +/// Positive denial (attach) and snapshot 404 both require a miss EVERYWHERE. +pub(crate) fn locate_transcript(session_id: &str) -> Option { + claude_home_candidates() + .iter() + .find_map(|root| find_transcript(root, session_id)) +} + +/// The session's ORIGINAL cwd: first non-empty `cwd` field among the transcript's +/// 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 { + use std::io::BufRead; + let file = std::fs::File::open(path).ok()?; + for line in std::io::BufReader::new(file).lines() { + let Ok(line) = line else { break }; + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + if let Some(cwd) = value.get("cwd").and_then(serde_json::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 +/// determinism (mirrors `directory_index.rs::discover_claude_home`). +pub(crate) fn find_transcript(claude_home: &Path, session_id: &str) -> Option { + if session_id.is_empty() + || session_id.contains('/') + || session_id.contains('\\') + || session_id.contains("..") + { + return None; + } + let filename = format!("{session_id}.jsonl"); + let projects = claude_home.join("projects"); + let entries = std::fs::read_dir(&projects).ok()?; + let mut dirs: Vec = entries + .flatten() + .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) + .map(|e| e.path()) + .collect(); + dirs.sort(); + for dir in &dirs { + let direct = dir.join(&filename); + if direct.is_file() { + return Some(direct); + } + let Ok(nested) = std::fs::read_dir(dir) else { + continue; + }; + let mut subdirs: Vec = nested + .flatten() + .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) + .map(|e| e.path()) + .collect(); + subdirs.sort(); + for sub in &subdirs { + let candidate = sub.join(&filename); + if candidate.is_file() { + return Some(candidate); + } + } + } + None +} + +/// Why a claude snapshot could not be served. +#[derive(Debug)] +pub(crate) enum ClaudeSnapshotError { + /// No transcript file for this id -- the store positively does not know it + /// (maps to 404 FRESH_AGENT_LOST_SESSION, the codex/opencode convention). + NotFound, + /// The file exists but could not be read; the message becomes the 500 error body. + Io(String), +} + +/// One transcript JSONL line -> zero-or-one snapshot turn. Parsing rules are the +/// legacy `extractChatMessagesFromJsonl` contract (`server/session-history-loader.ts:36-131`) +/// PLUS the real-store fixes from the ledger A5 census (23,615 real lines): keep only +/// type user|assistant; message may be a plain string, `{content: [...]}`, or +/// `{content: ""}` (the DOMINANT real prompt shape, which legacy-as-coded +/// drops); lines flagged isMeta/isSidechain/isCompactSummary/isVisibleInTranscriptOnly +/// are synthetic/subagent noise and are SKIPPED; malformed lines and unknown block +/// kinds are skipped, never fatal. +fn parse_transcript_turns(thread_id: &str, transcript: &str) -> Vec { + let mut turns: Vec = Vec::new(); + for line in transcript.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let Ok(obj) = serde_json::from_str::(line) else { + continue; + }; + let role = match obj.get("type").and_then(Value::as_str) { + Some("user") => "user", + Some("assistant") => "assistant", + _ => continue, + }; + // Real transcripts flag synthetic/subagent lines (ledger A5): skip them. + if [ + "isMeta", + "isSidechain", + "isCompactSummary", + "isVisibleInTranscriptOnly", + ] + .iter() + .any(|k| obj.get(*k).and_then(Value::as_bool) == Some(true)) + { + continue; + } + let msg = obj.get("message"); + let blocks: Vec = match msg { + Some(Value::String(text)) => vec![json!({ "type": "text", "text": text })], + Some(Value::Object(m)) => match m.get("content") { + Some(Value::Array(arr)) => arr.clone(), + Some(Value::String(text)) => vec![json!({ "type": "text", "text": text })], + _ => continue, + }, + _ => continue, + }; + + let ordinal = turns.len(); + let turn_id = format!("{thread_id}:{ordinal}"); + let mut items: Vec = Vec::new(); + for (j, block) in blocks.iter().enumerate() { + let item_id = format!("{turn_id}-i{j}"); + match block.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(text) = block.get("text").and_then(Value::as_str) { + items.push(json!({ "id": item_id, "kind": "text", "text": text })); + } + } + Some("thinking") => { + let text = block + .get("thinking") + .or_else(|| block.get("text")) + .and_then(Value::as_str) + .unwrap_or(""); + items.push(json!({ "id": item_id, "kind": "thinking", "text": text })); + } + Some("tool_use") => { + let tool_use_id = block + .get("id") + .and_then(Value::as_str) + .unwrap_or(item_id.as_str()) + .to_string(); + let name = block.get("name").and_then(Value::as_str).unwrap_or("tool"); + let mut item = Map::new(); + item.insert("id".into(), json!(item_id)); + item.insert("kind".into(), json!("tool_use")); + item.insert("toolUseId".into(), json!(tool_use_id)); + item.insert("name".into(), json!(name)); + if let Some(input) = block.get("input") { + item.insert("input".into(), input.clone()); + } + items.push(Value::Object(item)); + } + Some("tool_result") => { + let tool_use_id = block + .get("tool_use_id") + .and_then(Value::as_str) + .unwrap_or(item_id.as_str()) + .to_string(); + let is_error = block + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false); + items.push(json!({ + "id": item_id, + "kind": "tool_result", + "toolUseId": tool_use_id, + "content": tool_result_text(block), + "isError": is_error, + })); + } + _ => {} + } + } + if items.is_empty() { + continue; + } + + let summary = summarize(&items); + let mut turn = Map::new(); + turn.insert("id".into(), json!(turn_id)); + turn.insert("turnId".into(), json!(turn_id)); + if let Some(message_id) = msg + .and_then(|m| m.get("id")) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + turn.insert("messageId".into(), json!(message_id)); + } + turn.insert("ordinal".into(), json!(ordinal)); + turn.insert("source".into(), json!("durable")); + turn.insert("role".into(), json!(role)); + if let Some(ts) = obj.get("timestamp").and_then(Value::as_str) { + turn.insert("timestamp".into(), json!(ts)); + } + if let Some(model) = msg + .and_then(|m| m.get("model")) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + turn.insert("model".into(), json!(model)); + } + turn.insert("summary".into(), json!(summary)); + turn.insert("items".into(), json!(items)); + turns.push(Value::Object(turn)); + } + turns +} + +/// Flatten a tool_result block's content (string, or array of text blocks) to a string. +fn tool_result_text(block: &Value) -> String { + match block.get("content") { + Some(Value::String(s)) => s.clone(), + Some(Value::Array(parts)) => parts + .iter() + .filter_map(|p| p.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +/// Turn summary: first non-empty `text` item's text, falling back to the first +/// non-empty `thinking` item's text (char-safe truncate), else a tool label -- +/// `FreshAgentTurnSchema.summary` is REQUIRED. Text is preferred over thinking +/// so an assistant turn's summary is its visible answer, not its reasoning +/// preamble (golden fixture turn 1: items `[thinking "pondering", text "first +/// answer"]` must summarize to `"first answer"`). +fn summarize(items: &[Value]) -> String { + let first_text_of = |kind: &str| -> Option { + items.iter().find_map(|item| { + if item.get("kind").and_then(Value::as_str) != Some(kind) { + return None; + } + let trimmed = item.get("text").and_then(Value::as_str)?.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.chars().take(120).collect()) + } + }) + }; + if let Some(summary) = first_text_of("text").or_else(|| first_text_of("thinking")) { + return summary; + } + for item in items { + match item.get("kind").and_then(Value::as_str) { + Some("tool_use") => { + if let Some(name) = item.get("name").and_then(Value::as_str) { + return name.to_string(); + } + } + Some("tool_result") => return "[tool result]".to_string(), + _ => {} + } + } + "[claude turn]".to_string() +} + +/// Build the `FreshAgentSnapshotSchema`-exact JSON (`shared/fresh-agent-contract.ts:230-246`, +/// zod `.strict()` -- every key here is either required or schema-known; NOTHING extra). +pub(crate) fn build_claude_snapshot_json( + session_type: &str, + thread_id: &str, + transcript: &str, + revision: i64, +) -> Value { + let turns = parse_transcript_turns(thread_id, transcript); + let latest_turn_id = turns + .last() + .and_then(|t| t.get("turnId")) + .cloned() + .unwrap_or(Value::Null); + json!({ + "sessionType": session_type, + "provider": "claude", + "threadId": thread_id, + "sessionId": thread_id, + "revision": revision.max(0), + "latestTurnId": latest_turn_id, + // Deliberate divergence from codex (which serves live status from session + // state): this adapter is disk-only and always reports "idle" -- live status + // is authoritative via the WS status events, so the client ignores this on + // live sessions. + "status": "idle", + "capabilities": { + "send": true, + "interrupt": true, + "approvals": false, + "questions": false, + "fork": false, + }, + "tokenUsage": { "inputTokens": 0, "outputTokens": 0, "totalTokens": 0 }, + "pendingApprovals": [], + "pendingQuestions": [], + "worktrees": [], + "diffs": [], + "childThreads": [], + "turns": turns, + "extensions": {}, + }) +} + +/// Locate + read + build. `revision` = transcript mtime in ms (monotonic as the file +/// grows -- `mergeSnapshotForDisplay` DROPS revision regressions), fallback turn count. +pub(crate) async fn get_claude_snapshot( + session_type: &str, + thread_id: &str, +) -> Result { + // Cannot check => must not deny (the attach arm in claude.rs treats this exact + // state as Transient): with NO resolvable store root we cannot assert the + // session is gone, so this is Io (-> 500), never NotFound (-> 404 lost). + if claude_home_candidates().is_empty() { + return Err(ClaudeSnapshotError::Io( + "no claude store root resolvable (CLAUDE_CONFIG_DIR/CLAUDE_HOME/HOME all unset)".into(), + )); + } + // Miss in EVERY candidate root => 404 (positive denial; ledger A3/A4). + let path = locate_transcript(thread_id).ok_or(ClaudeSnapshotError::NotFound)?; + let mtime_ms = std::fs::metadata(&path) + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64); + let content = tokio::fs::read_to_string(&path) + .await + .map_err(|e| ClaudeSnapshotError::Io(e.to_string()))?; + let mut snapshot = build_claude_snapshot_json(session_type, thread_id, &content, 0); + let turn_count = snapshot["turns"] + .as_array() + .map(|a| a.len() as i64) + .unwrap_or(0); + snapshot["revision"] = json!(mtime_ms.unwrap_or(turn_count).max(0)); + Ok(snapshot) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_home() -> tempfile::TempDir { + tempfile::tempdir().expect("tempdir") + } + + #[test] + fn find_transcript_locates_a_direct_project_file() { + let home = temp_home(); + let dir = home.path().join("projects").join("-home-user-proj"); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("11111111-1111-4111-8111-111111111111.jsonl"); + std::fs::write(&file, "{}\n").unwrap(); + assert_eq!( + find_transcript(home.path(), "11111111-1111-4111-8111-111111111111"), + Some(file) + ); + } + + #[test] + fn find_transcript_locates_a_one_level_nested_file() { + let home = temp_home(); + let dir = home.path().join("projects").join("-p").join("sessions"); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("22222222-2222-4222-8222-222222222222.jsonl"); + std::fs::write(&file, "{}\n").unwrap(); + assert_eq!( + find_transcript(home.path(), "22222222-2222-4222-8222-222222222222"), + Some(file) + ); + } + + #[test] + fn find_transcript_misses_cleanly_and_rejects_traversal() { + let home = temp_home(); + std::fs::create_dir_all(home.path().join("projects")).unwrap(); + assert_eq!( + find_transcript(home.path(), "33333333-3333-4333-8333-333333333333"), + None + ); + assert_eq!(find_transcript(home.path(), "../etc/passwd"), None); + assert_eq!(find_transcript(home.path(), "a/b"), None); + assert_eq!(find_transcript(home.path(), ""), None); + } + + #[test] + fn transcript_cwd_reads_the_first_cwd_field() { + let home = temp_home(); + let file = home.path().join("t.jsonl"); + std::fs::write( + &file, + "{\"type\":\"summary\"}\n{\"type\":\"user\",\"cwd\":\"/home/user/proj\",\"message\":\"hi\"}\n", + ) + .unwrap(); + assert_eq!(transcript_cwd(&file), Some("/home/user/proj".to_string())); + let empty = home.path().join("e.jsonl"); + std::fs::write(&empty, "").unwrap(); + assert_eq!(transcript_cwd(&empty), None); + } + + const SAMPLE_TRANSCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../test/fixtures/fresh-agent/claude-transcript-sample.jsonl" + )); + const GOLDEN_SNAPSHOT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../test/fixtures/fresh-agent/claude-snapshot-golden.json" + )); + + #[test] + fn builder_output_matches_the_golden_snapshot_fixture() { + let built = build_claude_snapshot_json( + "freshclaude", + "44444444-4444-4444-8444-444444444444", + SAMPLE_TRANSCRIPT, + 1753437600000, + ); + let golden: serde_json::Value = + serde_json::from_str(GOLDEN_SNAPSHOT).expect("golden parses"); + assert_eq!(built, golden); + } + + #[test] + fn user_turns_carry_role_user_and_literal_prompt_text() { + // Load-bearing for the frozen client's local-echo clearing: claude's + // send.accepted has no submittedTurnId, so the client matches prompt text + // against role:'user' turns (freshAgentSlice fold). + let built = build_claude_snapshot_json("freshclaude", "t", SAMPLE_TRANSCRIPT, 0); + let turns = built["turns"].as_array().unwrap(); + let first = &turns[0]; + assert_eq!(first["role"], "user"); + assert_eq!(first["items"][0]["kind"], "text"); + assert_eq!(first["items"][0]["text"], "first question"); + } + + #[test] + fn turn_ids_are_unique_and_ordering_is_transcript_order() { + let built = build_claude_snapshot_json("kilroy", "t", SAMPLE_TRANSCRIPT, 0); + assert_eq!(built["sessionType"], "kilroy"); + let turns = built["turns"].as_array().unwrap(); + let mut ids: Vec<&str> = turns + .iter() + .map(|t| t["turnId"].as_str().unwrap()) + .collect(); + let before = ids.len(); + ids.sort(); + ids.dedup(); + assert_eq!( + ids.len(), + before, + "turnIds must be unique (historyBodies map key)" + ); + assert_eq!(turns.len(), 6); // summary + malformed + isMeta lines skipped + assert_eq!(built["latestTurnId"], turns[5]["turnId"]); + // The dominant real prompt shape (object-with-string-content, ledger A5) + // must yield a text turn -- local-echo clearing depends on it. + assert_eq!(turns[3]["items"][0]["text"], "cli string content question"); + } + + /// Saves the named env vars on construction and restores them on drop (so the + /// restore also runs on panic while the caller still holds `CLAUDE_ENV_LOCK` -- + /// locals drop in reverse declaration order, lock guard last). + struct EnvVarsRestore { + saved: Vec<(&'static str, Option)>, + } + impl EnvVarsRestore { + fn remove_all(keys: &[&'static str]) -> Self { + let saved = keys + .iter() + .map(|k| { + let v = std::env::var(k).ok(); + std::env::remove_var(k); + (*k, v) + }) + .collect(); + Self { saved } + } + } + impl Drop for EnvVarsRestore { + fn drop(&mut self) { + for (k, v) in &self.saved { + match v { + Some(v) => std::env::set_var(k, v), + None => std::env::remove_var(k), + } + } + } + } + + #[tokio::test] + async fn snapshot_with_no_resolvable_store_root_is_io_not_notfound() { + // Cannot check => must not deny: with every store-root env var unset the + // server cannot assert the session is gone, so the error must be Io (-> 500), + // never NotFound (-> 404 FRESH_AGENT_LOST_SESSION). Env vars are + // process-global -- serialize under the shared claude env lock. + let _guard = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; + let _restore = EnvVarsRestore::remove_all(&["CLAUDE_CONFIG_DIR", "CLAUDE_HOME", "HOME"]); + assert!(claude_home_candidates().is_empty()); + let result = + get_claude_snapshot("freshclaude", "55555555-5555-4555-8555-555555555555").await; + match result { + Err(ClaudeSnapshotError::Io(msg)) => { + assert!(msg.contains("no claude store root resolvable"), "{msg}"); + } + other => panic!("expected Io (cannot-check must not deny), got {other:?}"), + } + } +} diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index 9c009f2b3..a0c1a406a 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -37,6 +37,7 @@ //! backstop, by the harness sentinel sweep — no orphans. pub mod claude; +pub(crate) mod claude_snapshot; pub mod codex; pub mod opencode_ws; pub mod pane_ops; diff --git a/crates/freshell-freshagent/src/pane_ops.rs b/crates/freshell-freshagent/src/pane_ops.rs index 6f975b973..5a329bcb1 100644 --- a/crates/freshell-freshagent/src/pane_ops.rs +++ b/crates/freshell-freshagent/src/pane_ops.rs @@ -1137,6 +1137,10 @@ mod tests { msg["payload"]["newContent"]["terminalId"], json!(new_terminal_id) ); + let crid = msg["payload"]["newContent"]["createRequestId"] + .as_str() + .expect("split newContent.createRequestId missing"); + assert_eq!(crid.len(), 32); state .terminal_registry @@ -1763,6 +1767,16 @@ mod tests { msg["payload"]["content"]["terminalId"], json!(new_terminal_id) ); + // Task 3: respawn ROTATES the pane key — the broadcast content carries + // a fresh server-minted 32-hex createRequestId. Intentional legacy + // parity (router.ts:1602 mints per respawn) and required so + // reconcile's newest_live_by_create_request_id resolves the pane to + // the REPLACEMENT terminal, not the detached old one. + let crid = msg["payload"]["content"]["createRequestId"] + .as_str() + .expect("respawn content.createRequestId missing"); + assert_eq!(crid.len(), 32, "expected Uuid::simple format, got {crid:?}"); + assert!(crid.chars().all(|c| c.is_ascii_hexdigit())); // Bookkeeping now points the SAME pane id at the NEW terminal -- // "replace in place", not a second pane. @@ -1784,6 +1798,15 @@ mod tests { assert!(registry.is_running(&old_terminal_id)); assert!(registry.is_running(&new_terminal_id)); + // The NEW terminal's registry row was stamped with the SAME key + // (atomic insert) — the old terminal keeps its own lineage. + assert_eq!( + registry + .probe_create_request_id(&new_terminal_id) + .as_deref(), + Some(crid), + ); + registry.kill(&old_terminal_id); registry.kill(&new_terminal_id); } diff --git a/crates/freshell-freshagent/src/snapshot.rs b/crates/freshell-freshagent/src/snapshot.rs index 0b97a3adf..e41f733c0 100644 --- a/crates/freshell-freshagent/src/snapshot.rs +++ b/crates/freshell-freshagent/src/snapshot.rs @@ -26,13 +26,14 @@ //! //! ## Scope //! -//! `sessionType`/`provider` combinations outside `{freshcodex/codex, freshopencode/opencode}` -//! but within the shared locator's valid enum (`freshclaude/claude`, `kilroy/claude`) mirror -//! the reference's `FreshAgentRuntimeUnavailableError` (`runtime-manager.ts:25-27,338-341`) — -//! a 503 with `code:'FRESH_AGENT_RUNTIME_UNAVAILABLE'` — since this port has no adapter -//! registered for them (`server/fresh-agent/provider-registry.ts` equivalent doesn't exist -//! here yet). An outright invalid enum member (e.g. `sessionType=bogus`) is a 400, mirroring -//! the reference's `ThreadParamsSchema.safeParse` failure (`router.ts:181-186`). +//! All three providers are served: **freshcodex/codex** and **freshopencode/opencode** ask +//! their live runtime slices, while **freshclaude/claude** and **kilroy/claude** are a +//! disk+env adapter ([`crate::claude_snapshot::get_claude_snapshot`]) that reads the CLI's +//! own transcript store directly (`/projects/*/.jsonl`) — no sidecar +//! required, so snapshots survive a server restart. A missing transcript is a positive +//! denial: 404 with `code:'FRESH_AGENT_LOST_SESSION'` (the codex/opencode convention); a +//! read failure is a 500. An outright invalid enum member (e.g. `sessionType=bogus`) is a +//! 400, mirroring the reference's `ThreadParamsSchema.safeParse` failure (`router.ts:181-186`). use std::collections::HashMap; use std::sync::Arc; @@ -129,14 +130,28 @@ async fn get_snapshot( } } } + ("freshclaude", "claude") | ("kilroy", "claude") => { + match crate::claude_snapshot::get_claude_snapshot(&session_type, &thread_id).await { + Ok(snapshot) => Json(snapshot).into_response(), + Err(crate::claude_snapshot::ClaudeSnapshotError::NotFound) => fail_with_code( + StatusCode::NOT_FOUND, + format!("claude session {thread_id} not found"), + "FRESH_AGENT_LOST_SESSION", + ), + Err(crate::claude_snapshot::ClaudeSnapshotError::Io(err)) => { + fail(StatusCode::INTERNAL_SERVER_ERROR, err) + } + } + } (session_type_value, provider_value) => { if !VALID_SESSION_TYPES.contains(&session_type_value) || !VALID_PROVIDERS.contains(&provider_value) { return fail(StatusCode::BAD_REQUEST, "Invalid request".to_string()); } - // A structurally valid locator this port has no adapter registered for - // (freshclaude/claude, kilroy/claude) -- mirrors `FreshAgentRuntimeUnavailableError`. + // Every structurally valid locator now has an adapter registered above; this + // 503 arm is retained purely as a safety net for future enum growth -- + // mirrors `FreshAgentRuntimeUnavailableError` (`runtime-manager.ts:25-27`). fail_with_code( StatusCode::SERVICE_UNAVAILABLE, format!("No fresh-agent snapshot adapter registered for {session_type_value}"), @@ -250,25 +265,86 @@ mod tests { assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } - #[tokio::test] - async fn valid_but_unregistered_locator_is_503_with_code() { + // NOTE: `valid_but_unregistered_locator_is_503_with_code` used to live here. With + // the claude adapter registered, NO structurally-valid locator is unregistered + // anymore -- the handler's catch-all 503 arm is retained purely as a safety net for + // future enum growth. The two claude tests below plus the existing 400 + // invalid-locator tests now cover the whole routing table. + + // Env vars are process-global and cargo test is multi-threaded: EVERY test that + // mutates claude-store env (this file AND claude.rs) must take the SAME lock -- + // two independent per-file locks would NOT serialize against each other. claude.rs's + // `CLAUDE_ENV_LOCK` is `pub(crate)` for exactly this (mirroring how this file + // already reuses `crate::codex::tests::ENV_LOCK`). + use crate::claude::tests::CLAUDE_ENV_LOCK; + + /// The authorized-GET construction every test in this file uses, returning + /// `(status, parsed JSON body)`. + async fn get_json( + session_type: &str, + provider: &str, + thread_id: &str, + ) -> (StatusCode, serde_json::Value) { let resp = get_snapshot( State(snapshot_state()), Path(( - "freshclaude".to_string(), - "claude".to_string(), - "thread-1".to_string(), + session_type.to_string(), + provider.to_string(), + thread_id.to_string(), )), Query(HashMap::new()), headers_with_token("tok"), ) .await; - assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + let status = resp.status(); let body = axum::body::to_bytes(resp.into_body(), usize::MAX) .await .unwrap(); let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(value["code"], json!("FRESH_AGENT_RUNTIME_UNAVAILABLE")); + (status, value) + } + + #[tokio::test] + async fn claude_locator_serves_a_snapshot_from_the_transcript_store() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let home = tempfile::tempdir().unwrap(); + let dir = home.path().join("projects").join("-e2e"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("55555555-5555-4555-8555-555555555555.jsonl"), + r#"{"type":"user","timestamp":"2026-07-25T10:00:00.000Z","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}"#, + ) + .unwrap(); + // CLAUDE_CONFIG_DIR is the FIRST candidate root (what the real CLI honors) -- + // setting it makes the test immune to ambient CLAUDE_HOME/HOME. + std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); + + let (status, body) = get_json( + "freshclaude", + "claude", + "55555555-5555-4555-8555-555555555555", + ) + .await; + std::env::remove_var("CLAUDE_CONFIG_DIR"); + assert_eq!(status, StatusCode::OK); + assert_eq!(body["sessionType"], "freshclaude"); + assert_eq!(body["provider"], "claude"); + assert_eq!(body["turns"][0]["role"], "user"); + assert_eq!(body["turns"][0]["items"][0]["text"], "hello"); + assert!(body["revision"].as_i64().unwrap() >= 0); + } + + #[tokio::test] + async fn claude_locator_with_unknown_session_id_is_404_with_lost_session_code() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let home = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(home.path().join("projects")).unwrap(); + std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); + let (status, body) = + get_json("kilroy", "claude", "66666666-6666-4666-8666-666666666666").await; + std::env::remove_var("CLAUDE_CONFIG_DIR"); + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(body["code"], "FRESH_AGENT_LOST_SESSION"); } #[tokio::test] diff --git a/crates/freshell-freshagent/src/terminal_tabs.rs b/crates/freshell-freshagent/src/terminal_tabs.rs index a693ff4ae..d96bd09f8 100644 --- a/crates/freshell-freshagent/src/terminal_tabs.rs +++ b/crates/freshell-freshagent/src/terminal_tabs.rs @@ -609,6 +609,18 @@ pub(crate) async fn spawn_terminal_pane( .map(str::to_string); let cwd = body.get("cwd").and_then(Value::as_str).map(str::to_string); + // Stable pane identity key (reconciliation-handshake design §5.5, + // precondition 2): honor a caller-supplied key (snapshot restore passes + // the captured one through `pane_to_create_body`), else mint one so every + // REST-created terminal pane is keyed. Same Uuid::simple idiom as the + // fresh-agent path (lib.rs `create_tab`). + let create_request_id = body + .get("createRequestId") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| Uuid::new_v4().simple().to_string()); + // Validate `cwd` up front: a nonexistent directory would otherwise fail // INSIDE the spawned child (post-fork), which a synchronous `registry.create` // call cannot observe -- checking here keeps the atomic-rollback contract @@ -870,10 +882,8 @@ pub(crate) async fn spawn_terminal_pane( stream_id, &mode, resume_session_id.as_deref(), - // REST ingress mints no createRequestId (reconciliation design §5.5 - // precondition 2 — booked for the Phase-3 adoption change). - None, - None, + Some(create_request_id.as_str()), // create_request_id: REST accept-or-mint key (this task) + None, // ring_max_bytes: registry default on_exit, ) { // Nothing was recorded yet (no tab, no pane, no map entry) -> rollback @@ -947,6 +957,7 @@ pub(crate) async fn spawn_terminal_pane( let mut pane_content = json!({ "kind": "terminal", "terminalId": terminal_id, + "createRequestId": create_request_id, "status": "running", "mode": mode, "shell": shell_str.clone().unwrap_or_else(|| "system".to_string()), @@ -1659,6 +1670,99 @@ mod tests { assert_eq!(payload["paneContent"]["status"], json!("running")); } + #[tokio::test] + async fn rest_create_terminal_tab_mints_and_stamps_create_request_id() { + let state = state_with_registry(); + let mut rx = state.broadcast_tx.subscribe(); + let router = app(state.clone()); + + let tmp = std::env::temp_dir(); + let (status, body) = post( + router, + "/api/tabs", + json!({ "mode": "shell", "cwd": tmp.to_string_lossy() }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "create failed: {body}"); + + // Drain the broadcast bus for the ui.command{tab.create} frame. + let mut pane_content = None; + while let Ok(frame) = rx.try_recv() { + let msg: Value = serde_json::from_str(&frame).unwrap(); + if msg["command"] == json!("tab.create") { + pane_content = msg + .get("payload") + .and_then(|p| p.get("paneContent")) + .cloned(); + } + } + let pane_content = pane_content.expect("no tab.create broadcast"); + let crid = pane_content + .get("createRequestId") + .and_then(Value::as_str) + .expect("paneContent.createRequestId missing"); + assert_eq!(crid.len(), 32, "expected Uuid::simple format, got {crid:?}"); + assert!(crid.chars().all(|c| c.is_ascii_hexdigit())); + + // The registry row was stamped with the SAME key (atomic insert). + let terminal_id = pane_content + .get("terminalId") + .and_then(Value::as_str) + .expect("paneContent.terminalId missing"); + let registry = state.terminal_registry.clone().expect("registry wired"); + assert_eq!( + registry.probe_create_request_id(terminal_id).as_deref(), + Some(crid), + ); + } + + #[tokio::test] + async fn rest_create_honors_caller_supplied_create_request_id() { + let state = state_with_registry(); + let mut rx = state.broadcast_tx.subscribe(); + let router = app(state.clone()); + + let tmp = std::env::temp_dir(); + let (status, body) = post( + router, + "/api/tabs", + json!({ + "mode": "shell", + "cwd": tmp.to_string_lossy(), + "createRequestId": "crid-fixed-key", + }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "create failed: {body}"); + + let mut pane_content = None; + while let Ok(frame) = rx.try_recv() { + let msg: Value = serde_json::from_str(&frame).unwrap(); + if msg["command"] == json!("tab.create") { + pane_content = msg + .get("payload") + .and_then(|p| p.get("paneContent")) + .cloned(); + } + } + let pane_content = pane_content.expect("no tab.create broadcast"); + assert_eq!( + pane_content.get("createRequestId").and_then(Value::as_str), + Some("crid-fixed-key"), + ); + let terminal_id = pane_content + .get("terminalId") + .and_then(Value::as_str) + .expect("paneContent.terminalId missing"); + let registry = state.terminal_registry.clone().expect("registry wired"); + assert_eq!( + registry.probe_create_request_id(terminal_id).as_deref(), + Some("crid-fixed-key"), + ); + } + #[tokio::test] async fn create_tab_passes_codex_durability_through_and_records_restore_key() { // Continuity trio (`tabs_snapshots.rs:245`/`:632`): a restore-driven diff --git a/crates/freshell-protocol/src/server_messages.rs b/crates/freshell-protocol/src/server_messages.rs index 912cd07ac..e16b8776c 100644 --- a/crates/freshell-protocol/src/server_messages.rs +++ b/crates/freshell-protocol/src/server_messages.rs @@ -44,6 +44,11 @@ pub enum ServerMessage { CodingCliStderr(CodingCliStderr), #[serde(rename = "config.fallback")] ConfigFallback(ConfigFallback), + // Extension surface (P1.8 pane-identity ledger, not in the frozen T0 + // inventory): live per-pane durability warning. See + // `EXTENSION_SERVER_MESSAGE_TYPES`. + #[serde(rename = "durability.degraded")] + DurabilityDegraded(DurabilityDegraded), #[serde(rename = "error")] Error(ErrorMsg), #[serde(rename = "extension.server.error")] @@ -202,9 +207,15 @@ pub const SERVER_MESSAGE_TYPES: [&str; 53] = [ /// (TERM-15/TERM-16 follow-on). Kept out of [`SERVER_MESSAGE_TYPES`] so /// `tests/inventory.rs` keeps pinning the frozen contract untouched; the /// extension shapes are pinned by `tests/activity_extension.rs`. -pub const EXTENSION_SERVER_MESSAGE_TYPES: [&str; 3] = [ +pub const EXTENSION_SERVER_MESSAGE_TYPES: [&str; 4] = [ "amplifier.activity.list.response", "amplifier.activity.updated", + // P1.8 pane-identity ledger: live per-pane durability warning. NOT the + // same family as the frozen `terminal.codex.durability.updated` + // (`SERVER_MESSAGE_TYPES`), which is codex-sidecar durability; this frame + // is intentionally general pane-durability — the name collision is + // nearest-neighbor only, not overlap. + "durability.degraded", "terminal.idle", ]; @@ -808,6 +819,16 @@ pub struct TabsSyncAck { pub accepted: bool, pub closed_records: i64, pub open_records: i64, + /// `false` when the accepted push was NOT durably persisted (fail-loud + /// honesty, campaign P2.17). Omitted when persisted normally or when + /// persistence was skipped by design (empty push, persistence disabled), + /// keeping pre-change acks byte-identical on the wire. + #[serde(skip_serializing_if = "Option::is_none")] + pub persisted: Option, + /// Machine-readable reason accompanying `persisted:false` + /// (e.g. "oversize"). Serializes as `persistReason`. + #[serde(skip_serializing_if = "Option::is_none")] + pub persist_reason: Option, } // --- tabs.sync.snapshot ----------------------------------------------------- @@ -892,6 +913,20 @@ pub struct TerminalCodexDurabilityUpdated { pub terminal_id: String, } +/// P1.8 write-failure policy (spec §4.2): pushed LIVE at ledger-write +/// failure time so the warning arrives BEFORE the restart it warns about — +/// never a posthumous verdict flag. Frozen clients ignore unknown frame +/// types; rendering lands with the Phase 3 client adoption lane. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DurabilityDegraded { + pub terminal_id: String, + /// Machine-readable, e.g. "ledger_write_failed". + pub reason: String, + /// Human-readable pane warning. + pub message: String, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TerminalRestoreError { diff --git a/crates/freshell-protocol/tests/activity_extension.rs b/crates/freshell-protocol/tests/activity_extension.rs index 21850e48c..625c52c87 100644 --- a/crates/freshell-protocol/tests/activity_extension.rs +++ b/crates/freshell-protocol/tests/activity_extension.rs @@ -140,6 +140,7 @@ fn extension_surface_is_disjoint_from_the_frozen_inventory() { [ "amplifier.activity.list.response", "amplifier.activity.updated", + "durability.degraded", "terminal.idle", ] ); diff --git a/crates/freshell-protocol/tests/roundtrip.rs b/crates/freshell-protocol/tests/roundtrip.rs index 86c25cde4..179ee3109 100644 --- a/crates/freshell-protocol/tests/roundtrip.rs +++ b/crates/freshell-protocol/tests/roundtrip.rs @@ -427,3 +427,25 @@ fn accept_and_strip_ignores_unknown_fields() { other => panic!("expected PerfLogging, got {other:?}"), } } + +#[test] +fn tabs_sync_ack_roundtrips_with_and_without_persist_fields() { + // Success shape: fields omitted — byte-identical to today's ack on the wire. + let base = r#"{"type":"tabs.sync.ack","accepted":true,"closedRecords":0,"openRecords":3}"#; + match server_roundtrip(base, "tabs.sync.ack") { + ServerMessage::TabsSyncAck(ack) => { + assert_eq!(ack.persisted, None); + assert_eq!(ack.persist_reason, None); + } + other => panic!("expected TabsSyncAck, got {other:?}"), + } + // The honest-failure shape must conform to the frozen contract too. + let failed = r#"{"type":"tabs.sync.ack","accepted":true,"closedRecords":0,"openRecords":3,"persisted":false,"persistReason":"oversize"}"#; + match server_roundtrip(failed, "tabs.sync.ack") { + ServerMessage::TabsSyncAck(ack) => { + assert_eq!(ack.persisted, Some(false)); + assert_eq!(ack.persist_reason.as_deref(), Some("oversize")); + } + other => panic!("expected TabsSyncAck, got {other:?}"), + } +} diff --git a/crates/freshell-server/src/existence.rs b/crates/freshell-server/src/existence.rs index f93321c29..4494657eb 100644 --- a/crates/freshell-server/src/existence.rs +++ b/crates/freshell-server/src/existence.rs @@ -29,13 +29,21 @@ pub struct IndexExistenceProbe { index: Arc, /// `provider:sessionId` keys ever seen in ANY snapshot this boot. observed: Mutex>, + /// P1.8 (spec §4.2 read 2): the durable "ever bound by this server" + /// memory — survives restarts, so a transcript deleted while the server + /// was down yields loud dead_session, not silent fresh. + ledger: Option>, } impl IndexExistenceProbe { - pub fn new(index: Arc) -> Self { + pub fn new( + index: Arc, + ledger: Option>, + ) -> Self { Self { index, observed: Mutex::new(HashSet::new()), + ledger, } } @@ -86,10 +94,17 @@ impl SessionExistenceProbe for IndexExistenceProbe { } fn ever_observed(&self, provider: &str, session_id: &str) -> bool { - self.observed + if self + .observed .lock() .expect("observed set lock") .contains(&format!("{provider}:{session_id}")) + { + return true; + } + self.ledger + .as_ref() + .is_some_and(|ledger| ledger.ever_bound(provider, session_id)) } } @@ -137,7 +152,72 @@ mod tests { Duration::from_millis(50), None, // no persistent parse-cache — fully isolated temp home )); - (IndexExistenceProbe::new(Arc::clone(&index)), index) + (IndexExistenceProbe::new(Arc::clone(&index), None), index) + } + + /// Construct a probe exactly as `main.rs` does — over an index whose + /// provider home is an EMPTY temp dir (the transcript is gone) — with the + /// given ledger handle. The home leaks intentionally: it's a per-test + /// unique temp path and the OS temp cleaner owns it. + fn new_test_probe_with_ledger( + ledger: Option>, + ) -> IndexExistenceProbe { + let home = temp_claude_home("with-ledger"); + let index = Arc::new(SessionIndex::with_ttl_and_cache_path( + vec![Arc::new(ClaudeSource::new(home)) as Arc], + Duration::from_millis(50), + None, + )); + IndexExistenceProbe::new(index, ledger) + } + + #[test] + fn ever_observed_survives_a_restart_via_the_ledger() { + // Spec §4.2 read 2: a transcript deleted while the server was DOWN + // must yield loud dead_session, not silent fresh. The per-boot + // observed set is empty after a restart — the ledger is the durable + // memory. (The Absent+ever_observed => dead_session derivation is + // already pinned by reconcile.rs's + // `row4_absent_but_ever_observed_yields_dead_session`; this test + // covers the INPUT seam.) + let dir = std::env::temp_dir().join(format!( + "ledger-everobs-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + let ledger = + std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some(dir.clone()))); + // "Generation 1" bound this identity durably. + ledger + .record_binding(&freshell_ws::pane_ledger::BindingWrite { + provider: "claude", + session_id: "11111111-2222-3333-4444-555555555555", + terminal_id: "t1", + mode: "claude", + cwd: None, + create_request_id: None, + now_ms: 1_000, + }) + .unwrap(); + + // "Generation 2": a brand-new probe with an EMPTY observed set — + // construct it exactly as main.rs does, over an index whose + // provider home is an empty temp dir (the transcript is gone). + let probe = new_test_probe_with_ledger(Some(std::sync::Arc::clone(&ledger))); + assert!( + probe.ever_observed("claude", "11111111-2222-3333-4444-555555555555"), + "durable ledger memory answers across restarts" + ); + assert!(!probe.ever_observed("claude", "99999999-2222-3333-4444-555555555555")); + + // Without a ledger, the old per-boot behavior is preserved. + let bare = new_test_probe_with_ledger(None); + assert!(!bare.ever_observed("claude", "11111111-2222-3333-4444-555555555555")); + std::fs::remove_dir_all(&dir).ok(); } #[test] diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index 269f86f54..3457cffc7 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -417,6 +417,16 @@ async fn main() -> ExitCode { // Resolved ONCE so the rate-limit knobs and the gate the handlers consult // are guaranteed to come from the same env snapshot. let create_protect = freshell_ws::create_limit::CreateProtectConfig::from_env(); + // P1.8: the pane-identity ledger (spec §4.2). Root resolved ONCE here; + // the module itself never reads env vars. No home => disabled no-op, + // same policy as tabs-snapshots. `new_locked` = the single-writer + // guard (V2.md): exclusive flock on /lock, ConfigLock pattern — + // a second server on the same home comes up with a DISABLED ledger and + // a loud ERROR instead of two writers corrupting one store. + let pane_ledger = std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::new_locked( + home.as_ref() + .map(|h| h.join(".freshell").join("pane-ledger")), + )); let ws_state = WsState { activity: Some(activity_hub.clone()), identity: terminal_identity.clone(), @@ -430,6 +440,10 @@ async fn main() -> ExitCode { session_existence: match &session_index { Some(index) => std::sync::Arc::new(existence::IndexExistenceProbe::new( std::sync::Arc::clone(index), + // P1.8 read 2: the durable ledger backs `ever_observed`, so a + // transcript deleted while the server was DOWN still derives + // loud dead_session (per-boot observed set is empty then). + Some(std::sync::Arc::clone(&pane_ledger)), )), None => std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), }, @@ -459,7 +473,71 @@ async fn main() -> ExitCode { spawn_gate: std::sync::Arc::new(freshell_ws::spawn_gate::SpawnGate::from_config( &create_protect, )), + pane_ledger: std::sync::Arc::clone(&pane_ledger), }; + + // P1.8 boot hygiene: quarantine, stale-marker sweep, supersession + // repair, GC. Tombstone deletion keys on the DIRECT stat + // (`transcript_definitively_absent`) — never on probe.exists()==Absent + // (V10.md). Runs BEFORE the server accepts connections, so calling the + // blocking ledger API inline here is fine. + { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + // `home` = the same Option resolve_home() output the ledger + // root was derived from. No home => the ledger is disabled and the + // closure is never consulted; answering false (defer) is still safe. + let scan_home = home.clone(); + let report = pane_ledger.boot_scan(now, &move |provider, session_id| { + scan_home + .as_deref() + .is_some_and(|h| transcript_definitively_absent(h, provider, session_id)) + }); + if !report.quarantined.is_empty() { + tracing::error!( + count = report.quarantined.len(), + "pane_ledger_boot: rows quarantined (see per-row errors above)" + ); + } + } + + // P1.8 periodic GC (boot-time + periodic, spec §4.2 lifecycle). + { + let ledger = std::sync::Arc::clone(&pane_ledger); + let gc_home = home.clone(); // same Option as above + tokio::spawn(async move { + let mut ticker = tokio::time::interval(std::time::Duration::from_secs(6 * 60 * 60)); + ticker.tick().await; // the immediate first tick — boot_scan already ran + loop { + ticker.tick().await; + let ledger = std::sync::Arc::clone(&ledger); + let home = gc_home.clone(); + let joined = tokio::task::spawn_blocking(move || { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + // Same Option handling as the boot-scan closure above: + // no home => defer (false) — never the destructive branch. + ledger.gc(now, &|provider, session_id| { + home.as_deref().is_some_and(|h| { + transcript_definitively_absent(h, provider, session_id) + }) + }); + }) + .await; + if let Err(e) = joined { + tracing::error!( + error = %e, + "pane_ledger_gc_join_failed: periodic GC task panicked or was cancelled" + ); + } + } + }); + } + let api_state = ApiState { auth_token: Arc::clone(&auth_token), ready: true, @@ -1054,6 +1132,104 @@ fn resolve_home() -> Option { .map(PathBuf::from) } +/// 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 +/// source (claude discover: directory_index.rs:206; codex walk: :375 with +/// filename-UUID extraction :414-421; amplifier: amplifier.rs session dirs). +/// Providers without a cheap direct check (opencode: sqlite-backed) answer +/// `false` — deletion deferred, never risked. Unknown providers: `false`. +fn transcript_definitively_absent( + home: &std::path::Path, + provider: &str, + session_id: &str, +) -> bool { + match provider { + "claude" => { + // ~/.claude/projects//.jsonl — any match means present. + let projects = home.join(".claude").join("projects"); + let Ok(dirs) = std::fs::read_dir(&projects) else { + return false; // unreadable => defer + }; + for entry in dirs { + let Ok(entry) = entry else { + return false; // per-entry read error => defer + }; + let candidate = entry.path().join(format!("{session_id}.jsonl")); + match std::fs::metadata(&candidate) { + Ok(meta) if meta.is_file() => return false, // present => never delete + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} // definitely not here + Err(_) => return false, // couldn't tell (e.g. unreadable subdir) => defer + } + } + true + } + "codex" => { + // ~/.codex/sessions/** rollout files carry the session UUID in the + // filename — walk and match (bounded: sessions tree only). + let root = home.join(".codex").join("sessions"); + if !root.is_dir() { + return false; // unreadable/missing home => defer + } + !walk_contains_filename_fragment(&root, session_id) + } + "amplifier" => { + // /projects//sessions// — the + // session dir named by session id. Mirrors the SAME + // `amplifier_home` resolution (`AMPLIFIER_HOME` env / + // `/.amplifier`) main.rs already computes for the + // `AmplifierSource` construction above. + let projects = freshell_sessions::amplifier::amplifier_home(home).join("projects"); + let Ok(dirs) = std::fs::read_dir(&projects) else { + return false; // unreadable => defer + }; + for entry in dirs { + let Ok(entry) = entry else { + return false; // per-entry read error => defer + }; + let candidate = entry.path().join("sessions").join(session_id); + match std::fs::metadata(&candidate) { + Ok(meta) if meta.is_dir() => return false, // present => never delete + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} // definitely not here + Err(_) => return false, // couldn't tell (e.g. unreadable subdir) => defer + } + } + true + } + _ => false, // opencode (sqlite) + unknown providers: defer deletion + } +} + +/// Bounded recursive walk: does any filename under `root` contain `fragment`? +/// Deletion-defer bias (V10.md): ANY read error answers `true` ("assume a +/// match exists"), so [`transcript_definitively_absent`] reports the +/// transcript as present and tombstone deletion is deferred, never risked. +fn walk_contains_filename_fragment(root: &std::path::Path, fragment: &str) -> bool { + let Ok(entries) = std::fs::read_dir(root) else { + return true; // read error => "found" => outer fn defers deletion + }; + for entry in entries { + let Ok(entry) = entry else { + return true; // read error => defer, same as above + }; + let path = entry.path(); + if path.is_dir() { + if walk_contains_filename_fragment(&path, fragment) { + return true; + } + } else if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.contains(fragment)) + { + return true; + } + } + false +} + /// Build the `{ platform, availableClis, hostName, featureFlags }` payload the /// SPA reads on boot (mirrors `server/platform-router.ts`). `platform` is the /// real `/proc/version`-derived string (`detect_platform_proc`); `availableClis` @@ -1481,6 +1657,94 @@ mod tests { use super::*; use freshell_platform::MapEnv; + // -- 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 + // matching transcript answers `true`. + + #[test] + fn claude_transcript_present_is_not_absent() { + let home = tempfile::tempdir().expect("tempdir"); + let proj = home.path().join(".claude").join("projects").join("-p"); + std::fs::create_dir_all(&proj).expect("mkdir projects/-p"); + std::fs::write(proj.join("sess-1.jsonl"), "{}\n").expect("write transcript"); + assert!( + !transcript_definitively_absent(home.path(), "claude", "sess-1"), + "an existing /.jsonl means PRESENT (never delete)" + ); + } + + #[test] + fn claude_empty_projects_tree_is_definitively_absent() { + let home = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(home.path().join(".claude").join("projects")) + .expect("mkdir empty projects"); + assert!( + transcript_definitively_absent(home.path(), "claude", "sess-1"), + "a readable projects tree with no match is DEFINITIVELY absent" + ); + } + + #[cfg(unix)] + #[test] + fn claude_unreadable_projects_root_defers() { + use std::os::unix::fs::PermissionsExt; + let home = tempfile::tempdir().expect("tempdir"); + let projects = home.path().join(".claude").join("projects"); + std::fs::create_dir_all(&projects).expect("mkdir projects"); + std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o000)) + .expect("chmod 000"); + let verdict = transcript_definitively_absent(home.path(), "claude", "sess-1"); + // Restore so the tempdir can be cleaned up regardless of the assert. + std::fs::set_permissions(&projects, std::fs::Permissions::from_mode(0o755)) + .expect("chmod 755"); + assert!( + !verdict, + "an unreadable projects root must DEFER (false), never delete" + ); + } + + #[cfg(unix)] + #[test] + fn claude_unreadable_project_subdir_defers() { + use std::os::unix::fs::PermissionsExt; + let home = tempfile::tempdir().expect("tempdir"); + let projects = home.path().join(".claude").join("projects"); + let proj = projects.join("-p"); + std::fs::create_dir_all(&proj).expect("mkdir projects/-p"); + std::fs::set_permissions(&proj, std::fs::Permissions::from_mode(0o000)) + .expect("chmod 000 subdir"); + let verdict = transcript_definitively_absent(home.path(), "claude", "sess-1"); + // Restore so the tempdir can be cleaned up regardless of the assert. + std::fs::set_permissions(&proj, std::fs::Permissions::from_mode(0o755)) + .expect("chmod 755 subdir"); + assert!( + !verdict, + "a readable projects root with an UNREADABLE project subdir must \ + DEFER (false) - the transcript may live in exactly that subdir" + ); + } + + #[test] + fn missing_projects_root_defers() { + let home = tempfile::tempdir().expect("tempdir"); + assert!( + !transcript_definitively_absent(home.path(), "claude", "sess-1"), + "no ~/.claude/projects at all => defer (read error branch)" + ); + } + + #[test] + fn opencode_and_unknown_providers_always_defer() { + let home = tempfile::tempdir().expect("tempdir"); + assert!(!transcript_definitively_absent( + home.path(), + "opencode", + "s" + )); + assert!(!transcript_definitively_absent(home.path(), "no-such", "s")); + } + // `AI_CONFIG.enabled()` (`server/ai-prompts.ts:12-15`): // `enabled: () => Boolean(process.env.GOOGLE_GENERATIVE_AI_API_KEY)`. // These use an injected `MapEnv` (not real process env), so they need no diff --git a/crates/freshell-server/src/tabs_snapshots.rs b/crates/freshell-server/src/tabs_snapshots.rs index fc33f7772..8556284f8 100644 --- a/crates/freshell-server/src/tabs_snapshots.rs +++ b/crates/freshell-server/src/tabs_snapshots.rs @@ -175,78 +175,9 @@ use marker_mod::{ MAX_RESTORE_MARKER_SOURCE_ID_BYTES, MAX_RESTORE_MARKER_TERMINAL_ID_BYTES, RESTORE_MARKER_TMP, }; -/// Map a snapshot pane to its `POST /api/tabs` body. Invalid session identity -/// fails before spawn; unsupported kinds are skips. Captured terminal, browser, -/// and editor options pass through to the restored pane. -fn pane_to_create_body(tab_name: Option<&Value>, pane: &Value) -> Result { - let payload = pane.get("payload").cloned().unwrap_or_else(|| json!({})); - let kind = pane.get("kind").and_then(Value::as_str).unwrap_or(""); - let name = tab_name.cloned().unwrap_or(Value::Null); - match kind { - "terminal" => { - let mode = payload - .get("mode") - .and_then(Value::as_str) - .unwrap_or("shell"); - let mut b = json!({ "mode": mode, "name": name }); - if let Some(cwd) = payload.get("initialCwd").filter(|v| v.is_string()) { - b["cwd"] = cwd.clone(); - } - if let Some(shell) = payload.get("shell").filter(|v| v.is_string()) { - b["shell"] = shell.clone(); - } - if let Some(cd) = payload.get("codexDurability").filter(|v| v.is_object()) { - if mode == "codex" { - b["codexDurability"] = cd.clone(); - } - } - // Present identity must be nonempty and match the terminal mode. - if let Some(sref) = payload.get("sessionRef").filter(|v| !v.is_null()) { - let ok = sref.is_object() - && sref.get("provider").and_then(Value::as_str) == Some(mode) - && sref - .get("sessionId") - .and_then(Value::as_str) - .is_some_and(|s| !s.is_empty()); - if !ok { - return Err("session-identity-mismatch"); - } - b["sessionRef"] = sref.clone(); - } - Ok(b) - } - "browser" => match payload.get("url").and_then(Value::as_str) { - Some(url) => { - let mut b = json!({ "browser": url, "name": name }); - if let Some(dt) = payload.get("devToolsOpen").filter(|v| v.is_boolean()) { - b["devToolsOpen"] = dt.clone(); - } - Ok(b) - } - None => Err("missing-url"), - }, - "editor" => match payload.get("filePath") { - Some(file_path) if file_path.is_string() || file_path.is_null() => { - let mut b = json!({ "editor": file_path, "name": name }); - if let Some(lang) = payload.get("language").filter(|v| v.is_string()) { - b["language"] = lang.clone(); - } - if let Some(ro) = payload.get("readOnly").filter(|v| v.is_boolean()) { - b["readOnly"] = ro.clone(); - } - if let Some(vm) = payload.get("viewMode").filter(|v| v.is_string()) { - b["viewMode"] = vm.clone(); - } - if let Some(ww) = payload.get("wordWrap").filter(|v| v.is_boolean()) { - b["wordWrap"] = ww.clone(); - } - Ok(b) - } - _ => Err("missing-filePath"), - }, - _ => Err("unsupported-kind"), - } -} +#[path = "tabs_snapshots_create_body.rs"] +mod tabs_snapshots_create_body; +use tabs_snapshots_create_body::pane_to_create_body; /// Stable per-pane identity key (content-derived, NOT a positional index). fn pane_key(tab_key: &str, pane_id: &str) -> String { diff --git a/crates/freshell-server/src/tabs_snapshots_create_body.rs b/crates/freshell-server/src/tabs_snapshots_create_body.rs new file mode 100644 index 000000000..6c6ca8563 --- /dev/null +++ b/crates/freshell-server/src/tabs_snapshots_create_body.rs @@ -0,0 +1,89 @@ +//! `pane_to_create_body` — extracted from `tabs_snapshots.rs` to honor the +//! 1,000-line file cap (port/AGENTS.md) when the createRequestId passthrough +//! was added. Included via `#[path]` from `tabs_snapshots.rs`, matching the +//! sibling pattern used by `tabs_snapshots_marker.rs` / `tabs_snapshots_tests.rs`. + +use serde_json::{json, Value}; + +/// Map a snapshot pane to its `POST /api/tabs` body. Invalid session identity +/// fails before spawn; unsupported kinds are skips. Captured terminal, browser, +/// and editor options pass through to the restored pane. +pub(crate) fn pane_to_create_body( + tab_name: Option<&Value>, + pane: &Value, +) -> Result { + let payload = pane.get("payload").cloned().unwrap_or_else(|| json!({})); + let kind = pane.get("kind").and_then(Value::as_str).unwrap_or(""); + let name = tab_name.cloned().unwrap_or(Value::Null); + match kind { + "terminal" => { + let mode = payload + .get("mode") + .and_then(Value::as_str) + .unwrap_or("shell"); + let mut b = json!({ "mode": mode, "name": name }); + if let Some(cwd) = payload.get("initialCwd").filter(|v| v.is_string()) { + b["cwd"] = cwd.clone(); + } + if let Some(shell) = payload.get("shell").filter(|v| v.is_string()) { + b["shell"] = shell.clone(); + } + // Stable pane identity key (reconciliation design §5.5): restore + // re-creates the pane under its CAPTURED key so server-side state + // keyed on it survives; absent on legacy snapshots (the REST + // ingress mints one in that case). + if let Some(crid) = payload.get("createRequestId").filter(|v| v.is_string()) { + b["createRequestId"] = crid.clone(); + } + if let Some(cd) = payload.get("codexDurability").filter(|v| v.is_object()) { + if mode == "codex" { + b["codexDurability"] = cd.clone(); + } + } + // Present identity must be nonempty and match the terminal mode. + if let Some(sref) = payload.get("sessionRef").filter(|v| !v.is_null()) { + let ok = sref.is_object() + && sref.get("provider").and_then(Value::as_str) == Some(mode) + && sref + .get("sessionId") + .and_then(Value::as_str) + .is_some_and(|s| !s.is_empty()); + if !ok { + return Err("session-identity-mismatch"); + } + b["sessionRef"] = sref.clone(); + } + Ok(b) + } + "browser" => match payload.get("url").and_then(Value::as_str) { + Some(url) => { + let mut b = json!({ "browser": url, "name": name }); + if let Some(dt) = payload.get("devToolsOpen").filter(|v| v.is_boolean()) { + b["devToolsOpen"] = dt.clone(); + } + Ok(b) + } + None => Err("missing-url"), + }, + "editor" => match payload.get("filePath") { + Some(file_path) if file_path.is_string() || file_path.is_null() => { + let mut b = json!({ "editor": file_path, "name": name }); + if let Some(lang) = payload.get("language").filter(|v| v.is_string()) { + b["language"] = lang.clone(); + } + if let Some(ro) = payload.get("readOnly").filter(|v| v.is_boolean()) { + b["readOnly"] = ro.clone(); + } + if let Some(vm) = payload.get("viewMode").filter(|v| v.is_string()) { + b["viewMode"] = vm.clone(); + } + if let Some(ww) = payload.get("wordWrap").filter(|v| v.is_boolean()) { + b["wordWrap"] = ww.clone(); + } + Ok(b) + } + _ => Err("missing-filePath"), + }, + _ => Err("unsupported-kind"), + } +} diff --git a/crates/freshell-server/src/tabs_snapshots_restore_tests.rs b/crates/freshell-server/src/tabs_snapshots_restore_tests.rs index 7efe0bf93..3dbaa6414 100644 --- a/crates/freshell-server/src/tabs_snapshots_restore_tests.rs +++ b/crates/freshell-server/src/tabs_snapshots_restore_tests.rs @@ -598,6 +598,32 @@ fn create_body_carries_full_terminal_state_including_codex_durability() { assert_eq!(body["sessionRef"]["sessionId"], "s-1"); } +#[test] +fn create_body_carries_create_request_id_and_omits_when_absent() { + // Captured key passes through (P1.6: snapshot-restored panes keep identity). + let pane = json!({ "paneId": "p1", "kind": "terminal", "payload": { + "mode": "shell", "shell": "system", + "createRequestId": "crid-from-snapshot" + }}); + let body = pane_to_create_body(None, &pane).unwrap(); + assert_eq!(body["createRequestId"], "crid-from-snapshot"); + + // Legacy snapshot without the field: body omits it entirely (the REST + // ingress mints a fresh key in that case — never emit null/empty). + let legacy = json!({ "paneId": "p2", "kind": "terminal", "payload": { + "mode": "shell", "shell": "system" + }}); + let legacy_body = pane_to_create_body(None, &legacy).unwrap(); + assert!(legacy_body.get("createRequestId").is_none()); + + // Wrong-typed field is dropped, not an error (same tolerance as shell/cwd). + let wrong = json!({ "paneId": "p3", "kind": "terminal", "payload": { + "mode": "shell", "createRequestId": 42 + }}); + let wrong_body = pane_to_create_body(None, &wrong).unwrap(); + assert!(wrong_body.get("createRequestId").is_none()); +} + #[tokio::test] async fn restore_round_trips_non_default_pane_state_to_the_client() { // FULL PANE STATE (`:245`): NON-default captured values must reach the diff --git a/crates/freshell-terminal/src/registry.rs b/crates/freshell-terminal/src/registry.rs index b07451748..b3e7a5152 100644 --- a/crates/freshell-terminal/src/registry.rs +++ b/crates/freshell-terminal/src/registry.rs @@ -1575,7 +1575,7 @@ impl TerminalRegistry { } /// A terminal's stamped `createRequestId`, if any. - fn probe_create_request_id(&self, terminal_id: &str) -> Option { + pub fn probe_create_request_id(&self, terminal_id: &str) -> Option { let shared = { let inner = self.inner.lock().expect("registry lock"); inner diff --git a/crates/freshell-ws/Cargo.toml b/crates/freshell-ws/Cargo.toml index d935315ca..ebee7798d 100644 --- a/crates/freshell-ws/Cargo.toml +++ b/crates/freshell-ws/Cargo.toml @@ -46,6 +46,7 @@ notify = "6" freshell-freshagent = { path = "../freshell-freshagent" } # Transport: axum's WebSocket upgrade is tokio-tungstenite-backed (ADR Decision 3). axum = { version = "0.8", features = ["ws"] } +serde = { workspace = true } serde_json = { workspace = true } # ISO-8601 timestamp for `ready.timestamp` (matches Date#toISOString()). chrono = { version = "0.4", default-features = false, features = ["clock"] } @@ -80,6 +81,10 @@ tracing = "0.1" # workspace already resolves via crates/freshell-terminal/Cargo.toml:36. sha2 = "0.10" +[target.'cfg(unix)'.dependencies] +# `flock(2)` for pane_ledger's single-writer store lock (ConfigLock pattern). +libc = "0.2" + [dev-dependencies] # tabs_persist tests write snapshot generations into throwaway dirs. Same # version other workspace crates pin (e.g. crates/freshell-terminal). diff --git a/crates/freshell-ws/src/amplifier_association.rs b/crates/freshell-ws/src/amplifier_association.rs index 1ef90c10c..35c08f49c 100644 --- a/crates/freshell-ws/src/amplifier_association.rs +++ b/crates/freshell-ws/src/amplifier_association.rs @@ -147,6 +147,21 @@ pub(crate) async fn drain_and_associate(state: &WsState) { Some("amplifier".to_string()), Some(located.session_id.clone()), ); + // P1.8 (trigger d): locator resolution is an identity event — + // durable binding row first, then the spawn-time pending marker is + // deleted (Task 6's MARKER_MODES allowlist writes markers for + // exactly codex/opencode/amplifier, so every allowlisted mode's + // resolver must also resolve the marker). Registry-truth cwd, same + // as the in-memory binds above. Awaited (drain_and_associate is + // async; the helper spawn_blockings the fsync off this sweep task). + crate::pane_ledger::ledger_resolve_identity( + state, + &located.terminal_id, + "amplifier", + &located.session_id, + entry.cwd.as_deref(), + ) + .await; broadcast_terminal_session_associated( state, &located.terminal_id, @@ -249,6 +264,7 @@ mod tests { let broadcast_tx = StdArc::new(tokio::sync::broadcast::channel::(16).0); let rx = broadcast_tx.subscribe(); let state = WsState { + pane_ledger: std::sync::Arc::new(crate::pane_ledger::PaneLedger::disabled()), identity: crate::identity::TerminalIdentityRegistry::new(), auth_token: StdArc::clone(&auth_token), server_instance_id: StdArc::new("srv-1111".to_string()), @@ -306,6 +322,21 @@ mod tests { (state, rx) } + /// Sibling of `state_with_locator` with a REAL (enabled) pane ledger + /// rooted at `ledger_dir` — added rather than churning every existing + /// caller of the disabled-ledger fixture. Mirrors + /// `opencode_association::tests::state_with_locator_and_ledger`. + fn state_with_locator_and_ledger( + amplifier_home: std::path::PathBuf, + ledger_dir: &std::path::Path, + ) -> (WsState, tokio::sync::broadcast::Receiver) { + let (mut state, rx) = state_with_locator(amplifier_home); + state.pane_ledger = std::sync::Arc::new(crate::pane_ledger::PaneLedger::new(Some( + ledger_dir.to_path_buf(), + ))); + (state, rx) + } + fn unique_temp_dir(label: &str) -> std::path::PathBuf { use std::sync::atomic::{AtomicU64, Ordering}; static COUNTER: AtomicU64 = AtomicU64::new(0); @@ -382,7 +413,8 @@ mod tests { #[tokio::test] async fn drain_and_associate_binds_identity_and_broadcasts_on_location() { let home = unique_temp_dir("drain-associate"); - let (state, mut rx) = state_with_locator(home.clone()); + let ledger_dir = unique_temp_dir("drain-associate-ledger"); + let (state, mut rx) = state_with_locator_and_ledger(home.clone(), &ledger_dir); // A running amplifier terminal the locator can validate against at // association time (mode/status/resume_session_id all read from @@ -417,6 +449,15 @@ mod tests { .set_meta("t1", None, None, Some("amplifier".to_string()), None); maybe_arm(&state, "t1", "amplifier", Some("/proj"), None); + + // The spawn-time pending marker (written by handle_create in + // production — Task 6; written directly here because this test + // drives the module, not the WS handler). + state + .pane_ledger + .record_pending("t1", "amplifier", Some("/proj"), crate::terminal::now_ms()) + .unwrap(); + note_possible_submit(&state, "t1", "\r"); let dir = home @@ -475,7 +516,18 @@ mod tests { ); assert!(saw_meta, "expected a terminal.meta.updated broadcast"); + // P1.8 (trigger d): locator resolution wrote the durable binding row + // and deleted the spawn-time pending marker (pinned order). + let hit = state + .pane_ledger + .lookup_by_session("amplifier", "sess-drain") + .expect("binding row written at resolution"); + assert_eq!(hit.row.live_terminal_id.as_deref(), Some("t1")); + assert!(state.pane_ledger.pending_for_terminal("t1").is_none()); + assert!(state.pane_ledger.list_pending_raw().is_empty()); + state.registry.kill("t1"); let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&ledger_dir); } } diff --git a/crates/freshell-ws/src/codex_candidate.rs b/crates/freshell-ws/src/codex_candidate.rs index 42282716b..902bdb009 100644 --- a/crates/freshell-ws/src/codex_candidate.rs +++ b/crates/freshell-ws/src/codex_candidate.rs @@ -116,7 +116,7 @@ pub fn codex_sessions_root() -> Option { /// Handle one `terminal.codex.candidate.persisted` frame. No reply frame on /// any path; rejects are WARN logs, accepts bind BOTH identity homes and /// broadcast (mirrors `opencode_association.rs`'s resolve path). -pub(crate) fn handle_codex_candidate_persisted( +pub(crate) async fn handle_codex_candidate_persisted( state: &WsState, msg: TerminalCodexCandidatePersisted, ) { @@ -217,6 +217,18 @@ pub(crate) fn handle_codex_candidate_persisted( Some("codex".to_string()), Some(thread_id.to_string()), ); + // P1.8 (trigger b): the verified adoption is an identity event -- durable + // binding row first, then the spawn-time pending marker is deleted. + // Awaited spawn_blocking inside the helper: the fsync completes before + // the associated broadcast, without pinning the dispatch worker (V1.md). + crate::pane_ledger::ledger_resolve_identity( + state, + &msg.terminal_id, + "codex", + thread_id, + row.cwd.as_deref(), + ) + .await; broadcast_terminal_session_associated(state, &msg.terminal_id, thread_id, row.cwd.clone()); // G3: adopted identity also feeds the activity tracker, so this // terminal's `codex.activity.updated` records and subsequent diff --git a/crates/freshell-ws/src/invariants.rs b/crates/freshell-ws/src/invariants.rs index d97f84d5e..ad2e72271 100644 --- a/crates/freshell-ws/src/invariants.rs +++ b/crates/freshell-ws/src/invariants.rs @@ -96,8 +96,104 @@ pub(crate) fn error_claude_restore_unresolved(request_id: &str) { ); } +/// P1.8 (spec §4.2 write-failure policy): a ledger write failed. The event +/// itself proceeded (fail loud, degrade to status quo) — but this pane may +/// not survive a restart, and the live `durability.degraded` frame was +/// pushed at failure time. +pub(crate) fn error_pane_ledger_write_failed(terminal_id: &str, err: &std::io::Error) { + tracing::error!( + target: "freshell_ws::invariants", + terminal_id = %terminal_id, + error = %err, + "pane_ledger_write_failed: identity event could not be durably recorded; \ + durability.degraded broadcast live to all connected clients" + ); +} + +#[cfg(test)] +pub(crate) mod capture { + //! Thread-local capturing subscriber recording TARGET + message + + //! fields (the `freshell-freshagent` DIAG-01 convention, extended + //! with `metadata().target()` since these alarms are target-scoped). + use std::collections::BTreeMap; + use std::sync::{Arc, Mutex}; + use tracing::field::{Field, Visit}; + use tracing::{Event, Subscriber}; + use tracing_subscriber::layer::{Context, SubscriberExt}; + use tracing_subscriber::Layer; + + #[derive(Debug, Clone, Default)] + pub struct CapturedEvent { + pub target: String, + pub message: String, + pub fields: BTreeMap, + } + + #[derive(Default)] + struct FieldVisitor { + message: String, + fields: BTreeMap, + } + + impl Visit for FieldVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + let rendered = format!("{value:?}"); + if field.name() == "message" { + self.message = rendered; + } else { + self.fields.insert(field.name().to_string(), rendered); + } + } + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.message = value.to_string(); + } else { + self.fields + .insert(field.name().to_string(), value.to_string()); + } + } + fn record_i64(&mut self, field: &Field, value: i64) { + self.fields + .insert(field.name().to_string(), value.to_string()); + } + } + + struct CaptureLayer { + events: Arc>>, + } + + impl Layer for CaptureLayer { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let mut visitor = FieldVisitor::default(); + event.record(&mut visitor); + self.events + .lock() + .expect("capture lock") + .push(CapturedEvent { + target: event.metadata().target().to_string(), + message: visitor.message, + fields: visitor.fields, + }); + } + } + + pub fn capture() -> ( + Arc>>, + tracing::subscriber::DefaultGuard, + ) { + let events = Arc::new(Mutex::new(Vec::new())); + let layer = CaptureLayer { + events: Arc::clone(&events), + }; + let subscriber = tracing_subscriber::registry().with(layer); + let guard = tracing::subscriber::set_default(subscriber); + (events, guard) + } +} + #[cfg(test)] mod tests { + use super::capture; use super::*; fn row( @@ -117,86 +213,6 @@ mod tests { } } - mod capture { - //! Thread-local capturing subscriber recording TARGET + message + - //! fields (the `freshell-freshagent` DIAG-01 convention, extended - //! with `metadata().target()` since these alarms are target-scoped). - use std::collections::BTreeMap; - use std::sync::{Arc, Mutex}; - use tracing::field::{Field, Visit}; - use tracing::{Event, Subscriber}; - use tracing_subscriber::layer::{Context, SubscriberExt}; - use tracing_subscriber::Layer; - - #[derive(Debug, Clone, Default)] - pub struct CapturedEvent { - pub target: String, - pub message: String, - pub fields: BTreeMap, - } - - #[derive(Default)] - struct FieldVisitor { - message: String, - fields: BTreeMap, - } - - impl Visit for FieldVisitor { - fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { - let rendered = format!("{value:?}"); - if field.name() == "message" { - self.message = rendered; - } else { - self.fields.insert(field.name().to_string(), rendered); - } - } - fn record_str(&mut self, field: &Field, value: &str) { - if field.name() == "message" { - self.message = value.to_string(); - } else { - self.fields - .insert(field.name().to_string(), value.to_string()); - } - } - fn record_i64(&mut self, field: &Field, value: i64) { - self.fields - .insert(field.name().to_string(), value.to_string()); - } - } - - struct CaptureLayer { - events: Arc>>, - } - - impl Layer for CaptureLayer { - fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { - let mut visitor = FieldVisitor::default(); - event.record(&mut visitor); - self.events - .lock() - .expect("capture lock") - .push(CapturedEvent { - target: event.metadata().target().to_string(), - message: visitor.message, - fields: visitor.fields, - }); - } - } - - pub fn capture() -> ( - Arc>>, - tracing::subscriber::DefaultGuard, - ) { - let events = Arc::new(Mutex::new(Vec::new())); - let layer = CaptureLayer { - events: Arc::clone(&events), - }; - let subscriber = tracing_subscriber::registry().with(layer); - let guard = tracing::subscriber::set_default(subscriber); - (events, guard) - } - } - fn unresolved_warnings(events: &[capture::CapturedEvent]) -> Vec { events .iter() diff --git a/crates/freshell-ws/src/lib.rs b/crates/freshell-ws/src/lib.rs index ee41ab5a3..d1b9d9f8d 100644 --- a/crates/freshell-ws/src/lib.rs +++ b/crates/freshell-ws/src/lib.rs @@ -31,6 +31,7 @@ pub mod identity; pub(crate) mod invariants; pub mod opencode_association; pub mod origin; +pub mod pane_ledger; pub mod reconcile; pub mod screenshot; pub mod spawn_gate; @@ -246,6 +247,12 @@ pub struct WsState { /// its registry observer). `*.activity.list` requests answer with empty /// lists when `None` — same wire shape as "no busy terminals". pub activity: Option, + /// P1.8: the durable pane-identity ledger (spec §4.2). Constructed once + /// in `freshell-server::main` (root `/.freshell/pane-ledger`, + /// `PaneLedger::disabled()` when no home resolves) and shared with the + /// existence probe. Arc'd: identity events write it durably before they + /// are answered (async paths wrap the sync API in awaited spawn_blocking). + pub pane_ledger: std::sync::Arc, } /// The `/ws` sub-router, pre-bound to its state (mergeable into the server app). @@ -400,6 +407,14 @@ pub fn build_handshake_with_capabilities( if terminal.session_ref.is_none() { terminal.session_ref = state.identity.session_ref_for(&terminal.terminal_id); } + if terminal.session_ref.is_none() { + // P1.8 (spec §4.2 reads + precedence): the ledger's bound rows + // are the durable second rung of the identity authority chain — + // consulted only when live process truth is absent. + terminal.session_ref = state + .pane_ledger + .bound_session_ref_for_terminal(&terminal.terminal_id); + } } messages.push(ServerMessage::TerminalInventory(TerminalInventory { boot_id, @@ -691,6 +706,7 @@ mod tests { let auth_token = Arc::new("s3cr3t-token-abcdef".to_string()); let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(16).0); WsState { + pane_ledger: std::sync::Arc::new(crate::pane_ledger::PaneLedger::disabled()), identity: crate::identity::TerminalIdentityRegistry::new(), auth_token: Arc::clone(&auth_token), server_instance_id: Arc::new("srv-1111".to_string()), diff --git a/crates/freshell-ws/src/opencode_association.rs b/crates/freshell-ws/src/opencode_association.rs index a60c580f8..df20d50a6 100644 --- a/crates/freshell-ws/src/opencode_association.rs +++ b/crates/freshell-ws/src/opencode_association.rs @@ -146,6 +146,19 @@ pub(crate) async fn drain_and_associate(state: &WsState) { Some("opencode".to_string()), Some(located.session_id.clone()), ); + // P1.8 (trigger c) + P1.10: locator resolution is an identity event — + // durable binding row first, then the spawn-time pending marker is + // deleted. Registry-truth cwd, same as the in-memory binds above. + // Awaited (drain_and_associate is async; the helper spawn_blockings + // the fsync off this sweep task — V1.md). + crate::pane_ledger::ledger_resolve_identity( + state, + &located.terminal_id, + "opencode", + &located.session_id, + entry.cwd.as_deref(), + ) + .await; broadcast_terminal_session_associated( state, &located.terminal_id, @@ -224,6 +237,7 @@ mod tests { let broadcast_tx = StdArc::new(tokio::sync::broadcast::channel::(16).0); let rx = broadcast_tx.subscribe(); let state = WsState { + pane_ledger: std::sync::Arc::new(crate::pane_ledger::PaneLedger::disabled()), identity: crate::identity::TerminalIdentityRegistry::new(), auth_token: StdArc::clone(&auth_token), server_instance_id: StdArc::new("srv-1111".to_string()), @@ -281,6 +295,20 @@ mod tests { (state, rx) } + /// Sibling of `state_with_locator` with a REAL (enabled) pane ledger + /// rooted at `ledger_dir` — added rather than churning every existing + /// caller of the disabled-ledger fixture. + fn state_with_locator_and_ledger( + data_home: std::path::PathBuf, + ledger_dir: &std::path::Path, + ) -> (WsState, tokio::sync::broadcast::Receiver) { + let (mut state, rx) = state_with_locator(data_home); + state.pane_ledger = std::sync::Arc::new(crate::pane_ledger::PaneLedger::new(Some( + ledger_dir.to_path_buf(), + ))); + (state, rx) + } + fn unique_temp_dir(label: &str) -> std::path::PathBuf { use std::sync::atomic::{AtomicU64, Ordering}; static COUNTER: AtomicU64 = AtomicU64::new(0); @@ -492,4 +520,96 @@ mod tests { state.registry.kill("t1"); let _ = std::fs::remove_dir_all(&home); } + + /// P1.10: a RESTORE-created opencode pane that lacks identity must + /// still arm (restore:true suppresses arming ONLY via an implied + /// resume_session_id — `OpencodeLocator::arm` checks the resume id, + /// never a restore flag), and its identity-in-flight window must be + /// covered by a durable pending marker until resolution deletes it. + #[tokio::test] + async fn restore_created_pane_without_identity_arms_and_resolves_into_the_ledger() { + let home = unique_temp_dir("p110-restore-rearm"); + let ledger_dir = unique_temp_dir("p110-ledger"); + let (state, _rx) = state_with_locator_and_ledger(home.clone(), &ledger_dir); + let db = open_seed_db(&home); + + // A real PTY registry row, exactly as the sibling resolve-path test + // spawns it (the controller's reject checks read mode/status/resume + // from `state.registry`). + let spec = freshell_platform::build_spawn_spec( + freshell_platform::ShellType::System, + freshell_platform::detect::HostOs::Linux, + false, + Some("/tmp"), + &freshell_platform::RealEnv, + &freshell_platform::RealFileProbe, + &std::collections::BTreeMap::new(), + None, + None, + ); + state + .registry + .create( + &spec, + &std::collections::BTreeMap::new(), + "t1".to_string(), + "stream-1".to_string(), + "opencode", + None, + None, + None, + None, + ) + .expect("spawn a real shell for the test PTY"); + state + .registry + .set_meta("t1", None, None, Some("opencode".to_string()), None); + + // The restore-shaped arm: identity absent, so resume is None — the + // exact argument shape terminal.rs's handle_create produces for a + // restore:true create that carried no sessionRef. + maybe_arm(&state, "t1", "opencode", Some("/tmp"), None); + assert_eq!(state.opencode_locator.as_ref().unwrap().armed_count(), 1); + + // The spawn-time pending marker (written by handle_create in + // production — Task 6; written directly here because this test + // drives the module, not the WS handler). + state + .pane_ledger + .record_pending("t1", "opencode", Some("/tmp"), crate::terminal::now_ms()) + .unwrap(); + + note_possible_submit(&state, "t1", "\r"); + + insert_session(&db, "ses_restore", "/tmp", crate::terminal::now_ms()); + + // Drain repeatedly until the locator's correlation window has + // definitely closed relative to wall-clock `now_ms()`. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + for _ in 0..40 { + drain_and_associate(&state).await; + if state + .identity + .get("t1") + .and_then(|i| i.session_id) + .is_some() + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + // Resolution wrote the binding and deleted the marker (pinned order). + let hit = state + .pane_ledger + .lookup_by_session("opencode", "ses_restore") + .expect("binding row written at resolution"); + assert_eq!(hit.row.live_terminal_id.as_deref(), Some("t1")); + assert!(state.pane_ledger.pending_for_terminal("t1").is_none()); + assert!(state.pane_ledger.list_pending_raw().is_empty()); + + state.registry.kill("t1"); + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&ledger_dir); + } } diff --git a/crates/freshell-ws/src/pane_ledger.rs b/crates/freshell-ws/src/pane_ledger.rs new file mode 100644 index 000000000..ca940f1c4 --- /dev/null +++ b/crates/freshell-ws/src/pane_ledger.rs @@ -0,0 +1,799 @@ +//! P1.8 — the server-side pane-identity ledger (restart-resilience campaign +//! §4.2): a small per-row disk store under `/.freshell/pane-ledger/`, +//! written durably at identity events with atomic temp+rename +//! (`crate::tabs_persist::atomic_write_durable`). +//! +//! Two row types with different keys and different rights: +//! +//! * **Binding rows** — durable identity facts, keyed on the server-minted +//! `sessionRef` (provider, sessionId), with `terminalId` as a secondary +//! index. A binding row is *the resume-invocation record*: it stores +//! exactly what re-issuing the provider's resume needs (for terminal panes: +//! provider, sessionId, mode, cwd). Layout: +//! `bindings//.json`. +//! * **Pending markers** — evidence that identity establishment was in +//! flight, keyed on `terminalId` (the only stable server-minted id that +//! exists pre-identity). NEVER promoted, never joined (G1): resolution +//! writes a fresh binding row FIRST, then deletes the marker. Layout: +//! `pending/.json`. +//! +//! Deliberately NOT stored: scrollback (own store, P2.19), transcripts +//! (provider-owned), layout (client-owned). NOT keyed on `createRequestId` +//! (D4/V9.md: every restore path that re-creates an anchored pane re-mints +//! it first; only the orphaned in-flight-create replay preserves it) — +//! stored only as an advisory field, never an identity join key. +//! +//! Corruption policy: fail loud PER-ROW, never per-store — an unparsable row +//! is quarantined (renamed aside + logged), never silently dropped, and never +//! causes healthy rows to be skipped. +//! +//! Write-failure policy: a ledger write failure never blocks the +//! create/identity event, but it is never silent — see +//! [`surface_write_failure`]. +//! +//! Read/scan policy (V1.md / A15): a write-through in-memory index, loaded +//! ONCE at construction by a single directory scan, answers ALL steady-state +//! reads — no API does a per-call directory scan (full-store scans measured +//! at 21ms@1k / 426ms@20k rows; TTL math yields 1.2k-12k rows). Files stay +//! the durable source of truth; this process is the only writer +//! (single-writer flock, [`PaneLedger::new_locked`]), so write-through +//! invalidation is trivial. Reads never touch the fs and may run inline on +//! async paths; WRITES fsync (~15ms p50 on this host) and must be wrapped in +//! `spawn_blocking` at async call sites (the `terminal.rs:1369-1379` +//! PTY-spawn precedent) — the sync API here stays call-site-agnostic. + +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, RwLock}; + +use freshell_protocol::SessionLocator; +use serde::{Deserialize, Serialize}; + +#[path = "pane_ledger_scan.rs"] +mod pane_ledger_scan; +pub use pane_ledger_scan::{BootScanReport, QuarantinedRow}; + +/// Gates schema migration (spec §4.2): rows with a different version are +/// quarantined loudly at boot, never silently reinterpreted. +pub const LEDGER_VERSION: u32 = 1; + +/// Bound rows not observed within this TTL are expired TO TOMBSTONES +/// (`retired/gc_expired`), never deleted (spec §4.2 lifecycle). +pub const BOUND_GC_TTL_MS: i64 = 30 * 24 * 60 * 60 * 1000; + +/// Tombstones older than this are deleted ONLY once the transcript no longer +/// exists on disk — silent-fresh never returns by timer while the +/// conversation is still recoverable. +pub const TOMBSTONE_GC_TTL_MS: i64 = 90 * 24 * 60 * 60 * 1000; + +/// Pending markers older than this are swept (boot scan + periodic GC), +/// bounding leaked-marker lifetime (A8/V7: a pane that dies WITH the server +/// leaves a marker no exit hook will ever delete — terminal ids are never +/// re-minted). Fresh-by-race evidence matters at the boots near the crash; +/// a month-old marker is stale noise. +pub const PENDING_MARKER_TTL_MS: i64 = 30 * 24 * 60 * 60 * 1000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RowState { + Bound, + Retired, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RetiredReason { + Superseded, + Closed, + GcExpired, +} + +/// A durable identity fact — see the module doc for the schema contract. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BindingRow { + pub ledger_version: u32, + pub provider: String, + pub session_id: String, + pub mode: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + /// Advisory secondary index — the terminal that last owned this identity. + #[serde(skip_serializing_if = "Option::is_none")] + pub live_terminal_id: Option, + /// Advisory, latest-observed (D4: the client re-mints it on hydrate; it + /// is never an identity join key). + #[serde(skip_serializing_if = "Option::is_none")] + pub create_request_id: Option, + pub created_at: i64, + pub updated_at: i64, + pub last_observed_at: i64, + pub state: RowState, + #[serde(skip_serializing_if = "Option::is_none")] + pub retired_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub superseded_by: Option, +} + +/// Evidence that identity establishment was in flight (G1: never a binding). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingMarker { + pub ledger_version: u32, + pub terminal_id: String, + pub mode: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + pub spawned_at: i64, +} + +/// One identity event's worth of binding-row input. +pub struct BindingWrite<'a> { + pub provider: &'a str, + pub session_id: &'a str, + pub terminal_id: &'a str, + pub mode: &'a str, + pub cwd: Option<&'a str>, + pub create_request_id: Option<&'a str>, + pub now_ms: i64, +} + +/// A chain-terminus lookup result. `corrected == true` means the caller's +/// claimed ref was superseded and this row is the live successor. +#[derive(Debug, Clone)] +pub struct Resolution { + pub row: BindingRow, + pub corrected: bool, +} + +/// The in-memory write-through index (V1.md / A15). Loaded ONCE at +/// construction by a single directory scan; every successful file write +/// updates it in the same locked section. Unparsable / wrong-version files +/// are skipped here silently — the boot scan (Task 4) is what quarantines +/// them loudly. +#[derive(Default)] +struct LedgerIndex { + /// (provider, session_id) -> row. Bound AND retired (tombstones stay). + bindings: std::collections::HashMap<(String, String), BindingRow>, + /// terminal_id -> marker. + pending: std::collections::HashMap, +} + +/// The ledger store. `root: None` ⇒ feature disabled (no resolvable home) — +/// every write is an `Ok(())` no-op and every read answers empty, mirroring +/// the tabs-snapshots `Option`-wrapped-root precedent (`main.rs:709-711`). +pub struct PaneLedger { + root: Option, + /// ONE lock: serializes read-modify-write cycles AND owns the + /// write-through index — no cache-vs-file races by construction. + index: Mutex, + /// Held for the process lifetime by `new_locked` (single-writer guard, + /// V2.md); the kernel releases the flock on process death. + #[allow(dead_code)] // read only by the kernel (flock lifetime) + lock_file: Option, + /// Rows quarantined by the boot scan, retained for API surfacing. + #[allow(dead_code)] // populated + read by the boot scan (Task 4) + quarantined: RwLock>, +} + +impl PaneLedger { + /// Lock-free construction — tests and the integration harness use this + /// (verification handles over a live server's dir must not fight the + /// server's flock). Production uses [`PaneLedger::new_locked`]. + pub fn new(root: Option) -> Self { + let index = root + .as_ref() + .map(|r| Self::load_index(r)) + .unwrap_or_default(); + Self { + root, + index: Mutex::new(index), + lock_file: None, + quarantined: RwLock::new(Vec::new()), + } + } + + /// Production construction (V2.md single-writer guard): acquire an + /// exclusive advisory `flock(2)` on `/lock` (the `ConfigLock` + /// pattern, `settings_store.rs:385-417`). If another process holds it, + /// log a loud structured ERROR and come up DISABLED (no-op) — never two + /// writers on one store. Non-unix: no flock primitive is wired; + /// construct normally (ConfigLock's non-unix parity). + pub fn new_locked(root: Option) -> Self { + let Some(r) = root.clone() else { + return Self::new(None); + }; + match Self::acquire_store_lock(&r) { + Ok(lock_file) => { + let mut ledger = Self::new(root); + ledger.lock_file = lock_file; + ledger + } + Err(err) => { + tracing::error!( + target: "freshell_ws::pane_ledger", + root = %r.display(), + error = %err, + "pane_ledger_lock_unavailable: another writer holds /lock; \ + ledger DISABLED for this process (never two writers on one store)" + ); + Self::new(None) + } + } + } + + #[cfg(unix)] + fn acquire_store_lock(root: &Path) -> std::io::Result> { + use std::os::unix::io::AsRawFd; + std::fs::create_dir_all(root)?; + // Content irrelevant (only existence + flock state matter); + // truncate(false) avoids clippy's suspicious_open_options. + let file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(root.join("lock"))?; + // SAFETY: `fd` is a valid open descriptor owned by `file` for the + // duration of the call; flock only mutates kernel lock state. + let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if rc == 0 { + Ok(Some(file)) + } else { + Err(std::io::Error::last_os_error()) + } + } + + #[cfg(not(unix))] + fn acquire_store_lock(_root: &Path) -> std::io::Result> { + Ok(None) // no advisory-lock primitive on this platform (ConfigLock parity) + } + + /// A ledger that stores nothing — the test/default construction. + pub fn disabled() -> Self { + Self::new(None) + } + + fn bindings_dir(root: &Path) -> PathBuf { + root.join("bindings") + } + + fn pending_dir(root: &Path) -> PathBuf { + root.join("pending") + } + + fn binding_path(root: &Path, provider: &str, session_id: &str) -> PathBuf { + Self::bindings_dir(root) + .join(encode_segment(provider)) + .join(format!("{}.json", encode_segment(session_id))) + } + + /// The ONE directory scan — construction-time only (V1.md). + fn load_index(root: &Path) -> LedgerIndex { + let mut index = LedgerIndex::default(); + if let Ok(providers) = std::fs::read_dir(Self::bindings_dir(root)) { + for provider in providers.flatten() { + let Ok(files) = std::fs::read_dir(provider.path()) else { + continue; + }; + for file in files.flatten() { + let path = file.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; // *.tmp-* and *.quarantined-* residue + } + if let Ok(row) = load_row::(&path) { + if row.ledger_version == LEDGER_VERSION { + index + .bindings + .insert((row.provider.clone(), row.session_id.clone()), row); + } + } + } + } + } + if let Ok(files) = std::fs::read_dir(Self::pending_dir(root)) { + for file in files.flatten() { + let path = file.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + if let Ok(marker) = load_row::(&path) { + if marker.ledger_version == LEDGER_VERSION { + index.pending.insert(marker.terminal_id.clone(), marker); + } + } + } + } + index + } + + /// Poison-tolerant lock (the `with_persist_lock` idiom) over the + /// write-through index. + fn guard(&self) -> std::sync::MutexGuard<'_, LedgerIndex> { + self.index.lock().unwrap_or_else(|p| p.into_inner()) + } + + /// Record (or refresh) a `bound` row for this identity event. + pub fn record_binding(&self, w: &BindingWrite<'_>) -> std::io::Result<()> { + let Some(root) = &self.root else { + return Ok(()); + }; + let mut index = self.guard(); + self.record_binding_locked(root, &mut index, w) + } + + fn record_binding_locked( + &self, + root: &Path, + index: &mut LedgerIndex, + w: &BindingWrite<'_>, + ) -> std::io::Result<()> { + // Supersession (G3, retire-never-defend): if this terminal already + // owns a DIFFERENT bound identity, the order is pinned — write the + // new `bound` row FIRST, then retire the old. A crash between the + // two leaves two bound rows; the boot-scan repair (Task 4) closes + // that window. Detection is a memory scan over the index (V1.md). + let previous = index + .bindings + .values() + .find(|r| { + r.state == RowState::Bound + && r.live_terminal_id.as_deref() == Some(w.terminal_id) + && (r.provider != w.provider || r.session_id != w.session_id) + }) + .cloned(); + + let key = (w.provider.to_string(), w.session_id.to_string()); + let existing = index.bindings.get(&key); + let created_at = existing.map(|r| r.created_at).unwrap_or(w.now_ms); + if existing.is_some_and(|r| r.retired_reason == Some(RetiredReason::GcExpired)) { + tracing::info!( + target: "freshell_ws::pane_ledger", + provider = %w.provider, + session_id = %w.session_id, + "pane_ledger_revived: gc_expired tombstone re-bound by a live identity event" + ); + } + let row = BindingRow { + ledger_version: LEDGER_VERSION, + provider: w.provider.to_string(), + session_id: w.session_id.to_string(), + mode: w.mode.to_string(), + cwd: w.cwd.map(str::to_string), + live_terminal_id: Some(w.terminal_id.to_string()), + create_request_id: w.create_request_id.map(str::to_string), + created_at, + updated_at: w.now_ms, + last_observed_at: w.now_ms, + state: RowState::Bound, + retired_reason: None, + superseded_by: None, + }; + self.write_binding(root, index, &row)?; // new bound row FIRST (pinned) + + if let Some(mut old) = previous { + old.state = RowState::Retired; + old.retired_reason = Some(RetiredReason::Superseded); + old.superseded_by = Some(SessionLocator { + provider: w.provider.to_string(), + session_id: w.session_id.to_string(), + }); + old.updated_at = w.now_ms; + tracing::info!( + target: "freshell_ws::pane_ledger", + terminal_id = %w.terminal_id, + old_session_id = %old.session_id, + new_session_id = %w.session_id, + "pane_ledger_superseded: binding moved; old row retired, never defended" + ); + self.write_binding(root, index, &old)?; // THEN retire the old + } + Ok(()) + } + + /// One row: durable file FIRST, then the write-through index — in the + /// same locked section, so readers never see index-ahead-of-disk. + fn write_binding( + &self, + root: &Path, + index: &mut LedgerIndex, + row: &BindingRow, + ) -> std::io::Result<()> { + let dest = Self::binding_path(root, &row.provider, &row.session_id); + write_row_atomic(&dest, row)?; + index + .bindings + .insert((row.provider.clone(), row.session_id.clone()), row.clone()); + Ok(()) + } + + /// Best-effort retire on observed clean close (trigger e). Missing or + /// already-retired rows are Ok — this path is never load-bearing. + pub fn retire_closed( + &self, + provider: &str, + session_id: &str, + now_ms: i64, + ) -> std::io::Result<()> { + let Some(root) = &self.root else { + return Ok(()); + }; + let mut index = self.guard(); + let Some(mut row) = index + .bindings + .get(&(provider.to_string(), session_id.to_string())) + .cloned() + else { + return Ok(()); + }; + if row.state != RowState::Bound { + return Ok(()); + } + row.state = RowState::Retired; + row.retired_reason = Some(RetiredReason::Closed); + row.updated_at = now_ms; + self.write_binding(root, &mut index, &row) + } + + /// Raw single-row read from the index (no chain following — that is + /// `lookup_by_session`, Task 2). Memory-only (V1.md read policy). + pub fn load_binding(&self, provider: &str, session_id: &str) -> Option { + self.root.as_ref()?; + self.guard() + .bindings + .get(&(provider.to_string(), session_id.to_string())) + .cloned() + } + + /// Follow the `supersededBy` chain from a claimed ref to its terminus. + /// Chains cannot cycle (a supersession write always targets a fresh row + /// and retires its predecessor in the same act) — the hop cap is a + /// corruption backstop, loud when hit. + pub fn lookup_by_session(&self, provider: &str, session_id: &str) -> Option { + self.root.as_ref()?; + let index = self.guard(); // memory-only chain walk (V1.md read policy) + let mut row = index + .bindings + .get(&(provider.to_string(), session_id.to_string())) + .cloned()?; + let mut corrected = false; + let mut hops = 0u32; + while row.state == RowState::Retired { + let Some(next) = row.superseded_by.clone() else { + break; // closed / gc_expired terminus — caller applies its reader rule + }; + hops += 1; + if hops > 32 { + tracing::error!( + target: "freshell_ws::pane_ledger", + provider = %provider, + session_id = %session_id, + "pane_ledger_chain_overflow: supersession chain exceeded 32 hops (corruption?)" + ); + return None; + } + let Some(next_row) = index + .bindings + .get(&(next.provider.clone(), next.session_id.clone())) + .cloned() + else { + break; + }; + row = next_row; + corrected = true; + } + Some(Resolution { row, corrected }) + } + + /// Whether this server has EVER durably bound this identity — bound or + /// retired, tombstones included. This is the ledger-backed + /// `ever_observed` input (spec §4.2 reads). Memory-only. + pub fn ever_bound(&self, provider: &str, session_id: &str) -> bool { + if self.root.is_none() { + return false; + } + self.guard() + .bindings + .contains_key(&(provider.to_string(), session_id.to_string())) + } + + /// All indexed binding rows (bound AND retired). Memory-only. + pub fn list_bindings(&self) -> Vec { + if self.root.is_none() { + return Vec::new(); + } + self.guard().bindings.values().cloned().collect() + } + + /// Secondary-index read: the newest BOUND row owned by this terminal. + pub fn bound_session_ref_for_terminal(&self, terminal_id: &str) -> Option { + self.list_bindings() + .into_iter() + .filter(|r| { + r.state == RowState::Bound && r.live_terminal_id.as_deref() == Some(terminal_id) + }) + .max_by_key(|r| r.updated_at) + .map(|r| SessionLocator { + provider: r.provider, + session_id: r.session_id, + }) + } + + /// Advisory-index read for the claude restore ladder: the newest row for + /// this provider whose latest-observed `createRequestId` matches. + /// Includes `gc_expired` tombstones (auto-resume is a legal transition); + /// excludes `closed`/`superseded` rows (retired rows are never used to + /// answer a restore — reader rule, spec §4.2). + pub fn lookup_by_create_request_id( + &self, + provider: &str, + create_request_id: &str, + ) -> Option { + self.list_bindings() + .into_iter() + .filter(|r| { + r.provider == provider + && r.create_request_id.as_deref() == Some(create_request_id) + && (r.state == RowState::Bound + || r.retired_reason == Some(RetiredReason::GcExpired)) + }) + .max_by_key(|r| r.updated_at) + } + + fn pending_path(root: &Path, terminal_id: &str) -> PathBuf { + Self::pending_dir(root).join(format!("{}.json", encode_segment(terminal_id))) + } + + /// Durable evidence that identity establishment is in flight for this + /// terminal (spec §4.2): written at spawn of an identity-bearing pane + /// whose identity is not yet known. File first, then index (write-through). + pub fn record_pending( + &self, + terminal_id: &str, + mode: &str, + cwd: Option<&str>, + now_ms: i64, + ) -> std::io::Result<()> { + let Some(root) = &self.root else { + return Ok(()); + }; + let mut index = self.guard(); + let marker = PendingMarker { + ledger_version: LEDGER_VERSION, + terminal_id: terminal_id.to_string(), + mode: mode.to_string(), + cwd: cwd.map(str::to_string), + spawned_at: now_ms, + }; + write_row_atomic(&Self::pending_path(root, terminal_id), &marker)?; + index.pending.insert(terminal_id.to_string(), marker); + Ok(()) + } + + /// Identity resolved: two independent atomic operations in a PINNED, + /// load-bearing order — write the sessionRef-keyed binding row FIRST, + /// then delete the pending marker (spec §4.2, G1/decision 5). A crash + /// between the two leaves both, which is safe: the reader rule prefers + /// the binding row and the boot sweep (Task 4) deletes the stale marker. + /// Idempotent: a second racing resolution finds the marker gone or the + /// row already bound and no-ops. + /// + /// `Err` means the BINDING write failed — the real durability alarm. A + /// marker-delete failure after a successful binding write is NOT an + /// error: the durable identity was recorded and the stale marker is + /// exactly the crash-window shape the boot sweep repairs, so it is + /// logged at WARN and the fn returns `Ok(())` (never a false + /// `durability.degraded` alarm). + pub fn resolve_pending(&self, w: &BindingWrite<'_>) -> std::io::Result<()> { + let Some(root) = &self.root else { + return Ok(()); + }; + let mut index = self.guard(); + self.record_binding_locked(root, &mut index, w)?; // binding row FIRST + if let Err(err) = Self::remove_pending(root, &mut index, w.terminal_id) { + // THEN the marker — cleanup only. The identity IS durably + // recorded; the leftover marker is swept at the next boot/GC + // pass (same repair as a crash between the two operations). + tracing::warn!( + target: "freshell_ws::pane_ledger", + terminal_id = %w.terminal_id, + error = %err, + "pane_ledger_marker_delete_failed_on_resolve: binding row durably \ + written; stale marker left for the boot/GC sweep to repair" + ); + } + Ok(()) + } + + /// Best-effort marker removal (missing file == already resolved/GC'd). + pub fn delete_pending(&self, terminal_id: &str) -> std::io::Result<()> { + let Some(root) = &self.root else { + return Ok(()); + }; + let mut index = self.guard(); + Self::remove_pending(root, &mut index, terminal_id) + } + + fn remove_pending( + root: &Path, + index: &mut LedgerIndex, + terminal_id: &str, + ) -> std::io::Result<()> { + let result = match std::fs::remove_file(Self::pending_path(root, terminal_id)) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + }; + if result.is_ok() { + index.pending.remove(terminal_id); + } + result + } + + /// Reader-rule lookup: `None` when no marker exists OR when a binding + /// row already covers this terminal ("binding row wins; such a marker is + /// stale"). Memory-only (V1.md read policy). + pub fn pending_for_terminal(&self, terminal_id: &str) -> Option { + self.root.as_ref()?; + let index = self.guard(); + let marker = index.pending.get(terminal_id).cloned()?; + let has_binding = index + .bindings + .values() + .any(|r| r.live_terminal_id.as_deref() == Some(terminal_id)); + if has_binding { + return None; + } + Some(marker) + } + + /// Raw markers (no reader rule) — boot-sweep + test surface. Memory-only. + pub fn list_pending_raw(&self) -> Vec { + if self.root.is_none() { + return Vec::new(); + } + self.guard().pending.values().cloned().collect() + } + + /// Rows quarantined by this process's boot scan — the Phase-3 verdict + /// surfacing (`ledger_quarantined` breadcrumb) reads this. + pub fn quarantined_rows(&self) -> Vec { + self.quarantined + .read() + .unwrap_or_else(|p| p.into_inner()) + .clone() + } +} + +/// The write-failure policy (spec §4.2): a ledger write failure NEVER blocks +/// the create/identity event, but it is never silent — structured ERROR + +/// invariant counter + a LIVE `durability.degraded` frame broadcast to all +/// connected clients (frozen clients ignore unknown frame types), at failure +/// time (a verdict-time flag would be posthumous). +pub(crate) fn surface_write_failure( + state: &crate::WsState, + terminal_id: &str, + result: std::io::Result<()>, +) { + let Err(err) = result else { return }; + crate::invariants::error_pane_ledger_write_failed(terminal_id, &err); + let msg = freshell_protocol::ServerMessage::DurabilityDegraded( + freshell_protocol::DurabilityDegraded { + terminal_id: terminal_id.to_string(), + reason: "ledger_write_failed".to_string(), + message: "This pane's identity could not be durably recorded; it may not survive a server restart.".to_string(), + }, + ); + if let Ok(frame) = serde_json::to_string(&msg) { + let _ = state.broadcast_tx.send(frame); + } +} + +/// The shared post-locator/candidate resolution hook (write trigger b/c): +/// binding row FIRST, then the pending marker is deleted (`resolve_pending`'s +/// pinned order). `create_request_id` is deliberately None here — it is an +/// advisory field captured at create time; resolution never joins on it (D4). +/// Failures never block the identity event; they surface LIVE. +/// +/// `async` + awaited spawn_blocking (V1.md / A1): every caller is an async +/// dispatch/sweep task, and the fsyncing write (~15ms p50) must complete +/// BEFORE the associated broadcast without pinning an async worker. +pub(crate) async fn ledger_resolve_identity( + state: &crate::WsState, + terminal_id: &str, + provider: &str, + session_id: &str, + cwd: Option<&str>, +) { + let ledger = std::sync::Arc::clone(&state.pane_ledger); + let provider_owned = provider.to_string(); + let session_id_owned = session_id.to_string(); + let terminal_id_owned = terminal_id.to_string(); + let cwd_owned = cwd.map(str::to_string); + let now = crate::terminal::now_ms(); + let result = tokio::task::spawn_blocking(move || { + ledger.resolve_pending(&BindingWrite { + provider: &provider_owned, + session_id: &session_id_owned, + terminal_id: &terminal_id_owned, + mode: &provider_owned, + cwd: cwd_owned.as_deref(), + create_request_id: None, + now_ms: now, + }) + }) + .await + .unwrap_or_else(|join_err| Err(std::io::Error::other(join_err))); + surface_write_failure(state, terminal_id, result); +} + +/// Path-segment encoding: `[A-Za-z0-9._-]` pass through, everything else +/// (including `%`) becomes `%XX` uppercase hex. Injective and containment- +/// safe (no `/`, and the `.`/`..` specials are fully escaped). +pub(crate) fn encode_segment(raw: &str) -> String { + if raw.is_empty() { + return "%00".to_string(); + } + if raw == "." { + return "%2E".to_string(); + } + if raw == ".." { + return "%2E%2E".to_string(); + } + let mut out = String::with_capacity(raw.len()); + for b in raw.bytes() { + match b { + b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'.' | b'_' | b'-' => out.push(b as char), + other => out.push_str(&format!("%{other:02X}")), + } + } + out +} + +#[derive(Debug)] +pub(crate) enum RowLoadError { + Missing, + Io(std::io::Error), + Parse(String), +} + +impl std::fmt::Display for RowLoadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RowLoadError::Missing => write!(f, "missing"), + RowLoadError::Io(e) => write!(f, "io: {e}"), + RowLoadError::Parse(e) => write!(f, "parse: {e}"), + } + } +} + +pub(crate) fn load_row(path: &Path) -> Result { + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(RowLoadError::Missing), + Err(e) => return Err(RowLoadError::Io(e)), + }; + serde_json::from_slice(&bytes).map_err(|e| RowLoadError::Parse(e.to_string())) +} + +/// One row, atomically: sibling temp (PID+millis unique, the `instance_id.rs` +/// idiom) + `atomic_write_durable` (write, fsync, rename, fsync parent). +pub(crate) fn write_row_atomic(dest: &Path, row: &T) -> std::io::Result<()> { + let bytes = serde_json::to_vec_pretty(row) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + let file_name = dest.file_name().and_then(|n| n.to_str()).ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "row has no file name") + })?; + let parent = dest.parent().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "row has no parent") + })?; + std::fs::create_dir_all(parent)?; + let millis = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let tmp = parent.join(format!("{file_name}.tmp-{}-{millis}", std::process::id())); + crate::tabs_persist::atomic_write_durable(dest, &tmp, &bytes) +} + +#[cfg(test)] +#[path = "pane_ledger_tests.rs"] +mod tests; diff --git a/crates/freshell-ws/src/pane_ledger_scan.rs b/crates/freshell-ws/src/pane_ledger_scan.rs new file mode 100644 index 000000000..ffe2e0f9e --- /dev/null +++ b/crates/freshell-ws/src/pane_ledger_scan.rs @@ -0,0 +1,459 @@ +//! P1.8 — boot-scan / GC internals for [`PaneLedger`], split verbatim out of +//! `pane_ledger.rs` (repo 1K-line file limit). Declared as a child module of +//! `pane_ledger` (see the `#[path]` mod there), so this code keeps the +//! parent's privacy scope: direct access to the write-through index, private +//! row-write helpers, and the `quarantined` field. The types below are +//! re-exported from `pane_ledger`, so every existing path +//! (`crate::pane_ledger::BootScanReport`, ...) compiles unchanged. + +use super::*; + +/// A row the boot scan renamed aside because it could not be parsed. +#[derive(Debug, Clone)] +pub struct QuarantinedRow { + pub original_path: PathBuf, + pub quarantined_path: PathBuf, + pub error: String, +} + +/// What one boot scan / GC pass did — every field is also loudly logged. +#[derive(Debug, Default)] +pub struct BootScanReport { + pub quarantined: Vec, + pub stale_markers_removed: Vec, + /// (retired old ref, winning new ref) pairs from the crash-window repair. + pub supersession_repairs: Vec<(SessionLocator, SessionLocator)>, + pub gc_tombstoned: Vec, + pub tombstones_deleted: Vec, +} + +impl PaneLedger { + /// Boot-time hygiene (spec §4.2): per-row quarantine, stale-marker + /// sweep, supersession crash-window repair, then a GC pass. Fail loud + /// per-row, never per-store. The directory walks here are BOOT-ONLY — + /// steady-state reads stay on the in-memory index (V1.md). + pub fn boot_scan( + &self, + now_ms: i64, + transcript_absent: &dyn Fn(&str, &str) -> bool, + ) -> BootScanReport { + let Some(root) = self.root.clone() else { + return BootScanReport::default(); + }; + let mut index = self.guard(); + let mut report = BootScanReport::default(); + + // 1. Quarantine unparsable / wrong-version rows (bindings + pending). + // These never made it into the index (load_index keeps only clean + // current-version parses), so no index maintenance is needed here. + self.quarantine_unparsable(&root, now_ms, &mut report); + { + let mut q = self.quarantined.write().unwrap_or_else(|p| p.into_inner()); + q.extend(report.quarantined.iter().cloned()); + } + + // 2. Stale-marker sweep — two cases, both loud: + // (a) a marker whose terminalId already has a binding row is + // stale — the crash-between-write-and-delete shape; + // (b) a marker older than PENDING_MARKER_TTL_MS is aged out + // (A8/V7: bounds leaked markers from panes that died WITH the + // server — no exit hook will ever fire for them and terminal + // ids are never re-minted). + // Markers that are neither are PRESERVED (fresh-by-race + // evidence), never swept merely because the terminal isn't live. + let markers: Vec = index.pending.values().cloned().collect(); + for marker in markers { + let covered = index + .bindings + .values() + .any(|r| r.live_terminal_id.as_deref() == Some(marker.terminal_id.as_str())); + let aged_out = now_ms - marker.spawned_at > PENDING_MARKER_TTL_MS; + if covered || aged_out { + match Self::remove_pending(&root, &mut index, &marker.terminal_id) { + Ok(()) => { + tracing::warn!( + target: "freshell_ws::pane_ledger", + terminal_id = %marker.terminal_id, + covered_by_binding = covered, + aged_out = aged_out, + "pane_ledger_stale_marker_swept: crash-window residue or aged past TTL" + ); + report.stale_markers_removed.push(marker.terminal_id); + } + Err(err) => { + // Fail loud, never silent: the marker stays; the + // next boot/GC pass retries naturally. + tracing::warn!( + target: "freshell_ws::pane_ledger", + terminal_id = %marker.terminal_id, + covered_by_binding = covered, + aged_out = aged_out, + error = %err, + "pane_ledger_stale_marker_sweep_failed: marker removal failed; will retry next pass" + ); + } + } + } + } + + // 3. Supersession crash-window repair: two bound rows on one pane + // lineage — newer updatedAt wins, older auto-retired, loud. + let mut by_terminal: std::collections::HashMap> = + std::collections::HashMap::new(); + for row in index.bindings.values() { + if row.state == RowState::Bound { + if let Some(tid) = &row.live_terminal_id { + by_terminal + .entry(tid.clone()) + .or_default() + .push(row.clone()); + } + } + } + for (terminal_id, mut rows) in by_terminal { + if rows.len() < 2 { + continue; + } + // Tiebreak rationale (A16, strategist report): both rows were + // written by a SINGLE process run, milliseconds apart — the only + // hazard is a wall-clock step landing INSIDE that ms-wide window. + // Accepted: wall-clock updatedAt is the tiebreak. If this ever + // bites, stamp an in-process AtomicU64 sequence into rows as a + // secondary tiebreak (schema addition, P1.13-compatible). + rows.sort_by_key(|r| std::cmp::Reverse(r.updated_at)); + let winner = SessionLocator { + provider: rows[0].provider.clone(), + session_id: rows[0].session_id.clone(), + }; + for mut loser in rows.into_iter().skip(1) { + loser.state = RowState::Retired; + loser.retired_reason = Some(RetiredReason::Superseded); + loser.superseded_by = Some(winner.clone()); + loser.updated_at = now_ms; + tracing::warn!( + target: "freshell_ws::pane_ledger", + terminal_id = %terminal_id, + loser_session_id = %loser.session_id, + winner_session_id = %winner.session_id, + "pane_ledger_supersession_repair: two bound rows on one lineage; newer updatedAt wins" + ); + let loser_ref = SessionLocator { + provider: loser.provider.clone(), + session_id: loser.session_id.clone(), + }; + match self.write_binding(&root, &mut index, &loser) { + Ok(()) => { + report + .supersession_repairs + .push((loser_ref, winner.clone())); + } + Err(err) => { + // Fail loud, never silent: the loser stays bound on + // disk; the repair re-runs at the next boot scan. + tracing::error!( + target: "freshell_ws::pane_ledger", + terminal_id = %terminal_id, + loser_session_id = %loser_ref.session_id, + winner_session_id = %winner.session_id, + error = %err, + "pane_ledger_supersession_repair_failed: retire write failed; row left bound" + ); + } + } + } + } + + // 4. GC pass (also runs periodically via `gc`). + let gc_report = self.gc_locked(&root, &mut index, now_ms, transcript_absent); + report.gc_tombstoned = gc_report.gc_tombstoned; + report.tombstones_deleted = gc_report.tombstones_deleted; + report + } + + /// The periodic subset: expire unobserved bound rows TO TOMBSTONES, + /// delete old tombstones ONLY when the transcript is definitively gone — + /// per the caller's DIRECT-STAT closure (V10.md: probe Absent alone is + /// not definitive; see the boot_scan contract) — and sweep aged-out + /// pending markers (the leaked-marker lifetime bound must hold on a + /// long-running server, not only across restarts). + /// + /// Lock granularity: unlike the pre-serve boot scan, this path runs + /// CONCURRENTLY with async readers (handshake stamping, restore rung, + /// ever_bound), so it never holds the index guard across the whole + /// batch of fsyncing writes (~15-64ms each). It snapshots the work list + /// under the guard, then drops and re-acquires the guard per item; each + /// per-item helper re-reads current index state under the re-acquired + /// guard and skips items that no longer qualify. The write-through + /// invariant is preserved: every file mutation and its index update + /// still happen under ONE guard acquisition. + pub fn gc( + &self, + now_ms: i64, + transcript_absent: &dyn Fn(&str, &str) -> bool, + ) -> BootScanReport { + let Some(root) = self.root.clone() else { + return BootScanReport::default(); + }; + let mut report = BootScanReport::default(); + let (marker_ids, row_keys) = { + let index = self.guard(); + ( + index.pending.keys().cloned().collect::>(), + index + .bindings + .keys() + .cloned() + .collect::>(), + ) + }; + for terminal_id in marker_ids { + let mut index = self.guard(); + self.gc_marker_locked(&root, &mut index, &terminal_id, now_ms, &mut report); + } + for key in row_keys { + let mut index = self.guard(); + self.gc_row_locked( + &root, + &mut index, + &key, + now_ms, + transcript_absent, + &mut report, + ); + } + report + } + + /// The boot-time GC pass: same per-item helpers as `gc`, driven under + /// the caller's single guard (boot_scan runs pre-serve — no concurrent + /// readers exist, so batch-holding the guard is free and minimal). + fn gc_locked( + &self, + root: &Path, + index: &mut LedgerIndex, + now_ms: i64, + transcript_absent: &dyn Fn(&str, &str) -> bool, + ) -> BootScanReport { + let mut report = BootScanReport::default(); + let marker_ids: Vec = index.pending.keys().cloned().collect(); + for terminal_id in marker_ids { + self.gc_marker_locked(root, index, &terminal_id, now_ms, &mut report); + } + let row_keys: Vec<(String, String)> = index.bindings.keys().cloned().collect(); + for key in row_keys { + self.gc_row_locked(root, index, &key, now_ms, transcript_absent, &mut report); + } + report + } + + /// Aged-marker sweep for ONE marker, under the caller's guard (A8/V7): + /// part of the periodic subset per the `gc` contract, so a long-running + /// server bounds leaked-marker lifetime WITHOUT a restart. Only the TTL + /// case runs here — the covered-by-binding case is boot-only + /// crash-window residue (boot_scan step 2, which also handles the TTL + /// case at boot, so this finds nothing on the boot path). Re-reads the + /// marker from the index: one resolved/removed between the snapshot and + /// this guard acquisition is skipped safely. + fn gc_marker_locked( + &self, + root: &Path, + index: &mut LedgerIndex, + terminal_id: &str, + now_ms: i64, + report: &mut BootScanReport, + ) { + let Some(marker) = index.pending.get(terminal_id) else { + return; // resolved/removed since the snapshot — no longer qualifies + }; + if now_ms - marker.spawned_at <= PENDING_MARKER_TTL_MS { + return; + } + match Self::remove_pending(root, index, terminal_id) { + Ok(()) => { + tracing::warn!( + target: "freshell_ws::pane_ledger", + terminal_id = %terminal_id, + "pane_ledger_stale_marker_swept: aged past TTL (periodic GC)" + ); + report.stale_markers_removed.push(terminal_id.to_string()); + } + Err(err) => { + // Fail loud, never silent: the marker stays; the + // next GC pass retries naturally. + tracing::warn!( + target: "freshell_ws::pane_ledger", + terminal_id = %terminal_id, + error = %err, + "pane_ledger_stale_marker_sweep_failed: marker removal failed; will retry next pass" + ); + } + } + } + + /// GC for ONE binding row, under the caller's guard. Re-reads the row + /// from the index: one rewritten/removed between the snapshot and this + /// guard acquisition is re-evaluated against its CURRENT state (e.g. a + /// re-bound row with a fresh `last_observed_at` no longer qualifies and + /// is skipped). + fn gc_row_locked( + &self, + root: &Path, + index: &mut LedgerIndex, + key: &(String, String), + now_ms: i64, + transcript_absent: &dyn Fn(&str, &str) -> bool, + report: &mut BootScanReport, + ) { + let Some(mut row) = index.bindings.get(key).cloned() else { + return; // deleted since the snapshot — no longer qualifies + }; + let sref = SessionLocator { + provider: row.provider.clone(), + session_id: row.session_id.clone(), + }; + match row.state { + RowState::Bound => { + if now_ms - row.last_observed_at > BOUND_GC_TTL_MS { + row.state = RowState::Retired; + row.retired_reason = Some(RetiredReason::GcExpired); + row.updated_at = now_ms; + tracing::info!( + target: "freshell_ws::pane_ledger", + provider = %sref.provider, + session_id = %sref.session_id, + "pane_ledger_gc_tombstoned: bound row expired to tombstone (never deleted by timer)" + ); + match self.write_binding(root, index, &row) { + Ok(()) => report.gc_tombstoned.push(sref), + Err(err) => { + // Fail loud, never silent: the row stays + // bound on disk; the next GC pass retries. + tracing::error!( + target: "freshell_ws::pane_ledger", + provider = %sref.provider, + session_id = %sref.session_id, + error = %err, + "pane_ledger_gc_tombstone_failed: tombstone write failed; row left bound" + ); + } + } + } + } + RowState::Retired => { + let old_enough = now_ms - row.updated_at > TOMBSTONE_GC_TTL_MS; + if old_enough && transcript_absent(&row.provider, &row.session_id) { + let path = Self::binding_path(root, &row.provider, &row.session_id); + match std::fs::remove_file(&path) { + Ok(()) => { + index + .bindings + .remove(&(row.provider.clone(), row.session_id.clone())); + tracing::info!( + target: "freshell_ws::pane_ledger", + provider = %sref.provider, + session_id = %sref.session_id, + "pane_ledger_tombstone_deleted: transcript gone (direct stat) and tombstone TTL elapsed" + ); + report.tombstones_deleted.push(sref); + } + Err(err) => { + // Fail loud, never silent: the tombstone + // stays; the next GC pass retries naturally. + tracing::warn!( + target: "freshell_ws::pane_ledger", + provider = %sref.provider, + session_id = %sref.session_id, + error = %err, + "pane_ledger_tombstone_delete_failed: tombstone file removal failed; will retry next pass" + ); + } + } + } + } + } + } + + fn quarantine_unparsable(&self, root: &Path, now_ms: i64, report: &mut BootScanReport) { + let mut candidates: Vec = Vec::new(); + if let Ok(providers) = std::fs::read_dir(Self::bindings_dir(root)) { + for provider in providers.flatten() { + if let Ok(files) = std::fs::read_dir(provider.path()) { + candidates.extend(files.flatten().map(|f| f.path())); + } + } + } + if let Ok(files) = std::fs::read_dir(Self::pending_dir(root)) { + candidates.extend(files.flatten().map(|f| f.path())); + } + for path in candidates { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if name.contains(".tmp-") { + // Orphan temp from a crashed write — reap with a WARN (the + // `sweep_orphan_tmp` discipline). + tracing::warn!( + target: "freshell_ws::pane_ledger", + path = %path.display(), + "pane_ledger_orphan_tmp_reaped" + ); + let _ = std::fs::remove_file(&path); + continue; + } + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; // prior quarantine residue + } + let error = match load_row::(&path) { + Err(e) => format!("{e}"), + Ok(value) => { + let version = value.get("ledgerVersion").and_then(|v| v.as_u64()); + if version == Some(u64::from(LEDGER_VERSION)) { + // Version ok — but does it parse as its row type? + let is_pending = path + .parent() + .map(|p| p.ends_with("pending")) + .unwrap_or(false); + let typed_ok = if is_pending { + serde_json::from_value::(value).is_ok() + } else { + serde_json::from_value::(value).is_ok() + }; + if typed_ok { + continue; // healthy + } + "row shape does not match its type".to_string() + } else { + format!("unsupported ledgerVersion {version:?} (gate: {LEDGER_VERSION})") + } + } + }; + let quarantined_path = path.with_file_name(format!("{name}.quarantined-{now_ms}")); + match std::fs::rename(&path, &quarantined_path) { + Ok(()) => { + tracing::error!( + target: "freshell_ws::pane_ledger", + path = %path.display(), + quarantined = %quarantined_path.display(), + error = %error, + "pane_ledger_row_quarantined: unparsable row renamed aside (fail loud per-row, never per-store)" + ); + report.quarantined.push(QuarantinedRow { + original_path: path, + quarantined_path, + error, + }); + } + Err(rename_err) => { + // Fail loud, never silent: the bad row stays in place; + // the next boot scan retries the quarantine. + tracing::error!( + target: "freshell_ws::pane_ledger", + path = %path.display(), + quarantined = %quarantined_path.display(), + row_error = %error, + error = %rename_err, + "pane_ledger_quarantine_rename_failed: unparsable row left in place; will retry next boot" + ); + } + } + } + } +} diff --git a/crates/freshell-ws/src/pane_ledger_tests.rs b/crates/freshell-ws/src/pane_ledger_tests.rs new file mode 100644 index 000000000..1b48311b5 --- /dev/null +++ b/crates/freshell-ws/src/pane_ledger_tests.rs @@ -0,0 +1,654 @@ +//! Unit tests for `crate::pane_ledger` (P1.8, spec §4.2). Kept in a sibling +//! file (the `tabs_persist_tests.rs` convention) to respect the ≤1K-lines +//! file limit as the ledger's test surface grows. + +use super::*; +use std::path::PathBuf; + +fn temp_root(label: &str) -> PathBuf { + // Same atomic-counter + pid pattern as `opencode_association.rs`'s + // `unique_temp_dir` — no tempfile dependency needed for a dir we + // remove ourselves. + static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!( + "pane-ledger-test-{label}-{}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("create temp root"); + dir +} + +fn write( + provider: &str, + session_id: &str, + terminal_id: &str, + now_ms: i64, +) -> BindingWrite<'static> { + // Leak the strings for test brevity — tests are short-lived. + BindingWrite { + provider: Box::leak(provider.to_string().into_boxed_str()), + session_id: Box::leak(session_id.to_string().into_boxed_str()), + terminal_id: Box::leak(terminal_id.to_string().into_boxed_str()), + mode: Box::leak(provider.to_string().into_boxed_str()), + cwd: Some("/tmp/proj"), + create_request_id: Some("req-1"), + now_ms, + } +} + +#[test] +fn record_binding_roundtrips_all_fields() { + let root = temp_root("roundtrip"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_binding(&write("claude", "sess-a", "t1", 1_000)) + .expect("write ok"); + let row = ledger.load_binding("claude", "sess-a").expect("row exists"); + assert_eq!(row.ledger_version, LEDGER_VERSION); + assert_eq!(row.provider, "claude"); + assert_eq!(row.session_id, "sess-a"); + assert_eq!(row.mode, "claude"); + assert_eq!(row.cwd.as_deref(), Some("/tmp/proj")); + assert_eq!(row.live_terminal_id.as_deref(), Some("t1")); + assert_eq!(row.create_request_id.as_deref(), Some("req-1")); + assert_eq!(row.created_at, 1_000); + assert_eq!(row.updated_at, 1_000); + assert_eq!(row.last_observed_at, 1_000); + assert_eq!(row.state, RowState::Bound); + assert_eq!(row.retired_reason, None); + assert_eq!(row.superseded_by, None); + assert!(ledger.ever_bound("claude", "sess-a")); + assert!(!ledger.ever_bound("claude", "sess-other")); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn rewrite_preserves_created_at_and_bumps_updated_at() { + let root = temp_root("rewrite"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_binding(&write("codex", "th-1", "t1", 1_000)) + .unwrap(); + ledger + .record_binding(&write("codex", "th-1", "t1", 5_000)) + .unwrap(); + let row = ledger.load_binding("codex", "th-1").unwrap(); + assert_eq!(row.created_at, 1_000); + assert_eq!(row.updated_at, 5_000); + assert_eq!(row.last_observed_at, 5_000); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn disabled_ledger_is_a_silent_noop() { + let ledger = PaneLedger::disabled(); + ledger + .record_binding(&write("claude", "s", "t", 1)) + .expect("noop ok"); + assert_eq!(ledger.load_binding("claude", "s"), None); + assert!(!ledger.ever_bound("claude", "s")); + assert!(ledger.list_bindings().is_empty()); +} + +#[test] +fn writes_are_atomic_sibling_temp_plus_rename() { + // After a successful write no *.tmp-* residue remains, and the row file + // is a direct child of bindings//. + let root = temp_root("atomic"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_binding(&write("claude", "sess-a", "t1", 1_000)) + .unwrap(); + let provider_dir = root.join("bindings").join("claude"); + let entries: Vec = std::fs::read_dir(&provider_dir) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(entries, vec!["sess-a.json".to_string()]); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn key_encoding_is_path_safe_and_injective() { + assert_eq!(encode_segment("claude"), "claude"); + assert_eq!( + encode_segment("11111111-2222-3333-4444-555555555555"), + "11111111-2222-3333-4444-555555555555" + ); + assert_eq!(encode_segment("a/b"), "a%2Fb"); + assert_eq!(encode_segment("a%b"), "a%25b"); + assert_eq!(encode_segment(".."), "%2E%2E"); + assert_eq!(encode_segment("."), "%2E"); + assert_eq!(encode_segment(""), "%00"); + // Injective: distinct inputs never collide after encoding. + assert_ne!(encode_segment("a/b"), encode_segment("a%2Fb")); +} + +#[test] +fn index_loads_existing_rows_at_construction() { + // The write-through index is seeded by ONE directory scan in new() + // (V1.md read policy); a second instance over the same dir answers + // from its own fresh load — the restart-equivalent shape. + let root = temp_root("index-reload"); + { + let gen1 = PaneLedger::new(Some(root.clone())); + gen1.record_binding(&write("claude", "sess-a", "t1", 1_000)) + .unwrap(); + } + let gen2 = PaneLedger::new(Some(root.clone())); + assert!(gen2.ever_bound("claude", "sess-a")); + assert_eq!(gen2.list_bindings().len(), 1); + std::fs::remove_dir_all(&root).ok(); +} + +#[cfg(unix)] +#[test] +fn new_locked_degrades_to_disabled_when_another_holder_exists() { + // Single-writer guard (V2.md): never two writers on one store. The + // second locked construction logs a loud ERROR and comes up DISABLED; + // dropping the holder frees the flock (kernel-released on death too). + let root = temp_root("lock"); + let holder = PaneLedger::new_locked(Some(root.clone())); + holder + .record_binding(&write("claude", "s1", "t1", 1)) + .unwrap(); + let loser = PaneLedger::new_locked(Some(root.clone())); + loser + .record_binding(&write("claude", "s2", "t2", 2)) + .expect("disabled no-op"); + assert!(!loser.ever_bound("claude", "s2"), "loser is disabled"); + drop(holder); + let next = PaneLedger::new_locked(Some(root.clone())); + assert!(next.ever_bound("claude", "s1"), "flock freed on drop"); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn secondary_index_reads_by_terminal_and_request_id() { + let root = temp_root("secondary"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_binding(&write("claude", "sess-a", "t1", 1_000)) + .unwrap(); + ledger + .record_binding(&write("codex", "th-9", "t2", 2_000)) + .unwrap(); + let sref = ledger + .bound_session_ref_for_terminal("t1") + .expect("t1 bound"); + assert_eq!(sref.provider, "claude"); + assert_eq!(sref.session_id, "sess-a"); + assert_eq!(ledger.bound_session_ref_for_terminal("t-missing"), None); + let row = ledger + .lookup_by_create_request_id("claude", "req-1") + .expect("by request id"); + assert_eq!(row.session_id, "sess-a"); + assert_eq!( + ledger.lookup_by_create_request_id("claude", "req-none"), + None + ); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn rebind_retires_old_row() { + // Red test `rebind-retires-old-row` (spec §4.2 G3): a pane's binding + // legitimately moves -> the writer retires the old row and writes the + // new one; the old row records WHERE identity went. + let root = temp_root("rebind"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_binding(&write("codex", "th-old", "t1", 1_000)) + .unwrap(); + ledger + .record_binding(&write("codex", "th-new", "t1", 2_000)) + .unwrap(); + + let old = ledger.load_binding("codex", "th-old").unwrap(); + assert_eq!(old.state, RowState::Retired); + assert_eq!(old.retired_reason, Some(RetiredReason::Superseded)); + let by = old.superseded_by.expect("supersededBy set"); + assert_eq!(by.provider, "codex"); + assert_eq!(by.session_id, "th-new"); + + let new = ledger.load_binding("codex", "th-new").unwrap(); + assert_eq!(new.state, RowState::Bound); + assert_eq!(new.live_terminal_id.as_deref(), Some("t1")); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn client_claims_superseded_ref_is_answered_from_the_chain_terminus() { + // Red test `client-claims-superseded-ref` (ledger-API level; full + // verdict wiring is Phase 3): a lookup for a superseded ref follows + // `supersededBy` to the live bound row and reports corrected:true — + // never returns the retired row as the answer. + let root = temp_root("chain"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_binding(&write("codex", "th-1", "t1", 1_000)) + .unwrap(); + ledger + .record_binding(&write("codex", "th-2", "t1", 2_000)) + .unwrap(); + ledger + .record_binding(&write("codex", "th-3", "t1", 3_000)) + .unwrap(); + + let hit = ledger.lookup_by_session("codex", "th-1").expect("resolves"); + assert!(hit.corrected); + assert_eq!(hit.row.session_id, "th-3"); + assert_eq!(hit.row.state, RowState::Bound); + + // A direct claim of the live terminus is NOT a correction. + let direct = ledger.lookup_by_session("codex", "th-3").unwrap(); + assert!(!direct.corrected); + + // A retired row with no successor (e.g. closed) is returned as-is so + // callers can apply their own reader rule — but never invents a bound. + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn pending_marker_roundtrips_and_reader_rule_prefers_binding() { + let root = temp_root("pending"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_pending("t1", "opencode", Some("/tmp/p"), 1_000) + .unwrap(); + let marker = ledger.pending_for_terminal("t1").expect("marker readable"); + assert_eq!(marker.terminal_id, "t1"); + assert_eq!(marker.mode, "opencode"); + assert_eq!(marker.cwd.as_deref(), Some("/tmp/p")); + assert_eq!(marker.spawned_at, 1_000); + + // Reader rule (spec §4.2): "binding row wins; a marker whose terminalId + // already has a binding row is stale." + ledger + .record_binding(&write("opencode", "ses-1", "t1", 2_000)) + .unwrap(); + assert_eq!(ledger.pending_for_terminal("t1"), None); + // The raw file still exists until the boot sweep (Task 4) removes it. + assert_eq!(ledger.list_pending_raw().len(), 1); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn resolve_pending_writes_binding_first_then_deletes_marker() { + let root = temp_root("resolve"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_pending("t1", "codex", Some("/tmp/p"), 1_000) + .unwrap(); + ledger + .resolve_pending(&write("codex", "th-1", "t1", 2_000)) + .unwrap(); + assert!(ledger.load_binding("codex", "th-1").is_some()); + assert!(ledger.list_pending_raw().is_empty()); + std::fs::remove_dir_all(&root).ok(); +} + +#[cfg(unix)] +#[test] +fn resolve_pending_marker_delete_failure_is_not_a_durability_error() { + // The binding row (the durable identity) was written — a failed marker + // delete is cleanup residue the boot sweep repairs, NOT a durability + // failure. resolve_pending must return Ok(()) (logging at WARN), so the + // caller never raises a false `durability.degraded` alarm. + use std::os::unix::fs::PermissionsExt; + let root = temp_root("marker-delete-fails"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_pending("t1", "codex", Some("/tmp/p"), 1_000) + .unwrap(); + // Make the pending dir read-only so the marker unlink fails (EACCES). + let pending_dir = root.join("pending"); + std::fs::set_permissions(&pending_dir, std::fs::Permissions::from_mode(0o555)).unwrap(); + let result = ledger.resolve_pending(&write("codex", "th-1", "t1", 2_000)); + // Restore perms before asserting so cleanup always works. + std::fs::set_permissions(&pending_dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + result.expect("binding written durably; marker-delete failure must not propagate"); + // The durable identity IS recorded... + assert!(ledger.load_binding("codex", "th-1").is_some()); + // ...and the stale marker survives (on disk and in the index) for the + // boot sweep to repair. + assert!(pending_dir.join("t1.json").exists()); + assert_eq!(ledger.list_pending_raw().len(), 1); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn pending_resolution_collision_is_idempotent() { + // Red test `pending-resolution-collision` (spec §4.2 / decision 5): a + // second racing resolution for the same terminalId finds the marker + // gone or already-bound and no-ops — one binding row, no error. + let root = temp_root("collision"); + let ledger = std::sync::Arc::new(PaneLedger::new(Some(root.clone()))); + ledger + .record_pending("t1", "codex", Some("/tmp/p"), 1_000) + .unwrap(); + + // Sequential double-resolution. + ledger + .resolve_pending(&write("codex", "th-1", "t1", 2_000)) + .unwrap(); + ledger + .resolve_pending(&write("codex", "th-1", "t1", 2_001)) + .expect("second resolve no-ops"); + assert_eq!( + ledger + .list_bindings() + .iter() + .filter(|r| r.session_id == "th-1") + .count(), + 1 + ); + + // Concurrent resolution from two threads (the actual race shape). + ledger + .record_pending("t2", "codex", Some("/tmp/p"), 3_000) + .unwrap(); + let a = std::sync::Arc::clone(&ledger); + let b = std::sync::Arc::clone(&ledger); + let ha = std::thread::spawn(move || a.resolve_pending(&write("codex", "th-2", "t2", 3_001))); + let hb = std::thread::spawn(move || b.resolve_pending(&write("codex", "th-2", "t2", 3_002))); + ha.join().unwrap().expect("racer A ok"); + hb.join().unwrap().expect("racer B ok"); + assert_eq!( + ledger + .list_bindings() + .iter() + .filter(|r| r.session_id == "th-2") + .count(), + 1 + ); + assert!(ledger.pending_for_terminal("t2").is_none()); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn sigkill_inside_locator_window_leaves_a_durable_marker() { + // Red test `SIGKILL-inside-locator-window` (unit shape): a marker + // written pre-resolution survives "process death" (a second PaneLedger + // instance over the same dir) so a restarted server can answer + // "fresh by race, not by intent" instead of silent fresh. + let root = temp_root("sigkill-window"); + { + let gen1 = PaneLedger::new(Some(root.clone())); + gen1.record_pending("t1", "opencode", Some("/tmp/p"), 1_000) + .unwrap(); + // gen1 "dies" here — dropped without resolving. + } + let gen2 = PaneLedger::new(Some(root.clone())); + let marker = gen2 + .pending_for_terminal("t1") + .expect("marker survived the crash"); + assert_eq!(marker.mode, "opencode"); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn delete_pending_is_a_noop_when_missing() { + let root = temp_root("del-missing"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .delete_pending("never-existed") + .expect("missing marker is Ok"); + std::fs::remove_dir_all(&root).ok(); +} + +fn never_absent(_p: &str, _s: &str) -> bool { + false +} + +#[test] +fn corrupt_ledger_boot_quarantines_per_row_never_per_store() { + // Red test `corrupt-ledger-boot` (spec §4.2): an unparsable row is + // renamed aside + logged, never silently dropped, and never causes + // healthy rows to be skipped. + let root = temp_root("corrupt"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_binding(&write("claude", "sess-good", "t1", 1_000)) + .unwrap(); + let bad = root.join("bindings").join("claude").join("sess-bad.json"); + std::fs::write(&bad, b"{ not json").unwrap(); + // A future-versioned row is also quarantined (ledgerVersion gates + // migration), never silently reinterpreted. + let vnext = root.join("bindings").join("claude").join("sess-vnext.json"); + std::fs::write( + &vnext, + br#"{"ledgerVersion": 999, "someFutureShape": true}"#, + ) + .unwrap(); + + let report = ledger.boot_scan(2_000, &never_absent); + assert_eq!(report.quarantined.len(), 2); + assert!(!bad.exists(), "corrupt row renamed aside"); + assert!(!vnext.exists(), "future-version row renamed aside"); + let provider_dir = root.join("bindings").join("claude"); + let quarantined: Vec = std::fs::read_dir(&provider_dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".quarantined-")) + .collect(); + assert_eq!(quarantined.len(), 2, "renamed aside, not deleted"); + // Healthy rows still served. + assert!(ledger.load_binding("claude", "sess-good").is_some()); + assert_eq!(ledger.quarantined_rows().len(), 2, "surfaced via API"); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn crash_between_binding_write_and_marker_delete_is_repaired_at_boot() { + // Red test `crash-between-binding-write-and-marker-delete`: both rows + // present (the safe crash shape the pinned order buys) -> the boot + // sweep deletes the stale marker; the binding row wins throughout. + let root = temp_root("crash-window"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_pending("t1", "codex", Some("/tmp/p"), 1_000) + .unwrap(); + ledger + .record_binding(&write("codex", "th-1", "t1", 2_000)) + .unwrap(); + // (simulates: binding written, crash before marker delete) + + let report = ledger.boot_scan(3_000, &never_absent); + assert_eq!(report.stale_markers_removed, vec!["t1".to_string()]); + assert!(ledger.list_pending_raw().is_empty()); + assert!(ledger.load_binding("codex", "th-1").is_some()); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn boot_scan_never_sweeps_a_marker_merely_because_the_terminal_is_not_live() { + // Spec §4.2: pending markers are GC'd only for terminals whose clean + // exit was observed IN THIS PROCESS EPOCH — never swept at boot just + // because the terminal isn't currently live. That would erase the + // fresh-by-race breadcrumb at exactly the boot that needs it. + let root = temp_root("marker-preserved"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_pending("t1", "opencode", Some("/tmp/p"), 1_000) + .unwrap(); + let report = ledger.boot_scan(2_000, &never_absent); + assert!(report.stale_markers_removed.is_empty()); + assert!(ledger.pending_for_terminal("t1").is_some()); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn aged_out_marker_is_swept_after_its_ttl() { + // A8/V7: a marker can leak (e.g. its pane died with a dead server and + // the terminal id is never re-minted). Lifetime is BOUNDED: a marker + // older than PENDING_MARKER_TTL_MS is swept, loudly. Fresh-by-race + // evidence matters at the boots NEAR the crash — a 30-day-old marker + // is stale noise, not evidence. + let root = temp_root("marker-ttl"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_pending("t1", "codex", Some("/tmp/p"), 1_000) + .unwrap(); + let report = ledger.boot_scan(1_000 + PENDING_MARKER_TTL_MS + 1, &never_absent); + assert_eq!(report.stale_markers_removed, vec!["t1".to_string()]); + assert!(ledger.list_pending_raw().is_empty()); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn periodic_gc_sweeps_aged_markers_without_a_restart() { + // The `gc` contract includes the aged-marker sweep (see the Interfaces + // note and the PENDING_MARKER_TTL_MS doc): the leaked-marker lifetime + // bound must hold on a LONG-RUNNING server, so the periodic path — not + // just boot_scan — must sweep aged markers. + let root = temp_root("marker-ttl-gc"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_pending("t1", "codex", Some("/tmp/p"), 1_000) + .unwrap(); + // A fresh marker survives a GC pass (never swept merely for age < TTL)... + let report = ledger.gc(2_000, &never_absent); + assert!(report.stale_markers_removed.is_empty()); + assert!(ledger.pending_for_terminal("t1").is_some()); + // ...but an aged-out one is swept by gc() alone — no boot_scan involved. + let report = ledger.gc(1_000 + PENDING_MARKER_TTL_MS + 1, &never_absent); + assert_eq!(report.stale_markers_removed, vec!["t1".to_string()]); + assert!(ledger.list_pending_raw().is_empty()); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn crash_mid_supersession_two_bound_rows_repaired_by_updated_at_tiebreak() { + // Red test `crash-mid-supersession-two-bound-rows`: the new bound row + // was written but the crash landed before the old row was retired -> + // two bound rows share a pane lineage (liveTerminalId). Boot repair: + // newer updatedAt wins, older auto-retired as superseded, loudly. + let root = temp_root("two-bound"); + // Forge the crash shape directly on disk (record_binding would retire + // the old); the ledger is constructed AFTER, so its construction-time + // index load sees the forged rows — the actual post-crash boot shape. + for (sid, at) in [("th-old", 1_000i64), ("th-new", 2_000i64)] { + let row = BindingRow { + ledger_version: LEDGER_VERSION, + provider: "codex".into(), + session_id: sid.into(), + mode: "codex".into(), + cwd: None, + live_terminal_id: Some("t1".into()), + create_request_id: None, + created_at: at, + updated_at: at, + last_observed_at: at, + state: RowState::Bound, + retired_reason: None, + superseded_by: None, + }; + write_row_atomic( + &root + .join("bindings") + .join("codex") + .join(format!("{sid}.json")), + &row, + ) + .unwrap(); + } + // Constructed AFTER the forged rows, as promised above. + let ledger = PaneLedger::new(Some(root.clone())); + + let report = ledger.boot_scan(3_000, &never_absent); + assert_eq!(report.supersession_repairs.len(), 1); + let old = ledger.load_binding("codex", "th-old").unwrap(); + assert_eq!(old.state, RowState::Retired); + assert_eq!(old.retired_reason, Some(RetiredReason::Superseded)); + assert_eq!(old.superseded_by.as_ref().unwrap().session_id, "th-new"); + let new = ledger.load_binding("codex", "th-new").unwrap(); + assert_eq!(new.state, RowState::Bound); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn gc_expires_unobserved_bound_rows_to_tombstones_never_deletion() { + let root = temp_root("gc"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_binding(&write("claude", "sess-old", "t1", 1_000)) + .unwrap(); + let now = 1_000 + BOUND_GC_TTL_MS + 1; + let report = ledger.gc(now, &never_absent); + assert_eq!(report.gc_tombstoned.len(), 1); + let row = ledger.load_binding("claude", "sess-old").unwrap(); + assert_eq!(row.state, RowState::Retired); + assert_eq!(row.retired_reason, Some(RetiredReason::GcExpired)); + // NOT deleted — a tombstone. + assert!(ledger.ever_bound("claude", "sess-old")); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn tombstone_deletion_is_conditioned_on_transcript_absence() { + let root = temp_root("tombstone"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_binding(&write("claude", "sess-x", "t1", 1_000)) + .unwrap(); + let expire_at = 1_000 + BOUND_GC_TTL_MS + 1; + ledger.gc(expire_at, &never_absent); + let delete_at = expire_at + TOMBSTONE_GC_TTL_MS + 1; + + // Transcript still on disk (or unknown) -> tombstone survives forever. + let report = ledger.gc(delete_at, &never_absent); + assert!(report.tombstones_deleted.is_empty()); + assert!(ledger.ever_bound("claude", "sess-x")); + + // Definitively absent -> deletion is finally allowed. + let report = ledger.gc(delete_at, &|_p, _s| true); + assert_eq!(report.tombstones_deleted.len(), 1); + assert!(!ledger.ever_bound("claude", "sess-x")); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn gc_expired_tombstone_rebinds_on_a_live_identity_event() { + // Spec §4.2: `retired/gc_expired -> bound` is a LEGAL transition, taken + // automatically (never-ask-when-we-can-act) and loudly logged. + let root = temp_root("revive"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_binding(&write("claude", "sess-x", "t1", 1_000)) + .unwrap(); + ledger.gc(1_000 + BOUND_GC_TTL_MS + 1, &never_absent); + assert_eq!( + ledger + .load_binding("claude", "sess-x") + .unwrap() + .retired_reason, + Some(RetiredReason::GcExpired) + ); + let revive_at = 1_000 + BOUND_GC_TTL_MS + 2; + ledger + .record_binding(&write("claude", "sess-x", "t2", revive_at)) + .unwrap(); + let row = ledger.load_binding("claude", "sess-x").unwrap(); + assert_eq!(row.state, RowState::Bound); + assert_eq!(row.retired_reason, None); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn rebind_to_the_same_identity_is_not_a_supersession() { + let root = temp_root("samebind"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_binding(&write("codex", "th-1", "t1", 1_000)) + .unwrap(); + ledger + .record_binding(&write("codex", "th-1", "t1", 2_000)) + .unwrap(); + let row = ledger.load_binding("codex", "th-1").unwrap(); + assert_eq!(row.state, RowState::Bound); + assert_eq!(row.retired_reason, None); + std::fs::remove_dir_all(&root).ok(); +} diff --git a/crates/freshell-ws/src/tabs.rs b/crates/freshell-ws/src/tabs.rs index b17ad00c2..376f5bcc6 100644 --- a/crates/freshell-ws/src/tabs.rs +++ b/crates/freshell-ws/src/tabs.rs @@ -81,6 +81,13 @@ pub struct PushAck { pub accepted: bool, pub open_records: i64, pub closed_records: i64, + /// `Some(false)` when a persistence attempt did not durably write + /// (oversize, invalid ids, io failure, cap unenforceable). `None` when + /// persisted normally or when persistence was not attempted by design + /// (empty push, persistence disabled — kata h9vt owns those semantics). + pub persisted: Option, + /// Machine-readable reason accompanying `persisted: Some(false)`. + pub persist_reason: Option, } /// Shared, cheaply-cloneable in-memory tabs registry. Lives in @@ -172,6 +179,8 @@ impl TabsRegistry { accepted: true, open_records: open_records.len() as i64, closed_records: closed_records.len() as i64, + persisted: None, + persist_reason: None, }); } } else if let Some(wm) = watermark_rev { @@ -231,8 +240,10 @@ impl TabsRegistry { ); drop(state); // release the registry mutex before filesystem I/O + let mut persisted = None; + let mut persist_reason = None; if let Some((dir, records)) = persist_input { - crate::tabs_persist::persist_generation( + match crate::tabs_persist::persist_generation( &dir, server_instance_id, device_id, @@ -241,13 +252,25 @@ impl TabsRegistry { snapshot_revision, &records, now, - ); + ) { + crate::tabs_persist::PersistOutcome::Persisted => {} + crate::tabs_persist::PersistOutcome::Skipped { reason } => { + persisted = Some(false); + persist_reason = Some(reason.to_string()); + } + crate::tabs_persist::PersistOutcome::Failed { reason } => { + persisted = Some(false); + persist_reason = Some(reason); + } + } } Ok(PushAck { accepted: true, open_records: open_count, closed_records: closed_count, + persisted, + persist_reason, }) } diff --git a/crates/freshell-ws/src/tabs_persist.rs b/crates/freshell-ws/src/tabs_persist.rs index 99c76a6ca..941a9c7ef 100644 --- a/crates/freshell-ws/src/tabs_persist.rs +++ b/crates/freshell-ws/src/tabs_persist.rs @@ -290,10 +290,12 @@ fn all_generations_parsed( Ok(order_generations_newest_first(files)) } -/// The RAW device ids that have at least one persisted generation (read from -/// each device's stored `deviceId`, so the API never leaks the encoded folder -/// name). Sorted + deduped. FAIL-LOUD: a missing root is absence (`Ok(empty)`); -/// an unreadable dir or a corrupt device file is an ERROR (`Err`). +/// The RAW device ids that have at least one persisted generation. Every +/// generation in a device dir must agree on `deviceId` (verify-all-agree): +/// a half-migrated or hand-edited dir is an ERROR, never a nondeterministic +/// first-file-wins identity. Sorted + deduped. FAIL-LOUD: a missing root is +/// absence (`Ok(empty)`); an unreadable dir, a corrupt device file, or an +/// identity conflict is an ERROR (`Err`). pub fn list_snapshot_devices(dir: &Path) -> std::io::Result> { with_persist_lock(|| list_snapshot_devices_locked(dir)) } @@ -309,19 +311,36 @@ fn list_snapshot_devices_locked(dir: &Path) -> std::io::Result> { if !dpath.is_dir() { continue; } - // First readable *.json in the device dir carries the raw deviceId. - let first_json = std::fs::read_dir(&dpath)? - .flatten() - .map(|f| f.path()) - .find(|p| p.extension().is_some_and(|x| x == "json")); - if let Some(p) = first_json { + let mut dir_id: Option = None; + for p in std::fs::read_dir(&dpath)?.flatten().map(|f| f.path()) { + if p.extension().is_none_or(|x| x != "json") { + continue; + } let v = read_generation_file(&p)?; - ids.push( - v.get("deviceId") - .and_then(Value::as_str) - .expect("validated deviceId") - .to_string(), - ); + let id = v + .get("deviceId") + .and_then(Value::as_str) + .expect("validated deviceId") + .to_string(); + match &dir_id { + None => dir_id = Some(id), + Some(existing) if *existing == id => {} + Some(existing) => { + tracing::error!(target: "freshell_ws::invariants", + dir = %dpath.display(), first = %existing, conflicting = %id, + "tabs_snapshot_device_identity_conflict: generations in one device dir disagree on deviceId; refusing to guess an identity"); + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "tabs_snapshot_device_identity_conflict: {} holds generations with conflicting deviceIds ({existing} vs {id})", + dpath.display() + ), + )); + } + } + } + if let Some(id) = dir_id { + ids.push(id); } } ids.sort(); @@ -762,10 +781,11 @@ fn sweep_orphan_tmp(device_dir: &Path) { /// atomically (tmp + rename), then enforce every retention cap: oversize skip, /// per-(device,client) generation cap, global-per-device file cap, device count /// cap. The ENTIRE read-plan-mutate cycle runs under `PERSIST_LOCK` so it is -/// atomic w.r.t. any other push (`:678`). Best-effort: any failure is a WARN -/// with the full path + error, never an Err (a failed snapshot must never fail a -/// tabs push), and a partial-write failure leaves the last-good generations -/// intact (nothing is deleted before the new file is durably renamed into place). +/// atomic w.r.t. any other push (`:678`). Best-effort w.r.t. the push (a failed +/// snapshot never fails a tabs push) but HONEST: every non-write returns a +/// non-`Persisted` outcome that the ack surfaces. A partial-write failure leaves +/// the last-good generations intact (nothing is deleted before the new file is +/// durably renamed into place). #[allow(clippy::too_many_arguments)] pub(crate) fn persist_generation( dir: &Path, @@ -776,14 +796,19 @@ pub(crate) fn persist_generation( snapshot_revision: i64, open_records: &[Value], captured_at: i64, -) { +) -> PersistOutcome { // Serialize the whole filesystem cycle (see `with_persist_lock`). - let write = || -> std::io::Result<()> { + let write = || -> std::io::Result { let Some(device_dir) = device_dir_for(dir, device_id) else { - return Ok(()); // empty/uncontainable device id -> never persist + // empty/uncontainable device id -> never persist + return Ok(PersistOutcome::Skipped { + reason: "invalid-device-id", + }); }; let Some(client_enc) = encode_device_id(client_instance_id) else { - return Ok(()); + return Ok(PersistOutcome::Skipped { + reason: "invalid-client-instance-id", + }); }; let snapshot = json!({ "deviceId": device_id, @@ -796,9 +821,10 @@ pub(crate) fn persist_generation( }); let bytes = serde_json::to_vec_pretty(&snapshot)?; if bytes.len() > MAX_SNAPSHOT_BYTES { - tracing::warn!(target: "freshell_ws::tabs", device_id = %device_id, - bytes = bytes.len(), "tabs_snapshot_skipped_oversize"); - return Ok(()); + tracing::error!(target: "freshell_ws::invariants", + device_id = %device_id, bytes = bytes.len(), max = MAX_SNAPSHOT_BYTES, + "tabs_snapshot_dropped_oversize: generation exceeds MAX_SNAPSHOT_BYTES; nothing was persisted and the ack will carry persisted:false"); + return Ok(PersistOutcome::Skipped { reason: "oversize" }); } // Device cap: if this is a NEW device dir and we're at the cap, evict // the least-recently-written device (oldest max-capturedAt) first. @@ -844,11 +870,17 @@ pub(crate) fn persist_generation( ))), }; } - Ok(()) + Ok(PersistOutcome::Persisted) }; - if let Err(err) = with_persist_lock(write) { - tracing::warn!(target: "freshell_ws::tabs", device_id = %device_id, dir = %dir.display(), - error = %err, "tabs_snapshot_persist_failed: generation not written"); + match with_persist_lock(write) { + Ok(outcome) => outcome, + Err(err) => { + tracing::warn!(target: "freshell_ws::tabs", device_id = %device_id, dir = %dir.display(), + error = %err, "tabs_snapshot_persist_failed: generation not written"); + PersistOutcome::Failed { + reason: err.to_string(), + } + } } } @@ -939,60 +971,10 @@ fn enforce_device_file_cap(device_dir: &Path) -> std::io::Result<()> { Ok(()) } -/// Enforce MAX_SNAPSHOT_DEVICES before a write. New targets reserve one slot; -/// existing targets also repair a previously over-cap root. Lease-protected -/// restores and the write target are never candidates. If no eligible victim -/// remains, fail with `WouldBlock` rather than creating another directory. -fn enforce_device_cap(root: &Path, target_dir: &Path) -> std::io::Result<()> { - let target_exists = target_dir.exists(); - let entries = match std::fs::read_dir(root) { - Ok(e) => e, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(err) => return Err(err), - }; - let mut dirs: Vec<(i64, PathBuf)> = entries - .flatten() - .map(|e| e.path()) - .filter(|p| p.is_dir()) - .map(|p| { - let newest = std::fs::read_dir(&p) - .into_iter() - .flatten() - .flatten() - .map(|f| f.path()) - .filter(|f| f.extension().is_some_and(|x| x == "json")) - .filter_map(|f| { - serde_json::from_str::(&std::fs::read_to_string(&f).ok()?) - .ok()? - .get("capturedAt") - .and_then(Value::as_i64) - }) - .max() - .unwrap_or(0); - (newest, p) - }) - .collect(); - let mut device_count = dirs.len(); - let allowed_before_write = if target_exists { - MAX_SNAPSHOT_DEVICES - } else { - MAX_SNAPSHOT_DEVICES.saturating_sub(1) - }; - dirs.retain(|(_, path)| path != target_dir && !restore_protects(path)); - while device_count > allowed_before_write { - if dirs.is_empty() { - return Err(std::io::Error::new( - std::io::ErrorKind::WouldBlock, - "snapshot device cap is exhausted: all eviction candidates are protected by active restores; retry the tabs-sync push", - )); - } - dirs.sort_by_key(|(c, _)| *c); - let (_, victim) = dirs.remove(0); - remove_dir_all_logged(&victim)?; - device_count -= 1; - } - Ok(()) -} +#[path = "tabs_persist_retention.rs"] +mod retention; +use retention::enforce_device_cap; +pub(crate) use retention::PersistOutcome; #[cfg(test)] #[path = "tabs_persist_tests.rs"] diff --git a/crates/freshell-ws/src/tabs_persist_retention.rs b/crates/freshell-ws/src/tabs_persist_retention.rs new file mode 100644 index 000000000..69ba4ba16 --- /dev/null +++ b/crates/freshell-ws/src/tabs_persist_retention.rs @@ -0,0 +1,128 @@ +//! Device-directory retention: the device-count cap and its eviction policy. +//! +//! Split out of `tabs_persist.rs` (which sits at the repo's 1,000-line file +//! limit) following the `tabs_persist_validation.rs` precedent. Included from +//! `tabs_persist.rs` via `#[path]`, so `super::*` is the `tabs_persist` +//! module and this child can use its private items. + +use super::*; + +/// The honest result of one persistence attempt. `tabs.sync.push` surfaces +/// non-persistence on the ack (`persisted:false` + reason) instead of +/// silently ACKing (campaign fail-loud principle, P2.17 defect 2). +#[must_use] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum PersistOutcome { + /// The generation was durably written. + Persisted, + /// Deliberately not written (policy: oversize or malformed identifiers). + Skipped { reason: &'static str }, + /// The write was attempted and failed (io error / cap unenforceable). + Failed { reason: String }, +} + +/// One device dir's health for eviction scoring. +struct DeviceDirHealth { + /// Max `capturedAt` over cleanly-parseable files (`i64::MIN` when the dir + /// holds no parseable generation at all, e.g. an empty dir). + newest: i64, + /// Files (or the dir listing itself) that failed to read, parse, or carry + /// an i64 `capturedAt`. + unreadable: usize, + path: PathBuf, +} + +fn scan_device_dir(path: PathBuf) -> DeviceDirHealth { + let mut newest = i64::MIN; + let mut unreadable = 0usize; + match std::fs::read_dir(&path) { + // An unlistable dir is unreadable evidence, not an empty dir. + Err(_) => unreadable += 1, + Ok(entries) => { + for f in entries.flatten().map(|e| e.path()) { + if f.extension().is_none_or(|x| x != "json") { + continue; + } + let captured = std::fs::read_to_string(&f) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| v.get("capturedAt").and_then(Value::as_i64)); + match captured { + Some(c) => newest = newest.max(c), + None => unreadable += 1, + } + } + } + } + DeviceDirHealth { + newest, + unreadable, + path, + } +} + +/// Enforce MAX_SNAPSHOT_DEVICES before a write. New targets reserve one slot; +/// existing targets also repair a previously over-cap root. Lease-protected +/// restores, the write target, and — fail-loud, campaign P2.17 defect 1 — +/// any dir holding unreadable generation files are never candidates: corrupt +/// dirs are forensic evidence, not the cheapest victim. If no cleanly +/// parseable victim remains, fail the incoming write with `WouldBlock` +/// rather than destroying evidence or creating another directory. +pub(super) fn enforce_device_cap(root: &Path, target_dir: &Path) -> std::io::Result<()> { + let target_exists = target_dir.exists(); + let entries = match std::fs::read_dir(root) { + Ok(e) => e, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err), + }; + let dirs: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_dir()) + .map(scan_device_dir) + .collect(); + let mut device_count = dirs.len(); + let allowed_before_write = if target_exists { + MAX_SNAPSHOT_DEVICES + } else { + MAX_SNAPSHOT_DEVICES.saturating_sub(1) + }; + if device_count <= allowed_before_write { + return Ok(()); + } + // Under eviction pressure only: classify candidates. Corrupt dirs are + // exempt AND loud (bounded: this only logs while over-cap). + let mut corrupt_exempt = 0usize; + let mut candidates: Vec<(i64, PathBuf)> = Vec::new(); + for d in dirs { + if d.path == *target_dir || restore_protects(&d.path) { + continue; + } + if d.unreadable > 0 { + corrupt_exempt += 1; + tracing::error!(target: "freshell_ws::invariants", + path = %d.path.display(), unreadable = d.unreadable, + "tabs_snapshot_corrupt_dir_exempt_from_eviction: device dir holds unreadable generation files; exempting it from cap eviction to preserve forensic evidence"); + continue; + } + candidates.push((d.newest, d.path)); + } + candidates.sort_by_key(|(c, _)| *c); + let mut candidates = candidates.into_iter(); + while device_count > allowed_before_write { + let Some((_, victim)) = candidates.next() else { + tracing::error!(target: "freshell_ws::invariants", + root = %root.display(), corrupt_exempt, + "tabs_snapshot_device_cap_unenforceable: no cleanly-parseable eviction candidate remains; failing the incoming write instead of destroying evidence"); + return Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "snapshot device cap is exhausted: remaining candidates are protected by active restores or hold unreadable (corrupt) generations; refusing to evict", + )); + }; + tracing::warn!(target: "freshell_ws::tabs", path = %victim.display(), + "tabs_snapshot_device_evicted: device cap reached; evicting the least-recently-written clean device dir"); + remove_dir_all_logged(&victim)?; + device_count -= 1; + } + Ok(()) +} diff --git a/crates/freshell-ws/src/tabs_persist_tests.rs b/crates/freshell-ws/src/tabs_persist_tests.rs index bb2027b03..741ad6418 100644 --- a/crates/freshell-ws/src/tabs_persist_tests.rs +++ b/crates/freshell-ws/src/tabs_persist_tests.rs @@ -29,7 +29,7 @@ fn put( captured: i64, recs: Vec, ) { - persist_generation(dir, "srv-1", device, "Dev", client, rev, &recs, captured); + let _ = persist_generation(dir, "srv-1", device, "Dev", client, rev, &recs, captured); } // Result-unwrapping helpers so the tests read cleanly (readers are fail-loud). fn union(dir: &std::path::Path, device: &str) -> Option { @@ -797,7 +797,7 @@ fn concurrent_pushes_same_and_different_devices_stay_consistent() { }; let client = format!("client-{n}"); for rev in 1..=6i64 { - persist_generation( + let _ = persist_generation( &root, "srv", &device, @@ -1237,3 +1237,262 @@ fn every_supported_pane_kind_passes_semantic_generation_validation() { "all supported pane schemas should be readable" ); } + +#[test] +fn oversize_drop_returns_skipped_and_fires_invariant_alarm() { + // Campaign fail-loud: an oversize drop must be an ERROR-class invariant + // alarm and an honest non-Persisted outcome — never a silent WARN + Ok. + let (events, _guard) = crate::invariants::capture::capture(); + let dir = tempfile::tempdir().unwrap(); + let big = "x".repeat(MAX_SNAPSHOT_BYTES + 10); + let mut rec = open_record("dev:t1", "big", 1); + rec["blob"] = json!(big); + let outcome = persist_generation(dir.path(), "srv-1", "dev", "Dev", "c1", 1, &[rec], 1000); + assert_eq!(outcome, PersistOutcome::Skipped { reason: "oversize" }); + let events = events.lock().unwrap(); + assert!( + events.iter().any(|e| e.target == "freshell_ws::invariants" + && e.message.contains("tabs_snapshot_dropped_oversize")), + "oversize drop must fire the invariant alarm, got: {events:?}" + ); +} + +#[test] +fn successful_persist_returns_persisted() { + let dir = tempfile::tempdir().unwrap(); + let outcome = persist_generation( + dir.path(), + "srv-1", + "dev", + "Dev", + "c1", + 1, + &[open_record("dev:t1", "t", 1)], + 1000, + ); + assert_eq!(outcome, PersistOutcome::Persisted); + assert_eq!(list_generations(dir.path(), "dev", "c1").len(), 1); +} + +// Corrupt every generation file in a device dir (defect-1 fixtures) and +// return the dir path. +fn corrupt_all_files(dir: &std::path::Path, device: &str) -> std::path::PathBuf { + let ddir = device_dir_for(dir, device).unwrap(); + for path in std::fs::read_dir(&ddir) + .unwrap() + .flatten() + .map(|e| e.path()) + { + if path.extension().is_some_and(|x| x == "json") { + std::fs::write(&path, b"{ not valid json").unwrap(); + } + } + ddir +} + +#[test] +fn corrupt_device_dir_is_exempt_from_cap_eviction() { + // Fail-loud: a dir with >=1 unreadable file is forensic evidence and must + // NEVER be the eviction victim. The oldest CLEAN dir evicts instead. + // (Old scoring gave an all-corrupt dir capturedAt=0 -> evicted FIRST.) + let (events, _guard) = crate::invariants::capture::capture(); + let dir = tempfile::tempdir().unwrap(); + for n in 0..MAX_SNAPSHOT_DEVICES { + let dev = format!("dev-{n:03}"); + put( + dir.path(), + &dev, + "c1", + 1, + 1000 + n as i64, + vec![open_record(&format!("{dev}:t"), "t", 1)], + ); + } + // dev-001 is nearly the oldest AND fully corrupt: the old code evicts it + // first (score 0). It must survive; clean oldest dev-000 evicts instead. + let corrupt_dir = corrupt_all_files(dir.path(), "dev-001"); + + put( + dir.path(), + "dev-new", + "c1", + 1, + 9000, + vec![open_record("dev-new:t", "new", 1)], + ); + + assert!( + corrupt_dir.exists(), + "corrupt dir must be exempt from eviction" + ); + assert!( + !device_dir_for(dir.path(), "dev-000").unwrap().exists(), + "the oldest CLEAN dir must be the victim instead" + ); + assert!( + device_dir_for(dir.path(), "dev-new").unwrap().exists(), + "the new write must land" + ); + let events = events.lock().unwrap(); + assert!( + events.iter().any(|e| e.target == "freshell_ws::invariants" + && e.message + .contains("tabs_snapshot_corrupt_dir_exempt_from_eviction")), + "exempting a corrupt dir must be loud, got: {events:?}" + ); +} + +#[test] +fn cap_unenforceable_fails_the_write_and_preserves_all_evidence() { + // When every candidate holds unreadable files, refuse to evict: fail the + // incoming write loudly rather than destroy evidence. + let (events, _guard) = crate::invariants::capture::capture(); + let dir = tempfile::tempdir().unwrap(); + for n in 0..MAX_SNAPSHOT_DEVICES { + let dev = format!("dev-{n:03}"); + put( + dir.path(), + &dev, + "c1", + 1, + 1000 + n as i64, + vec![open_record(&format!("{dev}:t"), "t", 1)], + ); + corrupt_all_files(dir.path(), &dev); + } + let outcome = persist_generation( + dir.path(), + "srv-1", + "dev-new", + "Dev", + "c1", + 1, + &[open_record("dev-new:t", "new", 1)], + 9000, + ); + assert!( + matches!(outcome, PersistOutcome::Failed { .. }), + "unenforceable cap must fail the incoming write, got {outcome:?}" + ); + assert!( + !device_dir_for(dir.path(), "dev-new").unwrap().exists(), + "no new dir may be created past the cap" + ); + let surviving = std::fs::read_dir(dir.path()) + .unwrap() + .flatten() + .filter(|e| e.path().is_dir()) + .count(); + assert_eq!( + surviving, MAX_SNAPSHOT_DEVICES, + "no corrupt dir may be destroyed" + ); + let events = events.lock().unwrap(); + assert!( + events + .iter() + .any(|e| e.message.contains("tabs_snapshot_device_cap_unenforceable")), + "must alarm loudly: {events:?}" + ); +} + +#[test] +fn mixed_device_id_dir_is_a_loud_error_not_first_file_wins() { + // Defect 3: identity used to come from whatever *.json read_dir returned + // first — nondeterministic for a half-migrated/hand-edited dir. Now every + // generation must agree, or the read fails loudly. + let (events, _guard) = crate::invariants::capture::capture(); + let dir = tempfile::tempdir().unwrap(); + put( + dir.path(), + "dev", + "c1", + 1, + 1000, + vec![open_record("dev:t", "t", 1)], + ); + // Hand-craft a second, fully VALID generation in the same dir whose + // embedded deviceId disagrees. + let ddir = device_dir_for(dir.path(), "dev").unwrap(); + let existing = std::fs::read_dir(&ddir) + .unwrap() + .flatten() + .map(|e| e.path()) + .find(|p| p.extension().is_some_and(|x| x == "json")) + .unwrap(); + let mut doc: Value = + serde_json::from_str(&std::fs::read_to_string(&existing).unwrap()).unwrap(); + doc["deviceId"] = json!("dev-other"); + std::fs::write( + ddir.join("zzz-imposter.json"), + serde_json::to_vec_pretty(&doc).unwrap(), + ) + .unwrap(); + + let err = list_snapshot_devices(dir.path()) + .expect_err("conflicting deviceIds in one dir must be an error"); + assert!( + err.to_string() + .contains("tabs_snapshot_device_identity_conflict"), + "{err}" + ); + let events = events.lock().unwrap(); + assert!( + events.iter().any(|e| e.target == "freshell_ws::invariants" + && e.message.contains("tabs_snapshot_device_identity_conflict")), + "identity conflict must alarm loudly: {events:?}" + ); +} + +#[test] +fn agreeing_multi_generation_dir_lists_exactly_one_device_id() { + // Regression guard for the fix: reading ALL files (not just the first) + // must still dedupe agreeing generations to one id. + let dir = tempfile::tempdir().unwrap(); + put( + dir.path(), + "dev", + "c1", + 1, + 1000, + vec![open_record("dev:t", "a", 1)], + ); + put( + dir.path(), + "dev", + "c2", + 1, + 2000, + vec![open_record("dev:t2", "b", 1)], + ); + assert_eq!(devices(dir.path()), vec!["dev".to_string()]); +} + +#[test] +fn persist_lock_recovers_from_poison() { + // `with_persist_lock` is documented poison-tolerant: a panic while + // persisting must not wedge all future pushes/restores. NOTE: this + // deliberately poisons the process-global PERSIST_LOCK; every later + // acquisition goes through the same into_inner() recovery, which is + // exactly the property under test. + let _ = std::thread::spawn(|| with_persist_lock(|| panic!("deliberately poison PERSIST_LOCK"))) + .join(); + let value = with_persist_lock(|| 42); + assert_eq!( + value, 42, + "a poisoned persist lock must still be acquirable" + ); + // And a real write still works end-to-end after poisoning. + let dir = tempfile::tempdir().unwrap(); + let outcome = persist_generation( + dir.path(), + "srv-1", + "dev", + "Dev", + "c1", + 1, + &[open_record("dev:t", "t", 1)], + 1000, + ); + assert_eq!(outcome, PersistOutcome::Persisted); +} diff --git a/crates/freshell-ws/src/tabs_persist_validation.rs b/crates/freshell-ws/src/tabs_persist_validation.rs index 59fefa32c..8c09c2bd1 100644 --- a/crates/freshell-ws/src/tabs_persist_validation.rs +++ b/crates/freshell-ws/src/tabs_persist_validation.rs @@ -561,3 +561,7 @@ pub(super) fn validate_generation(path: &Path, value: &Value) -> std::io::Result } Ok(()) } + +#[cfg(test)] +#[path = "tabs_persist_validation_tests.rs"] +mod validation_tests; diff --git a/crates/freshell-ws/src/tabs_persist_validation_tests.rs b/crates/freshell-ws/src/tabs_persist_validation_tests.rs new file mode 100644 index 000000000..f0e2fbc10 --- /dev/null +++ b/crates/freshell-ws/src/tabs_persist_validation_tests.rs @@ -0,0 +1,119 @@ +//! Behavior locks for `createRequestId` in persisted tabs-snapshot pane +//! payloads (Lane A1 Task 4). These tests intentionally add NO validator +//! strictness: `validate_generation` is shared by the write-accept AND read +//! paths, and one strict read failure poisons the whole device +//! (`all_generations_parsed` propagates the first `Err`) plus the cross-device +//! listing (`list_snapshot_devices`). Tolerance here is a design decision — +//! see the Task 4 rationale in +//! docs/plans/2026-07-25-createrequestid-stabilization.md. Sibling file to +//! `tabs_persist_tests.rs` (co-owned by Lane A6, hence the separate file); +//! helper shapes are mirrored from there. Registered from +//! `tabs_persist_validation.rs` (a `#[path]` child of `tabs_persist`) because +//! `tabs_persist.rs` sits at 999 lines against the 1,000-line cap +//! (port/AGENTS.md:81) — hence the double `super` below. + +use super::super::*; // grandparent = tabs_persist (this mod lives under its `validation` child) +use crate::tabs::TabsRegistry; +use serde_json::{json, Value}; + +fn open_record(tab_key: &str, tab_name: &str, updated_at: i64) -> Value { + json!({ "tabKey": tab_key, "tabId": tab_key, "tabName": tab_name, "status": "open", + "revision": updated_at, "updatedAt": updated_at, "paneCount": 0, "panes": [] }) +} + +/// Direct deterministic write (explicit captured_at + revision) — bypasses the +/// WS-handler write gate on purpose, simulating a file that is ALREADY on disk +/// (legacy server, foreign writer, historical corpus). +fn put( + dir: &std::path::Path, + device: &str, + client: &str, + rev: i64, + captured: i64, + recs: Vec, +) { + let _ = persist_generation(dir, "srv-1", device, "Dev", client, rev, &recs, captured); +} + +fn keyed_panes(terminal_key: Value, fresh_key: Value) -> Value { + json!([ + { "paneId": "terminal", "kind": "terminal", + "payload": { "mode": "shell", "shell": "system", + "createRequestId": terminal_key } }, + { "paneId": "fresh", "kind": "fresh-agent", + "payload": { "sessionType": "freshclaude", "provider": "claude", + "createRequestId": fresh_key } } + ]) +} + +#[test] +fn pane_create_request_id_string_round_trips_push_to_read_unchanged() { + // Full production write pipeline (registry push -> persist_generation), + // then the fail-loud read path. The field must survive verbatim. + let dir = tempfile::tempdir().unwrap(); + let reg = TabsRegistry::with_persist_dir(dir.path().to_path_buf()); + let mut record = open_record("dev:t", "t", 1); + record["panes"] = keyed_panes( + json!("a3f2b8d07a98b5fb2f4af05baf580000"), // server-mint shape (32 hex) + json!("req-fa-1"), // client nanoid shape + ); + reg.replace_client_snapshot("srv-1", "dev", "Dev", "client-1", 1, vec![record]) + .unwrap(); + let union = read_device_union(dir.path(), "dev") + .expect("read io") + .expect("one generation"); + let panes = &union["records"][0]["panes"]; + assert_eq!( + panes[0]["payload"]["createRequestId"], + "a3f2b8d07a98b5fb2f4af05baf580000" + ); + assert_eq!(panes[1]["payload"]["createRequestId"], "req-fa-1"); + // And the shared write-accept validator (same validate_generation as the + // read path) accepts a generation carrying the string field. + let candidate = json!({ + "deviceId": "dev", "deviceLabel": "Dev", "clientInstanceId": "client-1", + "serverInstanceId": "srv-1", "snapshotRevision": 1, "capturedAt": 0, + "records": union["records"], + }); + assert!(validate_incoming_generation(&candidate).is_ok()); +} + +#[test] +fn legacy_snapshots_without_create_request_id_stay_valid() { + let dir = tempfile::tempdir().unwrap(); + let mut record = open_record("dev:legacy", "legacy", 1); + record["panes"] = json!([ + { "paneId": "terminal", "kind": "terminal", + "payload": { "mode": "shell", "shell": "system" } }, + { "paneId": "fresh", "kind": "fresh-agent", + "payload": { "sessionType": "freshclaude", "provider": "claude" } } + ]); + put(dir.path(), "dev", "c1", 1, 1000, vec![record]); + assert!( + read_device_union(dir.path(), "dev").unwrap().is_some(), + "snapshots predating createRequestId must remain readable" + ); +} + +#[test] +fn wrong_typed_create_request_id_never_poisons_the_device() { + // THE load-bearing lock: a wrong-typed key on disk (historical corpus, + // foreign writer, version skew) must NOT convert into device-wide — or + // via list_snapshot_devices, server-wide — snapshot unreadability. If this + // test ever goes red because someone added a type check to + // validate_generation, that check is on the READ path too: remove it. + let dir = tempfile::tempdir().unwrap(); + let mut record = open_record("dev:t", "t", 1); + record["panes"] = keyed_panes(json!(42), json!(true)); + put(dir.path(), "dev", "c1", 1, 1000, vec![record]); + let union = read_device_union(dir.path(), "dev") + .expect("wrong-typed createRequestId must NOT make the device unreadable") + .expect("generation present"); + // Current (locked) behavior: the unknown field passes through verbatim — + // nothing strips it; the CONSUMERS (pane_to_create_body, REST ingress) + // drop non-strings, so the only cost is that one pane loses key identity. + let panes = &union["records"][0]["panes"]; + assert_eq!(panes[0]["payload"]["createRequestId"], 42); + assert_eq!(panes[1]["payload"]["createRequestId"], true); + assert!(list_snapshot_devices(dir.path()).is_ok()); +} diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index 6ca41a220..eec05ee2b 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -89,6 +89,17 @@ pub(crate) fn now_ms() -> i64 { .unwrap_or(0) } +/// The modes that get a spawn-time pending marker: EXACTLY the modes with a +/// registered post-spawn identity resolver (codex candidate adoption, +/// opencode/amplifier locator sweeps). NOT "any non-shell mode" (V7.md): +/// claude has create-time identity (pre-allocation) and NO resolver — its +/// degenerate no-identity payloads (mismatched-provider sessionRef, +/// empty-string session id; V5.md) spawn un-resumable and stay ledgerless +/// by design; kimi/gemini/custom extension modes have no resolver, so a +/// marker for them could never resolve and would only leak until the TTL +/// sweep. Every mode listed here MUST have a resolution hook (Tasks 8-9). +const MARKER_MODES: [&str; 3] = ["codex", "opencode", "amplifier"]; + /// Map the protocol `shell` enum to the platform `ShellType`. fn map_shell(shell: Shell) -> ShellType { match shell { @@ -294,12 +305,30 @@ pub async fn run( // `OutputQueue::drain_all`). _ = output_queue.notified() => { let mut send_failed = false; - for out in output_queue.drain_all() { + // Protocol-order guarantee: `attach.ready` (and every other + // non-output frame) travels the DIRECT `conn_rx` channel while + // replay/live output travels this bounded queue. This unbiased + // `select!` could otherwise deliver already-queued replay + // frames BEFORE the `attach.ready` enqueued ahead of them — + // inverting the documented "attach.ready, then replay, then + // live" order the client depends on (it arms its pendingReplay + // window only on ready; src/lib/terminal-attach-seq-state.ts:143, + // "attach.ready arrives before replay frames"). Drain every + // direct frame already pending before any queued output. + while let Ok(out) = conn_rx.try_recv() { if !send(&mut ws_tx, &out).await { send_failed = true; break; } } + if !send_failed { + for out in output_queue.drain_all() { + if !send(&mut ws_tx, &out).await { + send_failed = true; + break; + } + } + } if send_failed { close_reason = "send_error"; break; @@ -483,7 +512,7 @@ async fn handle_client_text( // candidate -- guarded (campaign plan §2.3.1); rejects are logged and // ignored, never answered (legacy parity ws-handler.ts:2951-2963). ClientMessage::TerminalCodexCandidatePersisted(candidate) => { - crate::codex_candidate::handle_codex_candidate_persisted(state, candidate); + crate::codex_candidate::handle_codex_candidate_persisted(state, candidate).await; true } ClientMessage::TerminalAttach(attach) => { @@ -1312,6 +1341,8 @@ async fn handle_create( // -> `terminalMetadata.retire(terminalId)` (`server/index.ts:526-534`), so a // rename cascade still resolves after this terminal's process has exited. let identity = state.identity.clone(); + // P1.8 exit hygiene: the pending-marker delete rides the same hook. + let pane_ledger = std::sync::Arc::clone(&state.pane_ledger); // Restore-across-restart fix: disarm the amplifier locator too, so an // exited (never-submitted, or already-associated) terminal's armed // entry is never left dangling (mirrors `handleExit`, @@ -1330,6 +1361,16 @@ async fn handle_create( freshell_codex::launch_lifecycle::CodexTerminalLaunchManager::global() .notify_terminal_exit(&tid); identity.retire(&tid); + // P1.8: an observed PTY exit in this epoch ends any + // identity-in-flight window — the marker's job (distinguishing + // fresh-by-race from fresh-by-intent across a SERVER death) is + // over. Best-effort; never load-bearing. INLINE sync call is + // correct HERE: the ExitHook (`pty.rs:55`, `FnOnce + Send`) runs + // on the PTY's blocking/reader thread, not an async worker — + // the one truly-synchronous ledger call site (V1.md). + if let Err(err) = pane_ledger.delete_pending(&tid) { + tracing::warn!(terminal_id = %tid, error = %err, "pane_ledger_marker_delete_failed_on_exit"); + } if let Some(locator) = &lifier_locator { locator.disarm(&tid); } @@ -1523,6 +1564,60 @@ async fn handle_create( ); } + // P1.8 (spec §4.2 write triggers): the durable ledger write rides the + // SAME identity event that seeds the in-memory registry — atomic + // temp+rename, AWAITED before the create is answered (durable-before- + // answer). It runs on the blocking pool, not the per-connection + // dispatch task: each write costs ~15ms p50 / 21-64ms p99 in fsyncs + // (V1.md), 2-3 orders past tokio's async-worker budget — the same + // reasoning as the PTY spawn_blocking above. A failure never blocks + // the create but is surfaced LIVE (surface_write_failure). + if let Some(record) = &create_meta_record { + // Identity known at spawn: claude pre-allocation (trigger a) and + // every resume/restore create (all providers) — a binding row. + if let (Some(provider), Some(session_id)) = + (record.provider.as_deref(), record.session_id.as_deref()) + { + let ledger = std::sync::Arc::clone(&state.pane_ledger); + let provider = provider.to_string(); + let session_id = session_id.to_string(); + let write_terminal_id = record.terminal_id.clone(); + let write_mode = mode.clone(); + let write_cwd = record.cwd.clone(); + let write_request_id = create.request_id.clone(); + let now = now_ms(); + let result = tokio::task::spawn_blocking(move || { + ledger.record_binding(&crate::pane_ledger::BindingWrite { + provider: &provider, + session_id: &session_id, + terminal_id: &write_terminal_id, + mode: &write_mode, + cwd: write_cwd.as_deref(), + create_request_id: Some(&write_request_id), + now_ms: now, + }) + }) + .await + .unwrap_or_else(|join_err| Err(std::io::Error::other(join_err))); + crate::pane_ledger::surface_write_failure(state, &record.terminal_id, result); + } + } else if MARKER_MODES.contains(&mode.as_str()) { + // Identity-bearing pane whose identity is still in flight (fresh + // codex/opencode/amplifier — trigger d): a durable pending marker + // from spawn until resolution deletes it (binding-first order). + let ledger = std::sync::Arc::clone(&state.pane_ledger); + let write_terminal_id = terminal_id_for_meta.clone(); + let write_mode = mode.clone(); + let write_cwd = spec.cwd.clone(); + let now = now_ms(); + let result = tokio::task::spawn_blocking(move || { + ledger.record_pending(&write_terminal_id, &write_mode, write_cwd.as_deref(), now) + }) + .await + .unwrap_or_else(|join_err| Err(std::io::Error::other(join_err))); + crate::pane_ledger::surface_write_failure(state, &terminal_id_for_meta, result); + } + let created = ServerMessage::TerminalCreated(TerminalCreated { created_at: now_ms(), request_id: create.request_id, @@ -1564,39 +1659,98 @@ fn is_canonical_claude_session_id(s: &str) -> bool { }) } -/// P0.4 server-side resolution ladder (campaign plan §2.2; this slice is -/// in-process only -- the durable-ledger and disk-scan rungs land in a later -/// slice, P1.8). A `restore:true` claude create that carried no usable client -/// id gets one more chance: the newest terminal generation for the same -/// createRequestId, consulted in both identity homes -- the same two-home -/// precedence `reconcile.rs::resolve_authoritative_ref` uses. +/// P0.4 server-side resolution ladder (campaign plan §2.2; the in-process +/// rungs landed with PR #530, the durable-ledger rung with P1.8 read 3 -- +/// only the disk-scan rung remains for a later slice). A `restore:true` +/// claude create that carried no usable client id gets one more chance: the +/// newest terminal generation for the same createRequestId, consulted in +/// both identity homes -- the same two-home precedence +/// `reconcile.rs::resolve_authoritative_ref` uses -- and, when the +/// in-process homes have no lineage (a fresh boot), the durable pane ledger. /// /// GATED on the newest generation NOT being Running (ledger A13): if it is /// still live, auto-resuming would spawn a SECOND live claude on the same /// session id -- silently wrong. Return None and fail loud instead; /// capability-on clients get live adoption via the pane_reconcile dedupe. -/// Lineage exists only for NATURAL exits: `registry.kill()` removes the row, -/// so a restore after an explicit user-kill also fails loud -- correct under -/// "never silently wrong". +/// The ledger rung applies the equivalent guard against BOTH identity homes +/// (a live identity-registry owner AND a REST-shaped live registry row). +/// An explicit user-kill removes the registry row AND retires the ledger +/// row `closed`, so a restore after user-kill still fails loud -- correct +/// under "never silently wrong". fn resolve_claude_restore_session_id(state: &WsState, create_request_id: &str) -> Option { - let newest = state + // Rungs 1-2 (in-process, PR #530) -- unchanged, except the early-return + // structure now falls THROUGH to the ledger when the in-process homes + // simply have no lineage (a fresh boot), while the A13 live-guard stays + // a HARD stop the ledger must never reverse. + if let Some(newest) = state .registry - .newest_by_create_request_id(create_request_id)?; - let row = state.registry.probe(&newest)?; - if row.status == freshell_protocol::TerminalRunStatus::Running { - return None; + .newest_by_create_request_id(create_request_id) + { + if let Some(row) = state.registry.probe(&newest) { + if row.status == freshell_protocol::TerminalRunStatus::Running { + return None; // A13 live-guard: never a second live claude on one session id + } + if let Some(sref) = state.identity.session_ref_for(&newest) { + // Retired entries included -- an exited claude's identity is + // exactly what a same-lineage restore needs. + if sref.provider == "claude" { + return Some(sref.session_id); + } + } + // Registry-side identity home (REST-created resumes carry + // identity only on the registry row). + if row.mode == "claude" { + if let Some(sid) = row.resume_session_id.filter(|s| !s.is_empty()) { + return Some(sid); + } + } + } } - if let Some(sref) = state.identity.session_ref_for(&newest) { - // Retired entries included -- an exited claude's identity is exactly - // what a same-lineage restore needs. - return (sref.provider == "claude").then_some(sref.session_id); + // Rung 3 (P1.8): the durable ledger -- the rung that survives restarts. + // `lookup_by_create_request_id` answers only `bound` rows and + // `gc_expired` tombstones (auto-resume is a legal transition); a row + // retired `closed` (user-kill) or `superseded` is never resurrected. + // The lookup is memory-only (write-through index) -- cheap inline. + let row = state + .pane_ledger + .lookup_by_create_request_id("claude", create_request_id)?; + // A13-equivalent guard, part 1: if ANY live identity-registry entry + // currently owns this session id, fail loud rather than double-resume. + if let Some(owner) = state.identity.find_by_session("claude", &row.session_id) { + if state + .registry + .probe(&owner.terminal_id) + .is_some_and(|r| r.status == freshell_protocol::TerminalRunStatus::Running) + { + return None; + } } - // Registry-side identity home (REST-created resumes carry identity only - // on the registry row). - if row.mode != "claude" { + // A13-equivalent guard, part 2 (V6.md): REST-resumed claudes are + // invisible to the identity registry AND to createRequestId lineage -- + // their only footprint is a registry row {mode:"claude", + // resume_session_id, Running}. Scan the live registry so the ledger + // rung can never green-light a second live claude on one session id. + let rest_shaped_live = state.registry.directory().into_iter().any(|entry| { + entry.mode == "claude" + && entry.resume_session_id.as_deref() == Some(row.session_id.as_str()) + && entry.status == freshell_protocol::TerminalRunStatus::Running + }); + if rest_shaped_live { + tracing::warn!( + target: "freshell_ws::pane_ledger", + session_id = %row.session_id, + "claude_restore_refused: a live (REST-shaped) claude already owns this session id" + ); return None; } - row.resume_session_id.filter(|s| !s.is_empty()) + if row.retired_reason == Some(crate::pane_ledger::RetiredReason::GcExpired) { + tracing::info!( + target: "freshell_ws::pane_ledger", + session_id = %row.session_id, + "pane_ledger_auto_resume: gc_expired tombstone revived by restore (never-ask-when-we-can-act)" + ); + } + Some(row.session_id) } /// Build the create-time `TerminalMetaRecord` for the port-side closure of @@ -2084,6 +2238,28 @@ async fn handle_detach( /// silently). async fn handle_kill(kill: TerminalKill, ws_tx: &mut WsSink, state: &WsState) -> bool { if kill_and_broadcast(state, &kill.terminal_id) { + // P1.8 trigger (e): explicit user close — best-effort retire of the + // binding (`closed`) + marker cleanup. Best-effort by spec: SIGKILL + // is the tested mode, so retire-on-close must never be load-bearing. + // `session_ref_for` is retired-INCLUSIVE, so it still answers after + // the retire() inside kill_and_broadcast. Awaited spawn_blocking: + // fsync must not pin the dispatch task (V1.md; the PTY-spawn + // precedent above). + let sref = state.identity.session_ref_for(&kill.terminal_id); + let ledger = std::sync::Arc::clone(&state.pane_ledger); + let tid = kill.terminal_id.clone(); + let now = now_ms(); + let _ = tokio::task::spawn_blocking(move || { + if let Some(sref) = sref { + if let Err(err) = ledger.retire_closed(&sref.provider, &sref.session_id, now) { + tracing::warn!(terminal_id = %tid, error = %err, "pane_ledger_retire_failed_on_kill"); + } + } + if let Err(err) = ledger.delete_pending(&tid) { + tracing::warn!(terminal_id = %tid, error = %err, "pane_ledger_marker_delete_failed_on_kill"); + } + }) + .await; return true; } let msg = ServerMessage::Error(ErrorMsg { @@ -2160,6 +2336,8 @@ async fn tabs_push_response( accepted: ack.accepted, open_records: ack.open_records, closed_records: ack.closed_records, + persisted: ack.persisted, + persist_reason: ack.persist_reason, }, ))), Err(message) => TabsPushResponse::Error(tabs_error_frame(&message)), @@ -2369,6 +2547,43 @@ mod tabs_push_validation_tests { ); } + #[tokio::test] + async fn empty_push_ack_is_unchanged_and_omits_persist_fields() { + // The empty-push skip is BY DESIGN (wipe/unload protection) and its + // semantics belong to kata h9vt — pin that it is NOT reported as a + // persistence failure and the ack shape is byte-identical to before. + let snapshots = tempfile::tempdir().unwrap(); + let tabs = crate::tabs::TabsRegistry::with_persist_dir(snapshots.path().to_path_buf()); + let frame = serde_json::json!({ + "type": "tabs.sync.push", + "deviceId": "dev-1", + "deviceLabel": "Device 1", + "clientInstanceId": "client-1", + "snapshotRevision": 1, + "records": [] + }); + match tabs_push_response(&frame, tabs, "srv-test".to_string()).await { + TabsPushResponse::Ack(message) => { + let wire = serde_json::to_value(&*message).unwrap(); + assert_eq!(wire["type"], "tabs.sync.ack"); + assert_eq!(wire["accepted"], true); + assert_eq!(wire["openRecords"], 0); + assert!( + wire.get("persisted").is_none(), + "by-design empty-push skip must not read as a persistence failure: {wire}" + ); + assert!(wire.get("persistReason").is_none(), "{wire}"); + } + TabsPushResponse::Error(error) => panic!("empty push must be accepted: {error}"), + } + assert!( + crate::tabs_persist::list_snapshot_devices(snapshots.path()) + .unwrap() + .is_empty(), + "empty push must not create a persisted generation" + ); + } + #[tokio::test] async fn custom_extension_mode_push_is_accepted_and_persisted() { let snapshots = tempfile::tempdir().unwrap(); @@ -2428,6 +2643,181 @@ mod tabs_push_validation_tests { "accepted push must update the in-memory registry" ); } + + #[tokio::test] + async fn oversize_push_acks_persisted_false_with_reason() { + let snapshots = tempfile::tempdir().unwrap(); + let tabs = crate::tabs::TabsRegistry::with_persist_dir(snapshots.path().to_path_buf()); + // Inflate via tabName (a plain validated string field) so the frame + // stays schema-valid while the persisted document exceeds the cap. + let big = "x".repeat(crate::tabs_persist::MAX_SNAPSHOT_BYTES + 10); + let frame = serde_json::json!({ + "type": "tabs.sync.push", + "deviceId": "dev-1", + "deviceLabel": "Device 1", + "clientInstanceId": "client-1", + "snapshotRevision": 1, + "records": [{ + "tabKey": "dev-1:tab-1", + "tabId": "tab-1", + "tabName": big, + "status": "open", + "revision": 1, + "updatedAt": 1, + "paneCount": 1, + "panes": [{ + "paneId": "pane-1", + "kind": "terminal", + "payload": { + "mode": "acme-custom-cli", + "shell": "system", + "sessionRef": { + "provider": "acme-custom-cli", + "sessionId": "session-1" + } + } + }] + }] + }); + + match tabs_push_response(&frame, tabs, "srv-test".to_string()).await { + TabsPushResponse::Ack(message) => match *message { + ServerMessage::TabsSyncAck(ack) => { + assert!(ack.accepted, "accepted semantics must not change"); + assert_eq!(ack.persisted, Some(false), "the ack must stop lying"); + assert_eq!(ack.persist_reason.as_deref(), Some("oversize")); + } + other => panic!("unexpected acknowledgement frame: {other:?}"), + }, + TabsPushResponse::Error(error) => { + panic!("oversize push must still be accepted: {error}") + } + } + assert!( + crate::tabs_persist::read_generation(snapshots.path(), "dev-1", 0) + .unwrap() + .is_none(), + "oversize generation must not be written" + ); + } + + #[tokio::test] + async fn normal_push_ack_omits_persist_fields_on_the_wire() { + // Wire-compat: when the write succeeds the ack must stay byte-identical + // to the pre-change shape (fields OMITTED, not null) for the frozen + // client and contract. + let snapshots = tempfile::tempdir().unwrap(); + let tabs = crate::tabs::TabsRegistry::with_persist_dir(snapshots.path().to_path_buf()); + let frame = serde_json::json!({ + "type": "tabs.sync.push", + "deviceId": "dev-1", + "deviceLabel": "Device 1", + "clientInstanceId": "client-1", + "snapshotRevision": 1, + "records": [{ + "tabKey": "dev-1:tab-1", + "tabId": "tab-1", + "tabName": "small", + "status": "open", + "revision": 1, + "updatedAt": 1, + "paneCount": 1, + "panes": [{ + "paneId": "pane-1", + "kind": "terminal", + "payload": { + "mode": "acme-custom-cli", + "shell": "system", + "sessionRef": { + "provider": "acme-custom-cli", + "sessionId": "session-1" + } + } + }] + }] + }); + match tabs_push_response(&frame, tabs, "srv-test".to_string()).await { + TabsPushResponse::Ack(message) => { + let wire = serde_json::to_value(&*message).unwrap(); + assert_eq!(wire["type"], "tabs.sync.ack"); + assert_eq!(wire["accepted"], true); + assert!( + wire.get("persisted").is_none(), + "persisted must be omitted on the wire when the write succeeded: {wire}" + ); + assert!(wire.get("persistReason").is_none(), "{wire}"); + } + TabsPushResponse::Error(error) => panic!("must be accepted: {error}"), + } + } + + /// Wave-A cross-lane pin (A6 x A1): a push whose pane payloads carry + /// `createRequestId` (Lane A1's snapshot schema addition, BOTH mint + /// shapes: 32-hex server mint and 21-char client nanoid) rides through the + /// honest-persist ack path (Lane A6) unchanged -- accepted, persisted (the + /// success ack OMITS the persisted/persistReason fields on the wire), and + /// the key round-trips into the persisted generation verbatim. + #[tokio::test] + async fn create_request_id_panes_push_persists_with_honest_ack() { + let snapshots = tempfile::tempdir().unwrap(); + let tabs = crate::tabs::TabsRegistry::with_persist_dir(snapshots.path().to_path_buf()); + let frame = serde_json::json!({ + "type": "tabs.sync.push", + "deviceId": "dev-1", + "deviceLabel": "Device 1", + "clientInstanceId": "client-1", + "snapshotRevision": 1, + "records": [{ + "tabKey": "dev-1:tab-1", + "tabId": "tab-1", + "tabName": "wave-a", + "status": "open", + "revision": 1, + "updatedAt": 1, + "paneCount": 2, + "panes": [{ + "paneId": "pane-term", + "kind": "terminal", + "payload": { + "mode": "shell", + "shell": "system", + "createRequestId": "a3f2b8d07a98b5fb2f4af05baf580000" + } + }, { + "paneId": "pane-fresh", + "kind": "fresh-agent", + "payload": { + "sessionType": "freshclaude", + "provider": "claude", + "createRequestId": "e7w-2ovQqojRoZRD6iyk_" + } + }] + }] + }); + match tabs_push_response(&frame, tabs, "srv-test".to_string()).await { + TabsPushResponse::Ack(message) => { + let wire = serde_json::to_value(&*message).unwrap(); + assert_eq!(wire["accepted"], true, "{wire}"); + assert!( + wire.get("persisted").is_none(), + "createRequestId payloads must persist cleanly (success ack omits persisted): {wire}" + ); + assert!(wire.get("persistReason").is_none(), "{wire}"); + } + TabsPushResponse::Error(error) => panic!("must be accepted: {error}"), + } + let persisted = crate::tabs_persist::read_generation(snapshots.path(), "dev-1", 0) + .unwrap() + .expect("accepted push persisted"); + assert_eq!( + persisted["records"][0]["panes"][0]["payload"]["createRequestId"], + "a3f2b8d07a98b5fb2f4af05baf580000" + ); + assert_eq!( + persisted["records"][0]["panes"][1]["payload"]["createRequestId"], + "e7w-2ovQqojRoZRD6iyk_" + ); + } } /// `tabs.sync.query` — reply `tabs.sync.snapshot` with the merged cross-device view @@ -2751,6 +3141,7 @@ mod terminals_changed_tests { let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(16).0); let rx = broadcast_tx.subscribe(); let state = WsState { + pane_ledger: std::sync::Arc::new(crate::pane_ledger::PaneLedger::disabled()), identity: crate::identity::TerminalIdentityRegistry::new(), auth_token: Arc::clone(&auth_token), server_instance_id: Arc::new("srv-1111".to_string()), @@ -2952,6 +3343,7 @@ mod terminal_meta_created_tests { let broadcast_tx = std::sync::Arc::new(tokio::sync::broadcast::channel::(16).0); let rx = broadcast_tx.subscribe(); let state = WsState { + pane_ledger: std::sync::Arc::new(crate::pane_ledger::PaneLedger::disabled()), identity: crate::identity::TerminalIdentityRegistry::new(), auth_token: std::sync::Arc::clone(&auth_token), server_instance_id: std::sync::Arc::new("srv-1111".to_string()), diff --git a/crates/freshell-ws/tests/codex_candidate_persisted.rs b/crates/freshell-ws/tests/codex_candidate_persisted.rs index 45663fc22..76d478e5f 100644 --- a/crates/freshell-ws/tests/codex_candidate_persisted.rs +++ b/crates/freshell-ws/tests/codex_candidate_persisted.rs @@ -4,7 +4,7 @@ mod common; use common::{ - connect_and_capture_inventory, next_frame_of_type, sleeper_cli_spec, spawn_server_with_specs, + connect_and_capture_inventory, next_frame_of_type, sleeper_cli_spec, spawn_server_with_ledger, }; use futures_util::SinkExt; use serde_json::json; @@ -159,8 +159,17 @@ async fn codex_candidate_persisted_guards_and_happy_path() { let _ = std::fs::remove_file(&capture); std::env::set_var("CODEX_ARGV_CAPTURE_PATH", &capture); - let (url, registry) = - spawn_server_with_specs(vec![sleeper_cli_spec("claude"), codex_capture_spec()]).await; + // P1.8: a REAL ledger dir -- adoption must durably record the identity. + // The third tuple element (the server's own Arc) is unused: durability is + // verified via a FRESH reader instance constructed AFTER the adoption. + let ledger_dir = + std::env::temp_dir().join(format!("codex-adopt-ledger-{}", std::process::id())); + std::fs::create_dir_all(&ledger_dir).unwrap(); + let (url, registry, _server_ledger) = spawn_server_with_ledger( + vec![sleeper_cli_spec("claude"), codex_capture_spec()], + &ledger_dir, + ) + .await; let (mut ws, _inventory) = connect_and_capture_inventory(&url).await; // A codex terminal with NO identity yet (fresh create, no resume). @@ -250,6 +259,24 @@ async fn codex_candidate_persisted_guards_and_happy_path() { Some(THREAD_A) ); + // P1.8: adoption is an identity event -- the ledger must now hold a + // binding row for THREAD_A, and the spawn-time pending marker must be + // gone (binding-first pinned order, spec §4.2). + let ledger = freshell_ws::pane_ledger::PaneLedger::new(Some(ledger_dir.clone())); + let row = ledger + .load_binding("codex", THREAD_A) + .expect("adoption wrote a binding row"); + assert_eq!(row.state, freshell_ws::pane_ledger::RowState::Bound); + assert_eq!(row.live_terminal_id.as_deref(), Some(codex_tid.as_str())); + assert!( + ledger.pending_for_terminal(&codex_tid).is_none(), + "pending marker resolved away" + ); + assert!(ledger + .list_pending_raw() + .iter() + .all(|m| m.terminal_id != codex_tid)); + // ---- Guard 3b: cross-pane hijack -- THREAD_A is live-bound to codex_tid ---- send_create(&mut ws, "req-codex-cand-2", "codex", json!({})).await; let created2 = next_frame_of_type(&mut ws, "terminal.created").await; @@ -350,4 +377,5 @@ async fn codex_candidate_persisted_guards_and_happy_path() { registry.kill(&claude_tid); std::env::remove_var("CODEX_HOME"); std::env::remove_var("CODEX_ARGV_CAPTURE_PATH"); + std::fs::remove_dir_all(&ledger_dir).ok(); } diff --git a/crates/freshell-ws/tests/codex_managed_launch_e2e.rs b/crates/freshell-ws/tests/codex_managed_launch_e2e.rs index deba518ad..3c87985f9 100644 --- a/crates/freshell-ws/tests/codex_managed_launch_e2e.rs +++ b/crates/freshell-ws/tests/codex_managed_launch_e2e.rs @@ -117,6 +117,7 @@ async fn spawn_server() -> (String, freshell_terminal::TerminalRegistry) { let registry = freshell_terminal::TerminalRegistry::new(); let state = WsState { + pane_ledger: std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), identity: freshell_ws::identity::TerminalIdentityRegistry::new(), auth_token: Arc::clone(&auth_token), server_instance_id: Arc::new("srv-e2e".to_string()), diff --git a/crates/freshell-ws/tests/codex_session_ref_resume.rs b/crates/freshell-ws/tests/codex_session_ref_resume.rs index 183aafaec..57bc9d155 100644 --- a/crates/freshell-ws/tests/codex_session_ref_resume.rs +++ b/crates/freshell-ws/tests/codex_session_ref_resume.rs @@ -110,6 +110,7 @@ async fn spawn_server() -> (String, freshell_terminal::TerminalRegistry) { let registry = freshell_terminal::TerminalRegistry::new(); let state = WsState { + pane_ledger: std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), identity: freshell_ws::identity::TerminalIdentityRegistry::new(), auth_token: Arc::clone(&auth_token), server_instance_id: Arc::new("srv-codex-session-ref".to_string()), diff --git a/crates/freshell-ws/tests/common/mod.rs b/crates/freshell-ws/tests/common/mod.rs index ba4c7efc1..778e734e1 100644 --- a/crates/freshell-ws/tests/common/mod.rs +++ b/crates/freshell-ws/tests/common/mod.rs @@ -98,6 +98,7 @@ pub async fn spawn_server_with_specs( let registry = freshell_terminal::TerminalRegistry::new(); let state = WsState { + pane_ledger: std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), identity: freshell_ws::identity::TerminalIdentityRegistry::new(), auth_token: Arc::clone(&auth_token), server_instance_id: Arc::new("srv-test".to_string()), @@ -149,6 +150,90 @@ pub async fn spawn_server_with_specs( (format!("ws://{addr}/ws", addr = addr), registry) } +/// [`spawn_server_with_specs`], with a REAL pane ledger rooted at +/// `ledger_dir` (P1.8 tests). Two servers pointed at the same dir model a +/// restart. Returns the server's own `Arc` too: with the +/// write-through in-memory index (Task 1 / V1.md), only writes routed +/// through the SERVER'S instance are visible to its reads — tests that +/// seed or poll the live server's ledger must use this Arc, while +/// durability assertions may still construct fresh read-only instances +/// (whose construction-time scan sees whatever is on disk). Uses the +/// lock-free `PaneLedger::new` (the flock single-writer guard is a +/// production `main.rs` concern — `new_locked`). +pub async fn spawn_server_with_ledger( + cli_commands: Vec, + ledger_dir: &std::path::Path, +) -> ( + String, + freshell_terminal::TerminalRegistry, + std::sync::Arc, +) { + let auth_token = Arc::new(AUTH_TOKEN.to_string()); + let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(64).0); + let settings = + Arc::new(serde_json::from_value(test_settings_value()).expect("valid settings fixture")); + let registry = freshell_terminal::TerminalRegistry::new(); + let pane_ledger = std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + ledger_dir.to_path_buf(), + ))); + + let state = WsState { + pane_ledger: std::sync::Arc::clone(&pane_ledger), + identity: freshell_ws::identity::TerminalIdentityRegistry::new(), + auth_token: Arc::clone(&auth_token), + server_instance_id: Arc::new("srv-test".to_string()), + boot_id: Arc::new("boot-test".to_string()), + settings, + broadcast_tx: Arc::clone(&broadcast_tx), + fresh_codex: freshell_freshagent::FreshCodexState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + serde_json::json!({ "freshAgent": { "enabled": false } }), + ), + fresh_claude: freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)), + fresh_opencode: freshell_freshagent::FreshOpencodeState::new( + freshell_freshagent::FreshAgentState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + ), + ), + registry: registry.clone(), + tabs: freshell_ws::tabs::TabsRegistry::new(), + screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), + terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + cli_commands: Arc::new(cli_commands), + shutdown: Arc::new(tokio::sync::Notify::new()), + ping_interval_ms: 30_000, + hello_timeout_ms: 5_000, + allowed_origins: Arc::new(freshell_ws::origin::default_allowed_origins()), + ws_max_payload_bytes: 16 * 1024 * 1024, + term09: freshell_ws::backpressure::Term09Config::default(), + create_protect: freshell_ws::create_limit::CreateProtectConfig::default(), + spawn_gate: std::sync::Arc::new(freshell_ws::spawn_gate::SpawnGate::new(4, 64)), + config_fallback: None, + amplifier_locator: None, + opencode_locator: None, + activity: None, + session_existence: std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), + }; + + let router = freshell_ws::router(state); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral loopback port"); + let addr = listener.local_addr().expect("local addr"); + tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + ( + format!("ws://{addr}/ws", addr = addr), + registry, + pane_ledger, + ) +} + /// Activity-enabled variant of [`spawn_server_with_specs`]: identical body, /// except a real `ActivityHub` is constructed, tapped into the registry, and /// handed to `WsState` (mirroring `freshell-server/src/main.rs`). Kept as a @@ -169,6 +254,7 @@ pub async fn spawn_server_with_specs_and_activity( registry.set_activity_observer(activity_hub.registry_observer()); let state = WsState { + pane_ledger: std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), identity: freshell_ws::identity::TerminalIdentityRegistry::new(), auth_token: Arc::clone(&auth_token), server_instance_id: Arc::new("srv-test".to_string()), @@ -233,6 +319,7 @@ pub async fn spawn_server_with_create_protect( let registry = freshell_terminal::TerminalRegistry::new(); let state = WsState { + pane_ledger: std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), identity: freshell_ws::identity::TerminalIdentityRegistry::new(), auth_token: Arc::clone(&auth_token), server_instance_id: Arc::new("srv-test".to_string()), diff --git a/crates/freshell-ws/tests/diag01_lifecycle_events.rs b/crates/freshell-ws/tests/diag01_lifecycle_events.rs index 0c1fac920..912db6baf 100644 --- a/crates/freshell-ws/tests/diag01_lifecycle_events.rs +++ b/crates/freshell-ws/tests/diag01_lifecycle_events.rs @@ -134,6 +134,7 @@ async fn spawn_server(ping_interval_ms: u64) -> String { Arc::new(serde_json::from_value(test_settings_value()).expect("valid settings fixture")); let state = WsState { + pane_ledger: std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), identity: freshell_ws::identity::TerminalIdentityRegistry::new(), auth_token: Arc::clone(&auth_token), server_instance_id: Arc::new("srv-test".to_string()), diff --git a/crates/freshell-ws/tests/freshagent_claude_attach.rs b/crates/freshell-ws/tests/freshagent_claude_attach.rs index b6762f3f0..6014b3f3b 100644 --- a/crates/freshell-ws/tests/freshagent_claude_attach.rs +++ b/crates/freshell-ws/tests/freshagent_claude_attach.rs @@ -18,6 +18,96 @@ use freshell_ws::WsState; const AUTH_TOKEN: &str = "s3cr3t-token-abcdef"; +/// Serializes every test in this file that mutates process-global env vars +/// (`FRESHELL_CLAUDE_SIDECAR` / `FRESHELL_CLAUDE_NODE` / `CLAUDE_CONFIG_DIR`), +/// mirroring `freshagent_claude_kill_interrupt.rs`'s convention for the same hazard. +static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +// ── fake claude sidecar (resume flavor: created + sdk.session.init + sdk.status) ── + +/// A minimal scripted fake claude sidecar (no real SDK, no network, no cost) speaking +/// the SAME newline-JSON protocol `spawn_sidecar()` drives the vendored package with. +/// On `create` it replies `created`, then `sdk.session.init` echoing `resumeSessionId` +/// as the durable `cliSessionId` (resume continuity -- exactly what the real sidecar's +/// SDK init does), then `sdk.status idle`; on `shutdown` it exits. +const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" +import readline from 'node:readline' + +let counter = 0 +const rl = readline.createInterface({ input: process.stdin, terminal: false }) +rl.on('line', (line) => { + const trimmed = line.trim() + if (!trimmed) return + let msg + try { + msg = JSON.parse(trimmed) + } catch { + return + } + if (msg.type === 'create') { + counter += 1 + const sessionId = `fake-claude-session-${process.pid}-${counter}` + process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId }) + '\n') + const cliSessionId = msg.resumeSessionId || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') + process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') + } else if (msg.type === 'shutdown') { + process.exit(0) + } +}) +"#; + +/// A fresh temp dir holding the fake sidecar script, with `FRESHELL_CLAUDE_SIDECAR`/ +/// `FRESHELL_CLAUDE_NODE` pointed at it, PLUS a seeded claude transcript store with +/// `CLAUDE_CONFIG_DIR` pointed at it. Caller must hold [`CLAUDE_ENV_LOCK`] for the +/// lifetime of the returned guard. +struct FakeClaudeResumeEnv { + dir: std::path::PathBuf, +} +impl FakeClaudeResumeEnv { + fn install(durable: &str) -> Self { + let dir = std::env::temp_dir().join(format!( + "freshell-fake-claude-resume-ws-{}", + uuid_like_suffix() + )); + std::fs::create_dir_all(&dir).expect("create fake sidecar temp dir"); + let script = dir.join("fake-claude-sidecar.mjs"); + std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); + // Seed the transcript store: one user line carrying an EXISTING cwd ("/tmp"), + // so the resume request goes by durable UUID + original cwd (ledger A15). + let store = dir.join("claude-store"); + let project = store.join("projects").join("-t"); + std::fs::create_dir_all(&project).expect("create transcript project dir"); + std::fs::write( + project.join(format!("{durable}.jsonl")), + r#"{"type":"user","cwd":"/tmp","message":{"role":"user","content":[{"type":"text","text":"hi"}]}}"#, + ) + .expect("seed transcript"); + std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); + std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); + std::env::set_var("CLAUDE_CONFIG_DIR", &store); + Self { dir } + } +} +impl Drop for FakeClaudeResumeEnv { + fn drop(&mut self) { + std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); + std::env::remove_var("FRESHELL_CLAUDE_NODE"); + std::env::remove_var("CLAUDE_CONFIG_DIR"); + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +/// Dependency-free unique suffix (avoids pulling in `uuid` for this test crate). +fn uuid_like_suffix() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("{nanos}-{:?}", std::thread::current().id()) +} + // ── server harness (duplicated from diag01_lifecycle_events.rs's convention, with // `freshAgent.enabled: true` so `freshAgent.create` actually dispatches) ── @@ -48,6 +138,7 @@ async fn spawn_server() -> String { Arc::new(serde_json::from_value(test_settings_value()).expect("valid settings fixture")); let state = WsState { + pane_ledger: std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), identity: freshell_ws::identity::TerminalIdentityRegistry::new(), auth_token: Arc::clone(&auth_token), server_instance_id: Arc::new("srv-test".to_string()), @@ -201,6 +292,46 @@ async fn claude_attach_for_untracked_session_emits_lost_session_frame_over_ws() assert_eq!(frame["event"]["code"], "INVALID_SESSION_ID"); } +/// Restart parity (Task 6): an attach for an untracked session that DOES carry a +/// durable claude UUID with a resumable transcript must be resumed in place -- the +/// server spawns a sidecar with `resumeSessionId` and emits the idle +/// `freshAgent.session.snapshot` whose `timelineSessionId` is the durable UUID (the +/// frozen client persists it unvalidated -- NEVER a nanoid), all under the CLIENT's +/// original session id. Before the fix this attach produced the lost frame instead +/// (this test then fails with `await_frame` panicking on its timeout budget). +#[tokio::test] +async fn claude_attach_with_resumable_transcript_resumes_and_emits_snapshot_over_ws() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let durable = "abababab-abab-4bab-8bab-abababababab"; + let _env = FakeClaudeResumeEnv::install(durable); + + let url = spawn_server().await; + let mut ws = connect_and_complete_handshake(&url).await; + + send_json( + &mut ws, + &serde_json::json!({ + "type": "freshAgent.attach", + "provider": "claude", + "sessionId": "gone-after-restart", + "sessionType": "freshclaude", + "resumeSessionId": durable, + "sessionRef": { "provider": "claude", "sessionId": durable }, + }), + ) + .await; + + let frame = await_frame(&mut ws, Duration::from_secs(15), |v| { + v["type"] == "freshAgent.event" && v["event"]["type"] == "freshAgent.session.snapshot" + }) + .await; + + assert_eq!(frame["sessionId"], "gone-after-restart"); + assert_eq!(frame["sessionType"], "freshclaude"); + assert_eq!(frame["event"]["status"], "idle"); + assert_eq!(frame["event"]["timelineSessionId"], durable); +} + /// Kilroy panes ride the same claude provider arm with `sessionType: "kilroy"`; the /// envelope must echo it (through the real serde parse of `ClientMessage`, which the /// unit tests bypass) or the client builds the wrong session locator. diff --git a/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs b/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs index 739f29562..df44c89ef 100644 --- a/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs +++ b/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs @@ -169,6 +169,7 @@ async fn spawn_server() -> String { Arc::new(serde_json::from_value(test_settings_value()).expect("valid settings fixture")); let state = WsState { + pane_ledger: std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), identity: freshell_ws::identity::TerminalIdentityRegistry::new(), auth_token: Arc::clone(&auth_token), server_instance_id: Arc::new("srv-test".to_string()), diff --git a/crates/freshell-ws/tests/hello_timeout.rs b/crates/freshell-ws/tests/hello_timeout.rs index bf0ba9cac..c3d08eec0 100644 --- a/crates/freshell-ws/tests/hello_timeout.rs +++ b/crates/freshell-ws/tests/hello_timeout.rs @@ -54,6 +54,7 @@ async fn spawn_server(hello_timeout_ms: u64) -> String { Arc::new(serde_json::from_value(test_settings_value()).expect("valid settings fixture")); let state = WsState { + pane_ledger: std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), identity: freshell_ws::identity::TerminalIdentityRegistry::new(), auth_token: Arc::clone(&auth_token), server_instance_id: Arc::new("srv-test".to_string()), diff --git a/crates/freshell-ws/tests/keepalive.rs b/crates/freshell-ws/tests/keepalive.rs index f3df922ef..37132e772 100644 --- a/crates/freshell-ws/tests/keepalive.rs +++ b/crates/freshell-ws/tests/keepalive.rs @@ -55,6 +55,7 @@ async fn spawn_server( Arc::new(serde_json::from_value(test_settings_value()).expect("valid settings fixture")); let state = WsState { + pane_ledger: std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), identity: freshell_ws::identity::TerminalIdentityRegistry::new(), auth_token: Arc::clone(&auth_token), server_instance_id: Arc::new("srv-test".to_string()), diff --git a/crates/freshell-ws/tests/max_payload.rs b/crates/freshell-ws/tests/max_payload.rs index a9d605df5..aa9a0b06c 100644 --- a/crates/freshell-ws/tests/max_payload.rs +++ b/crates/freshell-ws/tests/max_payload.rs @@ -55,6 +55,7 @@ async fn spawn_server(ws_max_payload_bytes: usize) -> String { Arc::new(serde_json::from_value(test_settings_value()).expect("valid settings fixture")); let state = WsState { + pane_ledger: std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), identity: freshell_ws::identity::TerminalIdentityRegistry::new(), auth_token: Arc::clone(&auth_token), server_instance_id: Arc::new("srv-test".to_string()), diff --git a/crates/freshell-ws/tests/origin_policy.rs b/crates/freshell-ws/tests/origin_policy.rs index 3f6f2b467..b8f197f4f 100644 --- a/crates/freshell-ws/tests/origin_policy.rs +++ b/crates/freshell-ws/tests/origin_policy.rs @@ -45,6 +45,7 @@ async fn spawn_server(allowed_origins: Vec) -> (String, String) { Arc::new(serde_json::from_value(test_settings_value()).expect("valid settings fixture")); let state = WsState { + pane_ledger: std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), identity: freshell_ws::identity::TerminalIdentityRegistry::new(), auth_token: Arc::clone(&auth_token), server_instance_id: Arc::new("srv-test".to_string()), diff --git a/crates/freshell-ws/tests/pane_ledger_restore.rs b/crates/freshell-ws/tests/pane_ledger_restore.rs new file mode 100644 index 000000000..1d4cd2280 --- /dev/null +++ b/crates/freshell-ws/tests/pane_ledger_restore.rs @@ -0,0 +1,309 @@ +//! P1.8 read-side integration tests, exercised across server "generations" +//! sharing one ledger dir. Honesty (V7.md/V9.md): Read 1 (inventory +//! stamping) has NO production window today and its test FABRICATES one; +//! Read 3's ledger rung is production-reachable only via the orphaned +//! in-flight-create replay shape until P1.6 — comments on each test say +//! which. Read 2 (`ever_observed`) is live from day one. + +mod common; +use common::*; + +use freshell_ws::pane_ledger::BindingWrite; + +fn unique_ledger_dir(label: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "pane-ledger-read-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).expect("ledger dir"); + dir +} + +#[tokio::test(flavor = "multi_thread")] +async fn inventory_stamping_falls_back_to_ledger_bound_rows() { + // Authority chain (spec §4.2 precedence): in-memory registry first, + // ledger bound rows second. HONESTY (V7.md / A21): this window is + // FABRICATED — in production today, in-memory identity is written + // adjacent to every ledger write and survives retirement, so a live + // terminal with a ledger row but no in-memory identity does not occur; + // the mainline consumer of this read arrives with Phase 3 / P1.13 + // (REST-created panes). The seam is pinned here so that consumer lands + // on tested ground. + let dir = unique_ledger_dir("stamp"); + let (url, registry, server_ledger) = + spawn_server_with_ledger(vec![sleeper_cli_spec("codex")], &dir).await; + let (mut ws, _inv) = connect_and_capture_inventory(&url).await; + + // Fresh codex: no in-memory identity entry is seeded at create. + let create = serde_json::json!({ + "type": "terminal.create", + "requestId": "req-stamp-1", + "mode": "codex", + "shell": "system", + "cwd": std::env::temp_dir().to_string_lossy(), + }); + use futures_util::SinkExt; + ws.send(tokio_tungstenite::tungstenite::Message::Text( + create.to_string(), + )) + .await + .unwrap(); + let created = next_frame_of_type(&mut ws, "terminal.created").await; + let terminal_id = created["terminalId"].as_str().unwrap().to_string(); + assert!( + created.get("sessionRef").is_none(), + "fresh codex has no create-time identity (precondition)" + ); + + // FABRICATE the window (see the test-top comment): seed a bound row for + // this terminal WITHOUT the in-memory identity upsert that production + // always performs alongside it. Written through the SERVER'S OWN Arc — + // with the write-through index, only the server instance's writes are + // visible to its own reads. + server_ledger + .record_binding(&BindingWrite { + provider: "codex", + session_id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + terminal_id: &terminal_id, + mode: "codex", + cwd: None, + create_request_id: Some("req-stamp-1"), + now_ms: 1_000, + }) + .unwrap(); + + // A NEW connection's handshake inventory row must now be stamped from + // the ledger (in-memory identity is still absent). + let (_ws2, inventory) = connect_and_capture_inventory(&url).await; + let row = inventory["terminals"] + .as_array() + .unwrap() + .iter() + .find(|t| t["terminalId"] == terminal_id.as_str()) + .expect("terminal in inventory"); + assert_eq!(row["sessionRef"]["provider"], "codex"); + assert_eq!( + row["sessionRef"]["sessionId"], + "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + ); + + registry.kill(&terminal_id); + std::fs::remove_dir_all(&dir).ok(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn claude_restore_resolves_via_the_ledger_across_a_restart() { + // The P1.8 ladder rung: generation 1 pre-allocates a claude session id + // and durably records it; generation 2 (fresh process state) receives + // restore:true with ONLY the createRequestId and must auto-resume via + // the ledger instead of rejecting with RESTORE_UNAVAILABLE. + // + // HONESTY (V9.md / A11): the production shape that presents the SAME + // createRequestId across a restart is the ORPHANED IN-FLIGHT CREATE + // (pane never anchored — no terminalId — replays its persisted id, + // TerminalView.tsx:4309-4333 / persistMiddleware.ts:229). The mainline + // browser-closed/cleared-client restores RE-MINT the id and stay on + // RESTORE_UNAVAILABLE until P1.6. This test's wire shape matches the + // orphaned-create replay; the rung is an advisory lookup, never an + // identity join key (spec: "NOT keyed on createRequestId"). + let dir = unique_ledger_dir("ladder"); + use futures_util::SinkExt; + + // --- Generation 1 --- + let session_id; + { + let (url, registry, _ledger1) = + spawn_server_with_ledger(vec![sleeper_cli_spec("claude")], &dir).await; + let (mut ws, _inv) = connect_and_capture_inventory(&url).await; + let create = serde_json::json!({ + "type": "terminal.create", + "requestId": "req-ladder-1", + "mode": "claude", + "shell": "system", + "cwd": std::env::temp_dir().to_string_lossy(), + }); + ws.send(tokio_tungstenite::tungstenite::Message::Text( + create.to_string(), + )) + .await + .unwrap(); + let created = next_frame_of_type(&mut ws, "terminal.created").await; + session_id = created["sessionRef"]["sessionId"] + .as_str() + .unwrap() + .to_string(); + // Kill the PTY so generation 1 dies "abruptly" from the ledger's + // point of view (registry rows don't survive process death anyway; + // the ledger row must). NOTE: registry.kill models the PROCESS + // dying with the server — the ledger row stays BOUND because the + // wire kill path (handle_kill's retire_closed hygiene) was never + // invoked. + let tid = created["terminalId"].as_str().unwrap(); + registry.kill(tid); + } // generation 1 dropped — its in-memory identity dies with it + + // --- Generation 2, same ledger dir, fresh everything else --- + let (url2, registry2, _ledger2) = + spawn_server_with_ledger(vec![sleeper_cli_spec("claude")], &dir).await; + let (mut ws2, _inv2) = connect_and_capture_inventory(&url2).await; + let restore = serde_json::json!({ + "type": "terminal.create", + "requestId": "req-ladder-1", + "mode": "claude", + "shell": "system", + "restore": true, + "cwd": std::env::temp_dir().to_string_lossy(), + }); + ws2.send(tokio_tungstenite::tungstenite::Message::Text( + restore.to_string(), + )) + .await + .unwrap(); + let created2 = next_frame_of_type(&mut ws2, "terminal.created").await; + assert_eq!( + created2["sessionRef"]["sessionId"].as_str().unwrap(), + session_id, + "generation 2 auto-resumed the ledgered identity (never RESTORE_UNAVAILABLE)" + ); + // Cleanup: don't leave generation 2's sleeper running for 30s. + registry2.kill(created2["terminalId"].as_str().unwrap()); + std::fs::remove_dir_all(&dir).ok(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn claude_restore_still_fails_loud_when_the_ledger_row_was_closed() { + // Preserved judgment: an explicit user-kill retires the row `closed`; + // the ladder's ledger rung must NOT resurrect it — fail loud, exactly + // like the in-process kill path today. + let dir = unique_ledger_dir("ladder-closed"); + use futures_util::SinkExt; + let (url, _registry, _ledger) = + spawn_server_with_ledger(vec![sleeper_cli_spec("claude")], &dir).await; + let (mut ws, _inv) = connect_and_capture_inventory(&url).await; + let create = serde_json::json!({ + "type": "terminal.create", + "requestId": "req-closed-1", + "mode": "claude", + "shell": "system", + "cwd": std::env::temp_dir().to_string_lossy(), + }); + ws.send(tokio_tungstenite::tungstenite::Message::Text( + create.to_string(), + )) + .await + .unwrap(); + let created = next_frame_of_type(&mut ws, "terminal.created").await; + let tid = created["terminalId"].as_str().unwrap().to_string(); + // Explicit USER close (the wire kill path -> retire_closed). + let kill = serde_json::json!({ "type": "terminal.kill", "terminalId": tid }); + ws.send(tokio_tungstenite::tungstenite::Message::Text( + kill.to_string(), + )) + .await + .unwrap(); + let _ = next_frame_of_type(&mut ws, "terminals.changed").await; + + // Restore of the killed lineage (same requestId, no client id): + // the registry row is REMOVED by kill, and the ledger row is `closed` + // -> RESTORE_UNAVAILABLE, same as today. + let restore = serde_json::json!({ + "type": "terminal.create", + "requestId": "req-closed-1", + "mode": "claude", + "shell": "system", + "restore": true, + "cwd": std::env::temp_dir().to_string_lossy(), + }); + ws.send(tokio_tungstenite::tungstenite::Message::Text( + restore.to_string(), + )) + .await + .unwrap(); + let error = next_frame_of_type(&mut ws, "error").await; + assert_eq!( + error["code"], + serde_json::json!("RESTORE_UNAVAILABLE"), + "exact wire code: {error}" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn claude_restore_is_refused_while_a_rest_shaped_live_claude_owns_the_session() { + // A13 SAFETY red test (V6.md): a claude resumed via the freshagent REST + // API is invisible to identity.find_by_session (never upserted) AND to + // createRequestId lineage (REST mints none) — its ONLY footprint is a + // registry row {mode:"claude", resume_session_id:S, status:Running}. + // The ledger rung's live-guard must scan registry rows, or it would + // green-light a second live claude on S. + let dir = unique_ledger_dir("ladder-rest-live"); + use futures_util::SinkExt; + let (url, registry, server_ledger) = + spawn_server_with_ledger(vec![sleeper_cli_spec("claude")], &dir).await; + let (mut ws, _inv) = connect_and_capture_inventory(&url).await; + + const SESSION: &str = "22222222-3333-4444-8555-666666666666"; + + // Forge the REST shape: a live registry row with resume_session_id set + // but NO identity-registry entry and NO createRequestId. The registry's + // headless seam (`register_headless`, registry.rs — "crate tests seed + // live/exited terminal generations deterministically") registers a + // Running row exactly like freshagent's spawn_terminal_pane leaves one: + // mode "claude", resume_session_id SESSION, createRequestId None + // (terminal_tabs.rs:866-877 passes None; :926 set_meta stamps the + // resume id), and no identity upsert. + registry.register_headless(freshell_terminal::registry::HeadlessTerminal { + terminal_id: "rest-claude-1".to_string(), + stream_id: "S-rest-claude-1".to_string(), + mode: "claude".to_string(), + resume_session_id: Some(SESSION.to_string()), + create_request_id: None, + created_at: None, + }); + + // Seed the ledger with a bound row for the same session, carrying a + // createRequestId a restore will present. Written via the SERVER'S Arc + // (write-through index visibility). + server_ledger + .record_binding(&BindingWrite { + provider: "claude", + session_id: SESSION, + terminal_id: "gen1-terminal", + mode: "claude", + cwd: None, + create_request_id: Some("req-rest-live-1"), + now_ms: 1_000, + }) + .unwrap(); + + // The restore presents the ledgered requestId. Without the registry + // scan the rung would answer SESSION and double-resume; with it, the + // live REST claude is detected -> RESTORE_UNAVAILABLE, fail loud. + let restore = serde_json::json!({ + "type": "terminal.create", + "requestId": "req-rest-live-1", + "mode": "claude", + "shell": "system", + "restore": true, + "cwd": std::env::temp_dir().to_string_lossy(), + }); + ws.send(tokio_tungstenite::tungstenite::Message::Text( + restore.to_string(), + )) + .await + .unwrap(); + let error = next_frame_of_type(&mut ws, "error").await; + assert_eq!( + error["code"], + serde_json::json!("RESTORE_UNAVAILABLE"), + "exact wire code: {error}" + ); + + // Cleanup: remove the forged headless row (no real PTY behind it). + registry.kill("rest-claude-1"); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/freshell-ws/tests/pane_ledger_triggers.rs b/crates/freshell-ws/tests/pane_ledger_triggers.rs new file mode 100644 index 000000000..61a88a2a9 --- /dev/null +++ b/crates/freshell-ws/tests/pane_ledger_triggers.rs @@ -0,0 +1,256 @@ +//! P1.8 write-trigger integration tests: a REAL axum server + REAL WS client +//! (shared harness), asserting the on-disk ledger rows that identity events +//! must produce — including across a "restart" (a second PaneLedger instance +//! over the same dir; the crate-level shape of the SIGKILL wall tests). + +mod common; + +use common::{ + connect_and_capture_inventory, next_frame_of_type, sleeper_cli_spec, spawn_server_with_ledger, +}; +use freshell_ws::pane_ledger::{PaneLedger, RetiredReason, RowState}; +use futures_util::SinkExt; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +/// Next text frame of ANY type. The harness's `next_frame_of_type` drops +/// mismatched frames, which the write-failure test cannot afford (it must +/// capture two frames whose relative order is not guaranteed). +#[cfg(unix)] +async fn next_any_frame( + ws: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, +) -> serde_json::Value { + use futures_util::StreamExt; + loop { + let msg = tokio::time::timeout(std::time::Duration::from_secs(10), ws.next()) + .await + .expect("frame within 10s") + .expect("stream open") + .expect("ws ok"); + if let tokio_tungstenite::tungstenite::Message::Text(text) = msg { + return serde_json::from_str(&text).expect("json frame"); + } + } +} + +fn unique_ledger_dir(label: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "pane-ledger-it-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).expect("ledger dir"); + dir +} + +/// Poll (≤5s, the spec's wall) until `check` passes — identity durability +/// must be an event-driven guarantee, not a cadence race. +fn wait_for bool>(check: F, what: &str) { + for _ in 0..50 { + if check() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + panic!("timed out (5s wall) waiting for {what}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn claude_preallocation_writes_a_binding_row_synchronously() { + // Red test `SIGKILL-within-5s-of-pane-creation`, crate shape: by the + // time terminal.created is answered, the binding row is on disk — a + // SIGKILL any moment later cannot lose the identity. (The write runs + // in an AWAITED spawn_blocking before the reply — same guarantee, + // off the dispatch task; V1.md.) + let dir = unique_ledger_dir("claude-prealloc"); + let (url, registry, _ledger_arc) = + spawn_server_with_ledger(vec![sleeper_cli_spec("claude")], &dir).await; + let (mut ws, _inv) = connect_and_capture_inventory(&url).await; + + // Fresh claude create — the server pre-allocates the session UUID. + let create = serde_json::json!({ + "type": "terminal.create", + "requestId": "req-claude-1", + "mode": "claude", + "shell": "system", + "cwd": std::env::temp_dir().to_string_lossy(), + }); + ws.send(WsMessage::Text(create.to_string())).await.unwrap(); + let created = next_frame_of_type(&mut ws, "terminal.created").await; + let terminal_id = created["terminalId"].as_str().unwrap().to_string(); + let session_id = created["sessionRef"]["sessionId"] + .as_str() + .unwrap() + .to_string(); + + // The row must already be durable (the create handler awaits the + // write before answering). + let ledger = PaneLedger::new(Some(dir.clone())); + let row = ledger + .load_binding("claude", &session_id) + .expect("binding row written at create"); + assert_eq!(row.state, RowState::Bound); + assert_eq!(row.live_terminal_id.as_deref(), Some(terminal_id.as_str())); + assert_eq!(row.create_request_id.as_deref(), Some("req-claude-1")); + assert_eq!(row.mode, "claude"); + + // Claude NEVER gets a pending marker — no resolver exists to clear it + // (the marker trigger is an explicit resolver allowlist; V5.md/V7.md). + assert!(ledger.pending_for_terminal(&terminal_id).is_none()); + assert!(ledger.list_pending_raw().is_empty()); + + // "Restart": a brand-new ledger instance over the same dir still + // answers — process death cannot lose it (its construction-time index + // load reads the on-disk rows). + drop(ledger); + let gen2 = PaneLedger::new(Some(dir.clone())); + assert!(gen2.ever_bound("claude", &session_id)); + + registry.kill(&terminal_id); + std::fs::remove_dir_all(&dir).ok(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn fresh_identity_bearing_pane_gets_a_pending_marker_at_spawn() { + // Trigger (d): identity in flight (fresh codex — no resume id) -> + // durable pending marker from spawn until resolution. + let dir = unique_ledger_dir("codex-pending"); + let (url, registry, server_ledger) = + spawn_server_with_ledger(vec![sleeper_cli_spec("codex")], &dir).await; + let (mut ws, _inv) = connect_and_capture_inventory(&url).await; + + let create = serde_json::json!({ + "type": "terminal.create", + "requestId": "req-codex-1", + "mode": "codex", + "shell": "system", + "cwd": std::env::temp_dir().to_string_lossy(), + }); + ws.send(WsMessage::Text(create.to_string())).await.unwrap(); + let created = next_frame_of_type(&mut ws, "terminal.created").await; + let terminal_id = created["terminalId"].as_str().unwrap().to_string(); + + // Durability: a FRESH reader instance (constructed after the write — + // its index load scans the dir) sees the marker on disk. + let ledger = PaneLedger::new(Some(dir.clone())); + let marker = ledger + .pending_for_terminal(&terminal_id) + .expect("pending marker written at spawn"); + assert_eq!(marker.mode, "codex"); + + // Observed exit IN THIS EPOCH ends the identity-in-flight window: the + // kill path must delete the marker (spec §4.2 marker GC rule). Poll the + // SERVER'S OWN ledger Arc — reads answer from the in-memory index, so + // only the mutating instance observes its own later deletions. + let kill = serde_json::json!({ "type": "terminal.kill", "terminalId": terminal_id }); + ws.send(WsMessage::Text(kill.to_string())).await.unwrap(); + wait_for( + || server_ledger.pending_for_terminal(&terminal_id).is_none(), + "marker deleted on observed kill", + ); + + let _ = registry; // terminal already killed + std::fs::remove_dir_all(&dir).ok(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn resume_create_writes_binding_and_kill_retires_it_closed() { + // Trigger (a/e): a resume create (identity known at spawn) writes the + // binding row; an explicit user kill best-effort retires it `closed` — + // never load-bearing, but recorded. + let dir = unique_ledger_dir("resume-retire"); + let (url, _registry, server_ledger) = + spawn_server_with_ledger(vec![sleeper_cli_spec("codex")], &dir).await; + let (mut ws, _inv) = connect_and_capture_inventory(&url).await; + + let create = serde_json::json!({ + "type": "terminal.create", + "requestId": "req-codex-2", + "mode": "codex", + "shell": "system", + "cwd": std::env::temp_dir().to_string_lossy(), + "sessionRef": { "provider": "codex", "sessionId": "11111111-2222-3333-4444-555555555555" }, + }); + ws.send(WsMessage::Text(create.to_string())).await.unwrap(); + let created = next_frame_of_type(&mut ws, "terminal.created").await; + let terminal_id = created["terminalId"].as_str().unwrap().to_string(); + + let ledger = PaneLedger::new(Some(dir.clone())); + let row = ledger + .load_binding("codex", "11111111-2222-3333-4444-555555555555") + .expect("resume create wrote the binding"); + assert_eq!(row.state, RowState::Bound); + + let kill = serde_json::json!({ "type": "terminal.kill", "terminalId": terminal_id }); + ws.send(WsMessage::Text(kill.to_string())).await.unwrap(); + // Poll the SERVER'S ledger Arc (reads are index-backed; only the + // mutating instance observes its own later writes). + wait_for( + || { + server_ledger + .load_binding("codex", "11111111-2222-3333-4444-555555555555") + .is_some_and(|r| { + r.state == RowState::Retired && r.retired_reason == Some(RetiredReason::Closed) + }) + }, + "binding retired closed on user kill", + ); + std::fs::remove_dir_all(&dir).ok(); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn ledger_write_failure_surfaces_live_and_never_blocks_the_create() { + // Red test `ledger-write-failure-surfaces-live` (spec §4.2): break the + // store (read-only dir), create a claude pane. The create MUST succeed + // (fail loud, degrade to status quo) and a `durability.degraded` frame + // MUST arrive at failure time — before any restart could make the + // warning posthumous. + use std::os::unix::fs::PermissionsExt; + let dir = unique_ledger_dir("write-fail"); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).unwrap(); + + let (url, registry, _ledger_arc) = + spawn_server_with_ledger(vec![sleeper_cli_spec("claude")], &dir).await; + let (mut ws, _inv) = connect_and_capture_inventory(&url).await; + + let create = serde_json::json!({ + "type": "terminal.create", + "requestId": "req-fail-1", + "mode": "claude", + "shell": "system", + "cwd": std::env::temp_dir().to_string_lossy(), + }); + ws.send(WsMessage::Text(create.to_string())).await.unwrap(); + + // Both frames arrive; capture order-independently (broadcast vs direct + // send interleave). next_frame_of_type drops mismatches, so scan for + // the degraded frame FIRST, then the created frame cannot have been + // consumed... instead: collect frames until both seen. + let mut created: Option = None; + let mut degraded: Option = None; + for _ in 0..20 { + let frame = next_any_frame(&mut ws).await; // helper above + match frame["type"].as_str() { + Some("terminal.created") => created = Some(frame), + Some("durability.degraded") => degraded = Some(frame), + _ => {} + } + if created.is_some() && degraded.is_some() { + break; + } + } + let created = created.expect("create succeeded despite ledger failure"); + let degraded = degraded.expect("durability.degraded pushed LIVE at failure time"); + assert_eq!(degraded["reason"], "ledger_write_failed"); + assert_eq!(degraded["terminalId"], created["terminalId"]); + + let tid = created["terminalId"].as_str().unwrap(); + registry.kill(tid); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).ok(); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/freshell-ws/tests/pane_reconcile.rs b/crates/freshell-ws/tests/pane_reconcile.rs index c79777ed2..c1db5b723 100644 --- a/crates/freshell-ws/tests/pane_reconcile.rs +++ b/crates/freshell-ws/tests/pane_reconcile.rs @@ -69,6 +69,7 @@ async fn spawn_server() -> Server { let identity = freshell_ws::identity::TerminalIdentityRegistry::new(); let state = WsState { + pane_ledger: std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), identity: identity.clone(), auth_token: Arc::clone(&auth_token), server_instance_id: Arc::new("srv-test".to_string()), diff --git a/crates/freshell-ws/tests/safe08_restore_diagnostics.rs b/crates/freshell-ws/tests/safe08_restore_diagnostics.rs index 0b6f10843..c0a949981 100644 --- a/crates/freshell-ws/tests/safe08_restore_diagnostics.rs +++ b/crates/freshell-ws/tests/safe08_restore_diagnostics.rs @@ -141,6 +141,7 @@ async fn spawn_server() -> String { Arc::new(serde_json::from_value(test_settings_value()).expect("valid settings fixture")); let state = WsState { + pane_ledger: std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), identity: freshell_ws::identity::TerminalIdentityRegistry::new(), auth_token: Arc::clone(&auth_token), server_instance_id: Arc::new("srv-test".to_string()), diff --git a/crates/freshell-ws/tests/term09_output_queue.rs b/crates/freshell-ws/tests/term09_output_queue.rs index 9b1f2bbdc..9edcedb52 100644 --- a/crates/freshell-ws/tests/term09_output_queue.rs +++ b/crates/freshell-ws/tests/term09_output_queue.rs @@ -48,6 +48,7 @@ async fn spawn_server(term09: Term09Config) -> String { Arc::new(serde_json::from_value(test_settings_value()).expect("valid settings fixture")); let state = WsState { + pane_ledger: std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), identity: freshell_ws::identity::TerminalIdentityRegistry::new(), auth_token: Arc::clone(&auth_token), server_instance_id: Arc::new("srv-test".to_string()), diff --git a/docs/plans/2026-07-25-createrequestid-stabilization.md b/docs/plans/2026-07-25-createrequestid-stabilization.md new file mode 100644 index 000000000..b64a4cdc5 --- /dev/null +++ b/docs/plans/2026-07-25-createrequestid-stabilization.md @@ -0,0 +1,1498 @@ +# createRequestId Key Stabilization Implementation Plan + +> **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:** Make `createRequestId` a stable pane identity key: the client preserves it across hydrate/reload, the Rust REST ingress mints one for every terminal pane it creates, and the tabs-snapshot pipeline persists it end-to-end (client snapshot builder → wire → Rust validator → `pane_to_create_body`), with old snapshots that lack the field remaining valid. + +**Architecture:** Three thin, independent seams. (1) Client: `normalizePaneContent` in `src/store/panesSlice.ts` gains a `previous`-content fallback for `createRequestId` — the exact pattern already proven for `browserInstanceId` — so hydrate never re-mints when a local key exists; the boot-path fallback mint at `src/store/persistMiddleware.ts:229` (`content.createRequestId || nanoid()`) already preserves-when-present and stays as the genuinely-absent (legacy-migration) mint, locked by a new test. (2) Rust REST ingress: `spawn_terminal_pane` in `crates/freshell-freshagent/src/terminal_tabs.rs` accepts a caller-supplied `createRequestId` (else mints a `Uuid::new_v4().simple()`), stamps it into the terminal registry (the `create_request_id` parameter already exists — it currently receives `None`), and emits it in the terminal `paneContent`; this one pipeline covers `POST /api/tabs`, `POST /api/panes/:id/split`, AND `POST /api/panes/:id/respawn` (rotation-on-respawn is intentional legacy parity). (3) Snapshots: the client snapshot builder emits `payload.createRequestId`, the snapshot validator is deliberately left UNCHANGED — its unknown-field tolerance already round-trips the string, and because write-accept and read share `validate_generation`, ANY strict check there would make one wrong-typed on-disk value render the whole device (and the device listing) unreadable; Task 4 locks the tolerance with tests instead, and `pane_to_create_body` passes it through into the restore create body, which the Task-3 ingress then honors — so snapshot-restored panes keep their original key. + +**Tech Stack:** TypeScript/React/Redux Toolkit client (vitest, jsdom), Rust workspace crates `freshell-freshagent` / `freshell-ws` / `freshell-server` (cargo test, axum + tower oneshot test style), Playwright e2e against an owned `RustServer` instance. + +## Global Constraints + +- Work ONLY inside the worktree `/home/dan/code/freshell/.worktrees/createrequestid-stabilization` (branch based on `origin/main @ c491aee0`). All relative paths below are relative to this worktree root. +- **Scope fence (owned files):** `src/store/panesSlice.ts` + persisted-state hydrate path, `src/store/persistMiddleware.ts` (behavior locked by test; no code change expected), `src/lib/tab-registry-snapshot.ts`, `crates/freshell-ws/src/tabs_persist_validation_tests.rs` (new; `tabs_persist_tests.rs` is Lane A6's to modify — do not touch it) + a 3-line `#[cfg(test)]` test-module registration at the end of `crates/freshell-ws/src/tabs_persist_validation.rs` (`tabs_persist.rs` itself is at 999 lines against the 1,000-line cap, `port/AGENTS.md:81` — do not add lines to it), `crates/freshell-server/src/tabs_snapshots.rs` `pane_to_create_body` (+ its test files), `crates/freshell-freshagent` pane-create ingress (`terminal_tabs.rs`, `pane_ops.rs` tests only), plus two port-parity bookkeeping docs written in Task 7 only: `port/oracle/DEVIATIONS.md` (append one EDEV entry) and `port/contract/nondeterministic-fields.md` (one table-row edit). +- **Do NOT touch:** `crates/freshell-ws/src/tabs_persist.rs` caps/eviction (Lane A6 owns it), `src/components/TerminalView.tsx` / `src/components/fresh-agent/FreshAgentView.tsx` reconnect handlers (Lane A4), `crates/freshell-freshagent` `claude.rs`/`snapshot.rs` (Lane A2), `crates/freshell-terminal/src/registry.rs` (Lane A5 — we only pass a value to its existing `create_request_id` parameter, with ONE documented exception: Task 3 makes `fn probe_create_request_id` `pub` (`registry.rs:1578`) — a one-word visibility change, no behavior change — so `freshell-freshagent` tests can assert the registry stamp; Lane A5 (scrollback-persistence) owns this file and is appending a 10th trailing `scrollback` param to `TerminalRegistry::create`, so whichever lane lands second takes a small mechanical rebase here), `crates/freshell-ws/src/reconcile.rs`. No kimi/gemini/opencode-fresh-agent changes. +- **Do NOT change recreate semantics:** `clearTerminalContentForRecreate` (`panesSlice.ts:525-548`), `restartFreshAgentCreate` (`:1448`), `stripStaleIds`/`normalizeRestoredTree` (`:812-846`, the `restoreLayout` fresh-identity path), and the `FreshAgentView` new-session/fork mints are **intentional per-recreate mints**. They must keep rotating the key. The existing suites (`panesSlice.test.ts`, `crossTabSync.test.ts`, `terminal-restore.test.ts`) already assert this rotation and serve as the regression gate. +- Red-Green-Refactor TDD for every change. Frequent, focused, atomic commits. +- Broad npm suites (`npm test`, `npm run check`, `npm run test:unit`) go through the shared coordinator gate: check `npm run test:status` first; if another agent holds the gate, **WAIT — never kill a foreign holder** (5 sibling lanes run concurrently). Set `FRESHELL_TEST_SUMMARY="lane A1 createRequestId "` on every gated run. Focused vitest runs (`npm run test:vitest -- run --config config/vitest/vitest.config.ts`) and `cargo test` and Playwright e2e are NOT gated. +- E2E: own `RustServer` instances via `test/e2e-browser/helpers/rust-server.ts` fixtures only — ephemeral ports, **NEVER 3001/3002**. New specs are new files; the `playwright.config.ts` append must be minimal (5 sibling lanes also append — trivial conflicts there are expected and fine). +- NEVER restart the user's self-hosted server. NEVER use broad kill patterns. Disk has ~36 GB free — on ENOSPC, halt and report; do not delete anything outside this worktree. +- `port/AGENTS.md:81` imposes a 1,000-line-per-file limit on Rust files. `crates/freshell-server/src/tabs_snapshots.rs` is currently **exactly 1000 lines** — Task 5 pairs its addition with an extraction using the file's established `#[path]`-sibling pattern. +- Rust CI gates: `cargo fmt --all --check` and `cargo clippy --workspace --all-targets -- -D warnings` must pass. +- **PR policy: NOT approved.** Push the branch, STOP before `gh pr create`, report branch + red→green proof. +- README.md is the only end-user markdown doc; this plan under `docs/plans/` is a working/agent doc. Do not create other markdown docs. +- The campaign plan `/home/dan/code/freshell/docs/plans/2026-07-24-restart-resilience-architecture-analysis.md` is UNTRACKED in the main checkout — never commit it or copy it into this worktree. + +### Deviation-budget bookkeeping (reconciliation-handshake design §13) + +The design doc's frozen-client deviation budget (8 files) must be re-counted at Phase-3 adoption, and this lane's client-file spend must be booked against it. **This lane modifies exactly two production client files:** `src/store/panesSlice.ts` and `src/lib/tab-registry-snapshot.ts`. Note for the re-count: the campaign plan names `persistMiddleware.ts:229` as the hydrate re-mint, but that site is a fallback (`content.createRequestId || nanoid()`) that already preserves-when-present and is retained unchanged as the genuinely-absent legacy-migration mint (required by `isWellFormedPaneTree`, which drops trees missing the field — `src/store/paneTreeValidation.ts:41`); the effective hydrate re-mint lives in `normalizePaneContent` (`panesSlice.ts:78-80`, `:118`, `:161`) and is fixed there. Task 1 locks the `persistMiddleware.ts` preserve behavior with a test so the "no re-mint on hydrate" guarantee is proven at both sites. + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `src/store/panesSlice.ts` | Modify (3 small edits in `normalizePaneContent`) | Inherit `previous` pane's `createRequestId` instead of minting when incoming content lacks one (terminal + fresh-agent), mirroring the existing `browserInstanceId` pattern | +| `test/unit/client/store/createRequestIdStability.test.ts` | Create | New unit suite: hydrate inherit, boot round-trip preserve, new-pane mint | +| `src/lib/tab-registry-snapshot.ts` | Modify (`stripPanePayload`) | Emit `createRequestId` into terminal + fresh-agent snapshot payloads | +| `test/unit/client/lib/tab-registry-snapshot.test.ts` | Modify | RED anchor (strict `toEqual` payloads) + new field assertions | +| `crates/freshell-freshagent/src/terminal_tabs.rs` | Modify (`spawn_terminal_pane`, 3 edits + tests) | REST ingress accepts-or-mints `createRequestId`, stamps registry, emits in `paneContent` (covers `/api/tabs`, `/api/panes/:id/split`, and `/api/panes/:id/respawn`) | +| `crates/freshell-freshagent/src/pane_ops.rs` | Modify (tests only) | BOTH the split-path broadcast test and the respawn rotation test are extended to assert the key (production code unchanged — split and respawn delegate to `spawn_terminal_pane`) | +| `crates/freshell-terminal/src/registry.rs` | Modify (1 word) | `probe_create_request_id` becomes `pub` so freshell-freshagent tests can assert the registry stamp (documented fence exception) | +| `crates/freshell-ws/src/tabs_persist_validation.rs` | Modify (3-line `#[cfg(test)]` module registration at EOF only) | Register the new sibling test file. NOT in `tabs_persist.rs`: that file is 999/1,000 lines (`port/AGENTS.md:81` cap), so registration lives in this 563-line `#[path]` child instead — validator logic untouched, caps/eviction untouched (Lane A6 owns those) | +| `crates/freshell-ws/src/tabs_persist_validation_tests.rs` | Create | Behavior locks: string round-trips, absent-still-valid, wrong-type-never-poisons-the-device (NO validator code change — see Task 4 rationale) | +| `crates/freshell-server/src/tabs_snapshots.rs` | Modify | Replace inline `pane_to_create_body` with `#[path]`-sibling module include (1,000-line limit) | +| `crates/freshell-server/src/tabs_snapshots_create_body.rs` | Create | `pane_to_create_body` moved verbatim + `createRequestId` passthrough | +| `crates/freshell-server/src/tabs_snapshots_restore_tests.rs` | Modify | Unit tests: create body carries / omits the key | +| `test/e2e-browser/specs/createrequestid-stabilization-rust.spec.ts` | Create | E2E: REST-created pane has a server-minted key; reload preserves it | +| `test/e2e-browser/playwright.config.ts` | Modify (2 appends) | Register the rust-only spec | + +**Interface contract shared by all tasks (the wire/persist field):** the pane identity key is the JSON field `createRequestId` (camelCase, non-empty string), carried **inside the pane payload / paneContent object** (`payload.createRequestId` in registry snapshots and tabs-snapshots; `paneContent.createRequestId` in `ui.command{tab.create}` / `pane.split` payloads; `createRequestId` in the `POST /api/tabs` / split request body). Server-minted values are `Uuid::new_v4().simple()` (32 lowercase hex chars); client-minted values are 21-char nanoids. Absence of the field is always legal on read (backward compat). + +--- + +### Task 0: Baseline — confirm the suites are green before changing anything + +**Files:** none (verification only) + +**Interfaces:** +- Consumes: the worktree as created by the workspace stage. +- Produces: a recorded green baseline that later red→green proof is measured against. + +- [ ] **Step 1: Confirm worktree + branch** + +Run: +```bash +cd /home/dan/code/freshell/.worktrees/createrequestid-stabilization +git status --short && git branch --show-current && git log --oneline -1 +``` +Expected: clean tree (this plan file may be present/committed), a feature branch (e.g. `feat/createrequestid-stabilization`), tip at or descended from `c491aee0`. If the branch is somehow `main`, create the feature branch now: `git checkout -b feat/createrequestid-stabilization`. + +- [ ] **Step 2: Check the coordinator gate, then run the base suite** + +Run: +```bash +npm run test:status +``` +If another agent holds the gate, WAIT (re-check periodically); do not kill anything. Then: +```bash +FRESHELL_TEST_SUMMARY="lane A1 createRequestId baseline" npm test +``` +Expected: PASS (exit 0). If the baseline is red, HALT and report — do not build on a red base. + +- [ ] **Step 3: Rust baseline for the three crates we touch** + +Run: +```bash +cargo test -p freshell-freshagent -p freshell-ws -p freshell-server +``` +Expected: PASS. + +--- + +### Task 1: Client — hydrate preserves `createRequestId` (inherit `previous` ONLY on the hydrate path, mint everywhere else) + +**Files:** +- Modify: `src/store/panesSlice.ts:61-64` (`normalizePaneContent` signature — new opt-in `options` param), `:66-91` (terminal branch, mint at `:78-80`), `:106-175` (fresh-agent branches, mint sites at `:118` and `:161`), `:373-378` (`normalizePaneTree` leaf call — the single hydrate-scoped opt-in site) +- Create: `test/unit/client/store/createRequestIdStability.test.ts` + +**Interfaces:** +- Consumes: `normalizePaneContent(rawInput, previous?: PaneContent, options?: { inheritCreateRequestId?: boolean })` — the `previous` parameter is already threaded in by `normalizePaneTree` (`panesSlice.ts:373-378`: `normalizePaneContent(node.content, previousLeaf?.content)`) and already used for `browserInstanceId` stability (`:92-101`). `hydratePanes` reducer (`:1559`) calls `normalizePaneTree(mergedNode, localNode)` (`:1575`) and `normalizePaneTree(state.layouts[tabId])` (`:1587`); `normalizePaneTree` has NO other production callers (verified), so it is the hydrate-only seam. +- Produces: hydrate semantics all later tasks rely on — ON THE HYDRATE PATH ONLY, a pane whose incoming content carries `createRequestId` keeps it; a pane whose incoming content lacks it inherits the local (previous) same-kind pane's key; only a pane with neither gets a fresh `nanoid()`. EVERY OTHER `previous`-passing caller keeps today's mint-when-absent behavior — in particular `updatePaneContent` (`panesSlice.ts:1323`) still MINTS for key-less same-kind content, which the resume/repair dispatchers depend on for rotation (`src/store/tabsSlice.ts:668` `repairExistingTabLayout` and `src/components/context-menu/ContextMenuProvider.tsx:949` reopen-in-pane both dispatch `buildResumeContent` output, which never carries `createRequestId`; TerminalView's create effect is keyed on `terminalContent?.createRequestId` — `TerminalView.tsx:4416,4453` — so an inherited/unchanged key there would never drive the resume create). Class-B rotation sites are untouched (they set the field explicitly on `input`, so input-wins precedence preserves their behavior). + +- [ ] **Step 1: Write the failing tests** + +Create `test/unit/client/store/createRequestIdStability.test.ts` with exactly this content (preamble copied from the house style in `test/unit/client/store/panesPersistence.test.ts` — the localStorage mock MUST be installed before slice imports, and the three cache resets in `beforeEach` are required because the vitest config uses `sequence: { shuffle: true }`): + +```ts +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { configureStore } from '@reduxjs/toolkit' + +// Mock localStorage BEFORE importing slices +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + } +})() + +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) + +import tabsReducer from '../../../../src/store/tabsSlice' +import panesReducer, { hydratePanes, initLayout, updatePaneContent } from '../../../../src/store/panesSlice' +import { + loadPersistedPanes, + persistMiddleware, + resetPersistFlushListenersForTests, + resetPersistedPanesCacheForTests, + resetPersistedLayoutCacheForTests, +} from '../../../../src/store/persistMiddleware' + +function makeStore() { + return configureStore({ + reducer: { tabs: tabsReducer, panes: panesReducer }, + middleware: (getDefault) => getDefault().concat(persistMiddleware as any), + }) +} + +describe('createRequestId stability across hydrate', () => { + beforeEach(() => { + localStorageMock.clear() + vi.clearAllMocks() + vi.useFakeTimers() + resetPersistFlushListenersForTests() + resetPersistedPanesCacheForTests() + resetPersistedLayoutCacheForTests() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('hydratePanes inherits the local createRequestId when the incoming terminal pane lacks one', () => { + const store = makeStore() + store.dispatch(initLayout({ + tabId: 'tab1', + content: { + kind: 'terminal', mode: 'shell', shell: 'system', + status: 'running', createRequestId: 'stable-key-1', + } as any, + })) + const paneId = (store.getState().panes.layouts['tab1'] as any).id + + // Incoming (remote) copy of the SAME pane, but the field was dropped by + // the producer. status:'exited' biases mergeTerminalState toward the + // incoming node (exit state propagates from remote — crossTabSync.test.ts + // 'propagates exit state from remote even when local has terminalId'). + store.dispatch(hydratePanes({ + layouts: { + tab1: { + type: 'leaf', id: paneId, + content: { kind: 'terminal', mode: 'shell', shell: 'system', status: 'exited' }, + }, + }, + activePane: { tab1: paneId }, + paneTitles: {}, + paneTitleSetByUser: {}, + } as any)) + + const leaf = store.getState().panes.layouts['tab1'] as any + expect(leaf.content.createRequestId).toBe('stable-key-1') + }) + + it('hydratePanes inherits the local createRequestId when the incoming fresh-agent pane lacks one', () => { + const store = makeStore() + store.dispatch(initLayout({ + tabId: 'tab2', + content: { + kind: 'fresh-agent', sessionType: 'freshclaude', provider: 'claude', + status: 'idle', createRequestId: 'stable-key-fa', + } as any, + })) + const paneId = (store.getState().panes.layouts['tab2'] as any).id + + store.dispatch(hydratePanes({ + layouts: { + tab2: { + type: 'leaf', id: paneId, + content: { kind: 'fresh-agent', sessionType: 'freshclaude', provider: 'claude', status: 'idle' }, + }, + }, + activePane: { tab2: paneId }, + paneTitles: {}, + paneTitleSetByUser: {}, + } as any)) + + const leaf = store.getState().panes.layouts['tab2'] as any + expect(leaf.content.createRequestId).toBe('stable-key-fa') + }) + + it('boot hydrate preserves the persisted createRequestId byte-for-byte (lock-in)', () => { + const store1 = makeStore() + store1.dispatch(initLayout({ + tabId: 'tab3', + content: { + kind: 'terminal', mode: 'shell', shell: 'system', + status: 'running', createRequestId: 'persisted-key-3', + } as any, + })) + vi.runAllTimers() // flush persist debounce to localStorage + + // Simulate a fresh page load: reset the module caches, re-read storage. + resetPersistedPanesCacheForTests() + resetPersistedLayoutCacheForTests() + const persisted = loadPersistedPanes() + const leaf = (persisted as any).layouts['tab3'] + expect(leaf.content.createRequestId).toBe('persisted-key-3') + }) + + it('a genuinely new pane (no persisted key, no previous) mints a createRequestId', () => { + const store = makeStore() + store.dispatch(initLayout({ tabId: 'tab4', content: { kind: 'terminal', mode: 'shell' } as any })) + const leaf = store.getState().panes.layouts['tab4'] as any + expect(typeof leaf.content.createRequestId).toBe('string') + expect(leaf.content.createRequestId.length).toBeGreaterThan(0) + }) + + it('updatePaneContent MINTS a fresh key for key-less same-kind content (resume-path rotation preserved)', () => { + const store = makeStore() + store.dispatch(initLayout({ + tabId: 'tab5', + content: { + kind: 'terminal', mode: 'shell', shell: 'system', + status: 'running', createRequestId: 'rotate-me-5', + } as any, + })) + const paneId = (store.getState().panes.layouts['tab5'] as any).id + + // Mirrors the resume/repair dispatchers (tabsSlice.ts:668 + // repairExistingTabLayout; ContextMenuProvider.tsx:949 reopen-in-pane): + // buildResumeContent output never carries createRequestId, and those + // paths RELY on the reducer minting a fresh key so TerminalView's create + // effect (keyed on terminalContent?.createRequestId) re-fires and drives + // the resume create. The hydrate-scoped inherit must NOT leak here. + store.dispatch(updatePaneContent({ + tabId: 'tab5', + paneId, + content: { + kind: 'terminal', mode: 'claude', + sessionRef: { provider: 'claude', sessionId: 'sess-resume-5' }, + } as any, + })) + + const leaf = store.getState().panes.layouts['tab5'] as any + expect(typeof leaf.content.createRequestId).toBe('string') + expect(leaf.content.createRequestId.length).toBeGreaterThan(0) + expect(leaf.content.createRequestId).not.toBe('rotate-me-5') + }) +}) +``` + +- [ ] **Step 2: Run the new suite to verify the inherit tests fail** + +Run: +```bash +npm run test:vitest -- run test/unit/client/store/createRequestIdStability.test.ts --config config/vitest/vitest.config.ts +``` +Expected: the two "inherits the local createRequestId" tests FAIL — received value is a fresh 21-char nanoid, expected `'stable-key-1'` / `'stable-key-fa'`. Tests 3 and 4 should already PASS (they lock existing behavior). Test 5 (updatePaneContent rotation) must PASS both BEFORE and AFTER Step 3 — it locks the mint-on-key-less-update behavior the resume paths depend on; if it fails after Step 3, the inheritance leaked out of the hydrate seam. If an inherit test unexpectedly PASSES, `mergeTerminalState` kept the local node wholesale for that shape — investigate `mergeTerminalState` (`panesSlice.ts` around `:678`) and adjust the incoming leaf so the merge selects the incoming node before proceeding; do not skip the RED confirmation. + +- [ ] **Step 3: Implement the hydrate-scoped `previous` inheritance (opt-in flag, threaded only from `normalizePaneTree`)** + +In `src/store/panesSlice.ts`, make exactly four edits. The inheritance is gated on a new opt-in `options.inheritCreateRequestId` flag so it can ONLY fire on the hydrate path — `normalizePaneTree` is the single opt-in site, and it is reachable only from the `hydratePanes` reducer (`:1575`, `:1587`). Every other `previous`-passing caller (`updatePaneContent` `:1323`, `mergePaneContent` `:1400`, `buildMaterializedFreshAgentContent` `:574`, `restartFreshAgentCreate` `:1445`) passes no options and keeps today's mint-when-absent behavior — required by the resume/repair rotation dependency at `tabsSlice.ts:668` and `ContextMenuProvider.tsx:949`. + +Edit 1 — signature. Change the function signature (`:61-64`) from: + +```ts +function normalizePaneContent( + rawInput: PaneContentInput | PaneContent | Record, + previous?: PaneContent, +): PaneContent { +``` + +to: + +```ts +function normalizePaneContent( + rawInput: PaneContentInput | PaneContent | Record, + previous?: PaneContent, + options?: { inheritCreateRequestId?: boolean }, +): PaneContent { +``` + +Edit 2 — terminal branch. At the top of the `if (input.kind === 'terminal') {` block (immediately after the `const mode = ...` line at `:67`), add: + +```ts + const previousCreateRequestId = + options?.inheritCreateRequestId && previous?.kind === 'terminal' + ? previous.createRequestId + : undefined +``` + +and change the `createRequestId` property in the terminal return object (`:78-80`) from: + +```ts + createRequestId: typeof input.createRequestId === 'string' && input.createRequestId + ? input.createRequestId + : nanoid(), +``` + +to: + +```ts + createRequestId: typeof input.createRequestId === 'string' && input.createRequestId + ? input.createRequestId + : previousCreateRequestId || nanoid(), +``` + +Edit 3 — fresh-agent branches. At the top of the `if (input.kind === 'fresh-agent') {` block (immediately after `const rawFreshAgent = input as Record` at `:107`), add: + +```ts + const previousCreateRequestId = + options?.inheritCreateRequestId && previous?.kind === 'fresh-agent' + ? previous.createRequestId + : undefined +``` + +and change BOTH fresh-agent mint sites — the `restoreError` branch (`:118`) and the main branch (`:161`) — from (identical text at both sites, indentation differs): + +```ts + createRequestId: input.createRequestId || nanoid(), +``` + +to (this also lands the A4a-N1 hardening: the terminal branch already guards with `typeof ... === 'string'`, the fresh-agent branches did not — a non-string truthy legacy value now re-mints instead of leaking a non-string key into `TerminalPaneContent`/`FreshAgentPaneContent.createRequestId: string`): + +```ts + createRequestId: typeof input.createRequestId === 'string' && input.createRequestId + ? input.createRequestId + : previousCreateRequestId || nanoid(), +``` + +(At `:161` the property sits at 6-space indent — keep the continuation lines aligned to local style.) + +Edit 4 — the single opt-in site. In `normalizePaneTree`'s leaf branch, change (`:373-378`): + +```ts + if (node.type === 'leaf') { + const previousLeaf = previousValid ? findLeaf(previousValid, node.id) : null + const normalizedLeaf: Extract = { + ...node, + content: normalizePaneContent(node.content, previousLeaf?.content), + } +``` + +to: + +```ts + if (node.type === 'leaf') { + const previousLeaf = previousValid ? findLeaf(previousValid, node.id) : null + const normalizedLeaf: Extract = { + ...node, + // Hydrate-scoped: normalizePaneTree is reachable ONLY from hydratePanes, + // so this is the one place a key-less incoming pane may inherit the + // previous (local) same-kind pane's createRequestId instead of minting. + // updatePaneContent et al. pass no options and keep minting — the + // resume/repair rotation contract (tabsSlice.ts repairExistingTabLayout, + // ContextMenuProvider reopen-in-pane) depends on that. + content: normalizePaneContent(node.content, previousLeaf?.content, { inheritCreateRequestId: true }), + } +``` + +Do NOT touch `clearTerminalContentForRecreate`, `restartFreshAgentCreate`, `stripStaleIds`, `normalizeRestoredTree`, or `persistMiddleware.ts` — the Class-B sites set the field explicitly on `input` (input-wins precedence preserves them), `stripStaleIds` calls `normalizePaneContent(...)` with NO `previous` argument (`:846`), so `restoreLayout` keeps its fresh-identity semantics, and no caller other than `normalizePaneTree` passes `options`, so `updatePaneContent`/`mergePaneContent` keep today's mint semantics byte-for-byte. + +- [ ] **Step 4: Run the new suite to verify it passes** + +Run: +```bash +npm run test:vitest -- run test/unit/client/store/createRequestIdStability.test.ts --config config/vitest/vitest.config.ts +``` +Expected: 5 passed. + +- [ ] **Step 5: Run the neighboring suites (Class-B regression gate + hydrate/persist coverage)** + +Run: +```bash +npm run test:vitest -- run \ + test/unit/client/store/panesPersistence.test.ts \ + test/unit/client/store/panesSlice.test.ts \ + test/unit/client/store/crossTabSync.test.ts \ + test/unit/lib/terminal-restore.test.ts \ + --config config/vitest/vitest.config.ts +``` +Expected: all PASS. These suites assert the intentional rotation semantics (`clearDeadTerminals`, `clearTerminalLiveHandles`, `restartFreshAgentCreate`, restore-armed set) — if any fail, the fallback leaked into a Class-B path; fix the implementation, not the tests. + +- [ ] **Step 6: Typecheck and commit** + +Run: +```bash +npm run typecheck:client +git add src/store/panesSlice.ts test/unit/client/store/createRequestIdStability.test.ts +git commit -m "fix(client): hydrate inherits previous createRequestId instead of re-minting" +``` + +--- + +### Task 2: Client — snapshot builder persists `createRequestId` + +**Files:** +- Modify: `src/lib/tab-registry-snapshot.ts:15-68` (`stripPanePayload`) +- Test: `test/unit/client/lib/tab-registry-snapshot.test.ts` + +**Interfaces:** +- Consumes: `PaneContent` (terminal and fresh-agent variants carry `createRequestId: string`). +- Produces: `stripPanePayload` returns `payload.createRequestId: string | undefined` for `terminal` and `fresh-agent` kinds. `RegistryPaneSnapshot.payload` is `z.record(z.string(), z.unknown())` (`src/store/tabRegistryTypes.ts`) — **no Zod schema change is needed**. Tasks 4 and 5 consume this field server-side as `payload.createRequestId`. + +- [ ] **Step 1: Write the failing test** + +Append to `test/unit/client/lib/tab-registry-snapshot.test.ts`, inside the existing `describe('collectPaneSnapshots', ...)` block: + +```ts + it('persists createRequestId in terminal and fresh-agent pane payloads', () => { + const terminalNode: PaneNode = { + type: 'leaf', + id: 'pane-term', + content: { + kind: 'terminal', + createRequestId: 'req-stable-term', + status: 'running', + mode: 'shell', + shell: 'system', + terminalId: 'term-1', + serverInstanceId: 'server-1', + }, + } + const termSnapshots = collectPaneSnapshots(terminalNode, 'server-1') + expect(termSnapshots[0].payload.createRequestId).toBe('req-stable-term') + + const freshAgentNode: PaneNode = { + type: 'leaf', + id: 'pane-fa', + content: { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-stable-fa', + status: 'idle', + } as any, + } + const faSnapshots = collectPaneSnapshots(freshAgentNode, 'server-1') + expect(faSnapshots[0].payload.createRequestId).toBe('req-stable-fa') + }) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: +```bash +npm run test:vitest -- run test/unit/client/lib/tab-registry-snapshot.test.ts --config config/vitest/vitest.config.ts +``` +Expected: the new test FAILS with `payload.createRequestId` being `undefined` (the payload objects currently omit the field). + +- [ ] **Step 3: Implement — emit the field in `stripPanePayload`** + +In `src/lib/tab-registry-snapshot.ts`, add `createRequestId` as the first property of the `terminal` and `fresh-agent` case returns: + +```ts + case 'terminal': + return { + createRequestId: content.createRequestId, + mode: content.mode, + shell: content.shell, + sessionRef: content.sessionRef, + codexDurability: content.mode === 'codex' ? content.codexDurability : undefined, + liveTerminal: content.terminalId + ? { + terminalId: content.terminalId, + serverInstanceId: content.serverInstanceId ?? serverInstanceId, + } + : undefined, + initialCwd: content.initialCwd, + } +``` + +and: + +```ts + case 'fresh-agent': + return { + createRequestId: content.createRequestId, + provider: content.provider, + ... +``` +(leave every other property of both cases exactly as it is today; browser/editor/extension/picker cases unchanged). + +- [ ] **Step 4: Run the file's full suite; update the strict-equality RED anchors** + +Run: +```bash +npm run test:vitest -- run test/unit/client/lib/tab-registry-snapshot.test.ts --config config/vitest/vitest.config.ts +``` +Expected: the new test PASSES; the pre-existing `collectPaneSnapshots` tests that use strict `toEqual` on the whole payload now FAIL (e.g. the codex-durability test's expected payload lacks `createRequestId: 'req-codex'`). Update each failing expectation by adding the `createRequestId` value from that test's input content (e.g. add `createRequestId: 'req-codex',` to the expected payload object). Re-run until all pass. Do NOT weaken `toEqual` to `toMatchObject` — the strictness is the file's regression armor. + +- [ ] **Step 5: Run the registry round-trip neighbors** + +Run: +```bash +npm run test:vitest -- run \ + test/unit/client/lib/tab-registry-snapshot.test.ts \ + test/unit/client/tab-registry-fresh-agent-migration.test.ts \ + --config config/vitest/vitest.config.ts +``` +Expected: PASS. + +- [ ] **Step 6: Typecheck and commit** + +Run: +```bash +npm run typecheck:client +git add src/lib/tab-registry-snapshot.ts test/unit/client/lib/tab-registry-snapshot.test.ts +git commit -m "feat(client): persist createRequestId in tab-registry pane snapshots" +``` + +--- + +### Task 3: Rust — REST ingress mints and stamps `createRequestId` (covers `/api/tabs`, `/api/panes/:id/split`, AND `/api/panes/:id/respawn` — all three delegate to `spawn_terminal_pane`) + +**Files:** +- Modify: `crates/freshell-freshagent/src/terminal_tabs.rs` — `spawn_terminal_pane` (`:574`; edits near `:609`, `:866-878`, `:947-954`) + new tests in the in-file `#[cfg(test)] mod tests` (`:1463+`) +- Modify: `crates/freshell-freshagent/src/pane_ops.rs` — tests only (`mod tests` at `:941+`) +- Modify: `crates/freshell-terminal/src/registry.rs` — one-word visibility change: `fn probe_create_request_id` -> `pub fn` (`registry.rs:1578`) + +**Interfaces:** +- Consumes: request body field `createRequestId` (optional non-empty string — supplied by Task 5's restore path, absent for ordinary REST callers); `TerminalRegistry::create(&spec, &env, terminal_id, stream_id, &mode, resume_session_id: Option<&str>, create_request_id: Option<&str>, ring_max_bytes: Option, on_exit)` — the 7th positional argument already exists (`crates/freshell-terminal/src/registry.rs:678-690`); `TerminalRegistry::probe_create_request_id(&self, terminal_id: &str) -> Option` (`registry.rs:1578`) for test assertions. NOTE: `probe_create_request_id` is currently PRIVATE (`fn probe_create_request_id(&self, terminal_id: &str) -> Option`, `registry.rs:1578` — no `pub`); Task 3's tests in `freshell-freshagent` cannot call it as-is. Task 3 must change it to `pub fn` in `crates/freshell-terminal/src/registry.rs` (doc comment "A terminal's stamped `createRequestId`, if any." already present; internal caller at `registry.rs:1478` unaffected). +- Produces: every terminal pane spawned via REST has a `create_request_id` stamped atomically in the registry AND a `"createRequestId"` string in its `paneContent` (which flows into the `ui.command{tab.create}` payload, the `pane.split` `newContent`, the `pane.attach` respawn `content`, and thence the client store and persisted layout). Minted format: `Uuid::new_v4().simple().to_string()` (32 hex chars — same idiom as the fresh-agent path at `lib.rs:1288`). `spawn_terminal_pane` has exactly THREE production callers, all covered for free by this one edit: `create_terminal_tab` (`terminal_tabs.rs:1030`), `split_pane` (`pane_ops.rs:156`), and `respawn_pane` (`pane_ops.rs:716`). **Rotation-on-respawn is intentional legacy parity**: the Node server's respawn handler mints a fresh key per respawn (`server/agent-api/router.ts:1602`, `createRequestId: nanoid()` in the broadcast content), and rotation is REQUIRED for correctness — respawn detaches (never kills) the old terminal, and reconcile/adopt resolves panes via `newest_live_by_create_request_id` (`crates/freshell-ws/src/terminal.rs:899-931`), so the pane's key must track the REPLACEMENT terminal, not the orphaned old one. A respawn body MAY carry `createRequestId` (accept-or-mint applies uniformly); a caller passing one accepts the adopt-by-key semantics that implies. Browser/editor panes are out of scope (reconciliation v1 is terminal-only). + +- [ ] **Step 0: Sibling-lane drift check (cheap, read-only)** + +Run: +```bash +for w in /home/dan/code/freshell/.worktrees/*/; do echo "== $w"; git -C "$w" diff --name-only origin/main...HEAD 2>/dev/null; done +``` +Check whether any sibling lane has landed changes to `crates/freshell-terminal/src/registry.rs` (Lane A5's 10th `scrollback` param — if present, add the extra trailing arg at the Task 3 call site), `crates/freshell-ws/src/tabs_persist_tests.rs` / `tabs_persist_validation.rs`, or `src/store/panesSlice.ts`. As of plan validation (2026-07-25) all five sibling branches were plan-doc-only. + +- [ ] **Step 1: Write the failing tests** + +First, make the probe accessor public so the tests compile: in `crates/freshell-terminal/src/registry.rs:1578` change `fn probe_create_request_id(&self, terminal_id: &str) -> Option` to `pub fn probe_create_request_id(...)` — no other change (internal caller at `registry.rs:1478` unaffected). + +In `crates/freshell-freshagent/src/terminal_tabs.rs`'s existing `#[cfg(test)] mod tests` module, add two `#[tokio::test]` functions modeled on the module's existing HTTP tests (use the module's local `state_with_registry()` / `app(state)` / `post(...)` / `body_json(...)` helpers and broadcast-bus subscription exactly as the neighboring real-PTY tests do — read `split_terminal_pane_spawns_real_pty_and_broadcasts_pane_split` in `pane_ops.rs:1103` for the subscribe-and-drain pattern): + +```rust + #[tokio::test] + async fn rest_create_terminal_tab_mints_and_stamps_create_request_id() { + let state = state_with_registry(); + let mut rx = state.bus.subscribe(); + let router = app(state.clone()); + + let (status, body) = post( + router, + "/api/tabs", + serde_json::json!({ "mode": "shell" }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "create failed: {body}"); + + // Drain the broadcast bus for the ui.command{tab.create} frame. + let mut pane_content = None; + while let Ok(msg) = rx.try_recv() { + if let ServerMessage::UiCommand(cmd) = &msg { + if cmd.command == "tab.create" { + pane_content = cmd + .payload + .as_ref() + .and_then(|p| p.get("paneContent")) + .cloned(); + } + } + } + let pane_content = pane_content.expect("no tab.create broadcast"); + let crid = pane_content + .get("createRequestId") + .and_then(serde_json::Value::as_str) + .expect("paneContent.createRequestId missing"); + assert_eq!(crid.len(), 32, "expected Uuid::simple format, got {crid:?}"); + assert!(crid.chars().all(|c| c.is_ascii_hexdigit())); + + // The registry row was stamped with the SAME key (atomic insert). + let terminal_id = pane_content + .get("terminalId") + .and_then(serde_json::Value::as_str) + .expect("paneContent.terminalId missing"); + assert_eq!( + state.terminals.probe_create_request_id(terminal_id).as_deref(), + Some(crid), + ); + } + + #[tokio::test] + async fn rest_create_honors_caller_supplied_create_request_id() { + let state = state_with_registry(); + let mut rx = state.bus.subscribe(); + let router = app(state.clone()); + + let (status, body) = post( + router, + "/api/tabs", + serde_json::json!({ "mode": "shell", "createRequestId": "crid-fixed-key" }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "create failed: {body}"); + + let mut pane_content = None; + while let Ok(msg) = rx.try_recv() { + if let ServerMessage::UiCommand(cmd) = &msg { + if cmd.command == "tab.create" { + pane_content = cmd + .payload + .as_ref() + .and_then(|p| p.get("paneContent")) + .cloned(); + } + } + } + let pane_content = pane_content.expect("no tab.create broadcast"); + assert_eq!( + pane_content.get("createRequestId").and_then(serde_json::Value::as_str), + Some("crid-fixed-key"), + ); + let terminal_id = pane_content + .get("terminalId") + .and_then(serde_json::Value::as_str) + .expect("paneContent.terminalId missing"); + assert_eq!( + state.terminals.probe_create_request_id(terminal_id).as_deref(), + Some("crid-fixed-key"), + ); + } +``` + +Adapt mechanics (broadcast receiver type, `try_recv` vs the module's drain helper, `state.bus` vs `state.broadcast` field name, whether the frame arrives synchronously or needs the module's existing await/poll idiom, real-PTY fixtures like `recording_cli_spec`) to match the neighboring tests in the SAME module — the assertions above are the contract; the plumbing must follow local convention. If shell spawn in tests needs the module's fake-CLI/PATH fixture (see `pane_ops.rs` `create_shell_tab` helper for how existing tests create shell tabs), reuse it. + +In `crates/freshell-freshagent/src/pane_ops.rs`'s `mod tests`, extend the EXISTING test `split_terminal_pane_spawns_real_pty_and_broadcasts_pane_split` (`:1103`) with one additional assertion where it inspects the `pane.split` `ui.command` payload: + +```rust + let crid = msg["payload"]["newContent"]["createRequestId"] + .as_str() + .expect("split newContent.createRequestId missing"); + assert_eq!(crid.len(), 32); +``` + +Also extend the EXISTING test `respawn_pane_replaces_terminal_in_place_and_broadcasts_pane_attach` (`pane_ops.rs:1738`) with the rotation assertions. Two insertions: + +Insertion 1 — immediately AFTER this existing block (the `pane.attach` content check): + +```rust + assert_eq!( + msg["payload"]["content"]["terminalId"], + json!(new_terminal_id) + ); +``` + +add: + +```rust + // Task 3: respawn ROTATES the pane key — the broadcast content carries + // a fresh server-minted 32-hex createRequestId. Intentional legacy + // parity (router.ts:1602 mints per respawn) and required so + // reconcile's newest_live_by_create_request_id resolves the pane to + // the REPLACEMENT terminal, not the detached old one. + let crid = msg["payload"]["content"]["createRequestId"] + .as_str() + .expect("respawn content.createRequestId missing"); + assert_eq!(crid.len(), 32, "expected Uuid::simple format, got {crid:?}"); + assert!(crid.chars().all(|c| c.is_ascii_hexdigit())); +``` + +Insertion 2 — immediately AFTER this existing block (which already binds `registry`): + +```rust + let registry = state.terminal_registry.clone().unwrap(); + assert!(registry.is_running(&old_terminal_id)); + assert!(registry.is_running(&new_terminal_id)); +``` + +and BEFORE the trailing `registry.kill(...)` lines, add: + +```rust + // The NEW terminal's registry row was stamped with the SAME key + // (atomic insert) — the old terminal keeps its own lineage. + assert_eq!( + registry.probe_create_request_id(&new_terminal_id).as_deref(), + Some(crid), + ); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: +```bash +cargo test -p freshell-freshagent create_request_id -- --nocapture +cargo test -p freshell-freshagent split_terminal_pane_spawns_real_pty_and_broadcasts_pane_split +``` +Expected: FAIL — `paneContent.createRequestId missing` (the field is not emitted today) and `probe_create_request_id` returns `None` (registry receives `None` today). + +- [ ] **Step 3: Implement — three edits in `spawn_terminal_pane`** + +Edit 1 — accept-or-mint. In `spawn_terminal_pane` (`terminal_tabs.rs:574`), immediately after the `cwd` binding (`:609`), insert: + +```rust + // Stable pane identity key (reconciliation-handshake design §5.5, + // precondition 2): honor a caller-supplied key (snapshot restore passes + // the captured one through `pane_to_create_body`), else mint one so every + // REST-created terminal pane is keyed. Same Uuid::simple idiom as the + // fresh-agent path (lib.rs `create_tab`). + let create_request_id = body + .get("createRequestId") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| Uuid::new_v4().simple().to_string()); +``` + +Edit 2 — stamp the registry. At the `registry.create(...)` call (`:866-878`), replace the first `None` argument and its deferral comment: + +```rust + resume_session_id.as_deref(), + // REST ingress mints no createRequestId (reconciliation design §5.5 + // precondition 2 — booked for the Phase-3 adoption change). + None, + None, + on_exit, +``` + +with: + +```rust + resume_session_id.as_deref(), + Some(create_request_id.as_str()), + None, + on_exit, +``` + +When rewriting the `registry.create` call (`terminal_tabs.rs:866-878`, signature `registry.rs:679-690`), name the positional `Option` args with inline comments so the next lane can rebase mechanically: + +```rust + resume_session_id.as_deref(), + Some(create_request_id.as_str()), // create_request_id: REST accept-or-mint key (this task) + None, // ring_max_bytes: registry default + on_exit, +``` + +NOTE: Lane A5 (scrollback persistence) appends a 10th trailing `scrollback` parameter to `TerminalRegistry::create` — whichever lane lands second takes a one-line mechanical rebase at this call site; the named-arg comments above are there to make that rebase unambiguous. + +Edit 3 — emit into `paneContent`. In the terminal `pane_content` construction (`:947-954`), add the field to the `json!` literal: + +```rust + let mut pane_content = json!({ + "kind": "terminal", + "terminalId": terminal_id, + "createRequestId": create_request_id, + "status": "running", + "mode": mode, + "shell": shell_str.clone().unwrap_or_else(|| "system".to_string()), + "initialCwd": cwd, + }); +``` + +(If the borrow checker complains about `create_request_id` being moved into `json!` while still borrowed at edit 2's call site, use `create_request_id.clone()` in the `json!` — the registry borrow ends before this point in the current code, so a plain move should compile.) + +No `pane_ops.rs` production change: terminal splits delegate to `spawn_terminal_pane` and inherit all three edits. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +```bash +cargo test -p freshell-freshagent +``` +Expected: PASS, including both new tests and the extended split test. + +- [ ] **Step 5: Format, lint, commit** + +Run: +```bash +cargo fmt --all +cargo clippy -p freshell-freshagent --all-targets -- -D warnings +git add crates/freshell-freshagent/src/terminal_tabs.rs crates/freshell-freshagent/src/pane_ops.rs crates/freshell-terminal/src/registry.rs +git commit -m "feat(freshagent): REST pane-create ingress mints and stamps createRequestId" +``` + +--- + +### Task 4: Rust — lock snapshot-pipeline tolerance for `createRequestId` (tests only; NO validator change) + +**Files:** +- Modify: `crates/freshell-ws/src/tabs_persist_validation.rs` (3-line `#[cfg(test)]` module registration appended at end of file — the file is 563 lines today; ZERO validator-logic change. This file is a `#[path]` child of `tabs_persist` — registered at `tabs_persist.rs:96-97` as `#[path = "tabs_persist_validation.rs"] mod validation;` — so a test module declared here is a descendant of `tabs_persist` and keeps `pub(crate)`/private visibility) +- Create: `crates/freshell-ws/src/tabs_persist_validation_tests.rs` +- Do NOT modify: `crates/freshell-ws/src/tabs_persist.rs` (it is 999 lines — appending even a 3-line registration there would break the ≤1,000-line cap, `port/AGENTS.md:81`, that this plan treats as binding; its caps/eviction code is also Lane-A6-owned), `crates/freshell-ws/src/tabs_persist_tests.rs` (Lane A6 co-edits it — our tests live in the new sibling file to avoid merge conflicts) + +**Why NO strict check (read this before "improving" anything):** the write-accept path (`validate_incoming_generation`, `tabs_persist.rs:104`) and the read path (`read_generation_file`, `tabs_persist.rs:108`) share ONE `validate_generation`. Any type check added there is therefore also a READ check, and the read blast radius is catastrophic: `all_generations_parsed` (`tabs_persist.rs:265-292`) propagates the first invalid file with `?`, so ONE wrong-typed field in ONE generation file makes `read_device_union`, `read_device_overview`, `read_generation`, and `read_generations_union_by_ids` return `Err` for the ENTIRE device — and `list_snapshot_devices` (`tabs_persist.rs:297-330`) reads one file per device dir, so it Errs for ALL devices on the server. Historical user disks are unauditable, and a wrong-typed value could plausibly exist (e.g. written through a pre-Task-4 server's unknown-field tolerance). Meanwhile strictness buys nothing: every consumer of the field already drops non-strings safely (`pane_to_create_body`'s `.filter(|v| v.is_string())` — Task 5 — and the Task-3 REST ingress), and the validator's existing unknown-field tolerance means string values already round-trip write→read unchanged with zero code change (write path is insert-only stamping: `terminal.rs:2262-2297`, `tabs.rs:133-142`; `persist_generation` serializes records verbatim: `tabs_persist.rs:788-796`; `validate_generation` never mutates). So this task LOCKS that tolerance with tests instead of adding validation. Enforcement of the field's type lives at the consumers, where a bad value costs one pane its identity key — not a device its entire snapshot history. + +**Interfaces:** +- Consumes: `persist_generation` (`tabs_persist.rs:770`, `pub(crate)`), `read_device_union` (`:544`), `list_snapshot_devices` (`:297`), `validate_incoming_generation` (`:104`, `pub(crate)`), `TabsRegistry::with_persist_dir` + `replace_client_snapshot` (`crates/freshell-ws/src/tabs.rs:121-129`) — all reachable via `use super::super::*;` / `use crate::tabs::TabsRegistry;` from the test module, which is a grandchild of `tabs_persist` (declared inside its `validation` child, hence the double `super`). +- Produces: locked guarantees the rest of the plan builds on — (a) a string `payload.createRequestId` on terminal + fresh-agent panes round-trips push→persist→read unchanged (what Task 2 writes, Task 5 reads); (b) absence stays valid (legacy snapshots readable); (c) a wrong-typed value NEVER makes a generation — let alone the device — unreadable (read succeeds, value passes through verbatim; consumers drop it). + +- [ ] **Step 1: Write the behavior-locking tests** + +This is a tests-only task locking CURRENT behavior, so there is no red→green cycle: all three tests are expected to pass immediately. If any test FAILS, that is a discovery that the tolerance assumption is wrong — HALT and report; do not "fix" production code to make them pass. + +Create `crates/freshell-ws/src/tabs_persist_validation_tests.rs` with exactly this content: + +```rust +//! Behavior locks for `createRequestId` in persisted tabs-snapshot pane +//! payloads (Lane A1 Task 4). These tests intentionally add NO validator +//! strictness: `validate_generation` is shared by the write-accept AND read +//! paths, and one strict read failure poisons the whole device +//! (`all_generations_parsed` propagates the first `Err`) plus the cross-device +//! listing (`list_snapshot_devices`). Tolerance here is a design decision — +//! see the Task 4 rationale in +//! docs/plans/2026-07-25-createrequestid-stabilization.md. Sibling file to +//! `tabs_persist_tests.rs` (co-owned by Lane A6, hence the separate file); +//! helper shapes are mirrored from there. Registered from +//! `tabs_persist_validation.rs` (a `#[path]` child of `tabs_persist`) because +//! `tabs_persist.rs` sits at 999 lines against the 1,000-line cap +//! (port/AGENTS.md:81) — hence the double `super` below. + +use super::super::*; // grandparent = tabs_persist (this mod lives under its `validation` child) +use crate::tabs::TabsRegistry; +use serde_json::{json, Value}; + +fn open_record(tab_key: &str, tab_name: &str, updated_at: i64) -> Value { + json!({ "tabKey": tab_key, "tabId": tab_key, "tabName": tab_name, "status": "open", + "revision": updated_at, "updatedAt": updated_at, "paneCount": 0, "panes": [] }) +} + +/// Direct deterministic write (explicit captured_at + revision) — bypasses the +/// WS-handler write gate on purpose, simulating a file that is ALREADY on disk +/// (legacy server, foreign writer, historical corpus). +fn put( + dir: &std::path::Path, + device: &str, + client: &str, + rev: i64, + captured: i64, + recs: Vec, +) { + persist_generation(dir, "srv-1", device, "Dev", client, rev, &recs, captured); +} + +fn keyed_panes(terminal_key: Value, fresh_key: Value) -> Value { + json!([ + { "paneId": "terminal", "kind": "terminal", + "payload": { "mode": "shell", "shell": "system", + "createRequestId": terminal_key } }, + { "paneId": "fresh", "kind": "fresh-agent", + "payload": { "sessionType": "freshclaude", "provider": "claude", + "createRequestId": fresh_key } } + ]) +} + +#[test] +fn pane_create_request_id_string_round_trips_push_to_read_unchanged() { + // Full production write pipeline (registry push -> persist_generation), + // then the fail-loud read path. The field must survive verbatim. + let dir = tempfile::tempdir().unwrap(); + let reg = TabsRegistry::with_persist_dir(dir.path().to_path_buf()); + let mut record = open_record("dev:t", "t", 1); + record["panes"] = keyed_panes( + json!("a3f2b8d07a98b5fb2f4af05baf580000"), // server-mint shape (32 hex) + json!("req-fa-1"), // client nanoid shape + ); + reg.replace_client_snapshot("srv-1", "dev", "Dev", "client-1", 1, vec![record]) + .unwrap(); + let union = read_device_union(dir.path(), "dev") + .expect("read io") + .expect("one generation"); + let panes = &union["records"][0]["panes"]; + assert_eq!( + panes[0]["payload"]["createRequestId"], + "a3f2b8d07a98b5fb2f4af05baf580000" + ); + assert_eq!(panes[1]["payload"]["createRequestId"], "req-fa-1"); + // And the shared write-accept validator (same validate_generation as the + // read path) accepts a generation carrying the string field. + let candidate = json!({ + "deviceId": "dev", "deviceLabel": "Dev", "clientInstanceId": "client-1", + "serverInstanceId": "srv-1", "snapshotRevision": 1, "capturedAt": 0, + "records": union["records"], + }); + assert!(validate_incoming_generation(&candidate).is_ok()); +} + +#[test] +fn legacy_snapshots_without_create_request_id_stay_valid() { + let dir = tempfile::tempdir().unwrap(); + let mut record = open_record("dev:legacy", "legacy", 1); + record["panes"] = json!([ + { "paneId": "terminal", "kind": "terminal", + "payload": { "mode": "shell", "shell": "system" } }, + { "paneId": "fresh", "kind": "fresh-agent", + "payload": { "sessionType": "freshclaude", "provider": "claude" } } + ]); + put(dir.path(), "dev", "c1", 1, 1000, vec![record]); + assert!( + read_device_union(dir.path(), "dev").unwrap().is_some(), + "snapshots predating createRequestId must remain readable" + ); +} + +#[test] +fn wrong_typed_create_request_id_never_poisons_the_device() { + // THE load-bearing lock: a wrong-typed key on disk (historical corpus, + // foreign writer, version skew) must NOT convert into device-wide — or + // via list_snapshot_devices, server-wide — snapshot unreadability. If this + // test ever goes red because someone added a type check to + // validate_generation, that check is on the READ path too: remove it. + let dir = tempfile::tempdir().unwrap(); + let mut record = open_record("dev:t", "t", 1); + record["panes"] = keyed_panes(json!(42), json!(true)); + put(dir.path(), "dev", "c1", 1, 1000, vec![record]); + let union = read_device_union(dir.path(), "dev") + .expect("wrong-typed createRequestId must NOT make the device unreadable") + .expect("generation present"); + // Current (locked) behavior: the unknown field passes through verbatim — + // nothing strips it; the CONSUMERS (pane_to_create_body, REST ingress) + // drop non-strings, so the only cost is that one pane loses key identity. + let panes = &union["records"][0]["panes"]; + assert_eq!(panes[0]["payload"]["createRequestId"], 42); + assert_eq!(panes[1]["payload"]["createRequestId"], true); + assert!(list_snapshot_devices(dir.path()).is_ok()); +} +``` + +- [ ] **Step 2: Register the sibling test module** + +In `crates/freshell-ws/src/tabs_persist_validation.rs`, append at the very end of the file (after the closing `}` at `:563` — the file is 563 lines today, so this lands it at 566): + +```rust +#[cfg(test)] +#[path = "tabs_persist_validation_tests.rs"] +mod validation_tests; +``` + +Why HERE and not `tabs_persist.rs`: `tabs_persist.rs` is 999 lines and `port/AGENTS.md:81` caps files at 1,000 — appending the registration there would break the cap this plan treats as binding (cf. Task 5's extraction for exactly this reason). `tabs_persist_validation.rs` is itself a `#[path]` child of `tabs_persist` (`tabs_persist.rs:96-97`: `#[path = "tabs_persist_validation.rs"] mod validation;`), so the test module lands at `tabs_persist::validation::validation_tests` — still a descendant of `tabs_persist`, which is what keeps `pub(crate)` items reachable and makes Step 1's `use super::super::*;` resolve. The nested `#[path]` resolves relative to the directory containing `tabs_persist_validation.rs` (`src/`), so it finds the new sibling file. This is a `#[cfg(test)]`-gated module declaration ONLY — zero validator-logic change, honoring this task's NO-validator-change rule. Repo precedent for a sibling test file registering another sibling to respect the cap: `crates/freshell-server/src/tabs_snapshots_tests.rs:987`. It does not touch the caps/eviction logic Lane A6 owns, and it keeps `tabs_persist_tests.rs` untouched (Lane A6 co-edits that file). + +- [ ] **Step 3: Run the new tests** + +Run: +```bash +cargo test -p freshell-ws --lib validation_tests +``` +Expected: 3 passed (behavior locks — green immediately; a failure is a finding, not a fixture bug — HALT and report). Then confirm the neighbors still pass: +```bash +cargo test -p freshell-ws --lib -- semantically_corrupt corrupt_generation_file +``` +Expected: `semantically_corrupt_generation_files_fail_loud` and `corrupt_generation_file_reads_as_error_not_absence` still PASS (the fail-loud model for GENUINE corruption is unchanged; we added tolerance locks, not tolerance code). + +- [ ] **Step 4: Format, lint, commit** + +Run: +```bash +cargo fmt --all +cargo clippy -p freshell-ws --all-targets -- -D warnings +git add crates/freshell-ws/src/tabs_persist_validation.rs crates/freshell-ws/src/tabs_persist_validation_tests.rs +git commit -m "test(ws): lock snapshot read/write tolerance for pane createRequestId" +``` + +--- + +### Task 5: Rust — `pane_to_create_body` emits `createRequestId` (with 1,000-line-limit extraction) + +**Files:** +- Create: `crates/freshell-server/src/tabs_snapshots_create_body.rs` +- Modify: `crates/freshell-server/src/tabs_snapshots.rs` (`:177-249` — remove the inline function, add the `#[path]` include; the file is exactly 1000 lines today and `port/AGENTS.md:81` caps files at 1000, so the addition is paired with this extraction using the file's own established sibling pattern, cf. `#[path = "tabs_snapshots_tests.rs"] mod tests;` at `:998-1000`) +- Test: `crates/freshell-server/src/tabs_snapshots_restore_tests.rs` + +**Interfaces:** +- Consumes: snapshot pane `Value`s whose `payload.createRequestId` is an optional string (written by Task 2's client builder; Task 4 locks that the persistence pipeline round-trips it and tolerates wrong types — this function's `.filter(|v| v.is_string())` is the actual type gate). Callers of `pane_to_create_body(tab_name: Option<&Value>, pane: &Value) -> Result`: the restore loop (`tabs_snapshots.rs:564-596`) and the restore-projection validator (`tabs_snapshots_marker.rs:458`, via `super::pane_to_create_body` — the `use` re-export below keeps that path resolving). +- Produces: for `kind == "terminal"`, the create body carries `createRequestId` when the payload has a string one, omitted otherwise — consumed by Task 3's `spawn_terminal_pane` accept-or-mint, so snapshot-restored panes keep their captured key and legacy panes get a freshly minted one. + +- [ ] **Step 1: Write the failing test** + +In `crates/freshell-server/src/tabs_snapshots_restore_tests.rs`, next to `create_body_carries_full_terminal_state_including_codex_durability` (`:583`), add: + +```rust +#[test] +fn create_body_carries_create_request_id_and_omits_when_absent() { + // Captured key passes through (P1.6: snapshot-restored panes keep identity). + let pane = json!({ "paneId": "p1", "kind": "terminal", "payload": { + "mode": "shell", "shell": "system", + "createRequestId": "crid-from-snapshot" + }}); + let body = pane_to_create_body(None, &pane).unwrap(); + assert_eq!(body["createRequestId"], "crid-from-snapshot"); + + // Legacy snapshot without the field: body omits it entirely (the REST + // ingress mints a fresh key in that case — never emit null/empty). + let legacy = json!({ "paneId": "p2", "kind": "terminal", "payload": { + "mode": "shell", "shell": "system" + }}); + let legacy_body = pane_to_create_body(None, &legacy).unwrap(); + assert!(legacy_body.get("createRequestId").is_none()); + + // Wrong-typed field is dropped, not an error (same tolerance as shell/cwd). + let wrong = json!({ "paneId": "p3", "kind": "terminal", "payload": { + "mode": "shell", "createRequestId": 42 + }}); + let wrong_body = pane_to_create_body(None, &wrong).unwrap(); + assert!(wrong_body.get("createRequestId").is_none()); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: +```bash +cargo test -p freshell-server create_body_carries_create_request_id -- --nocapture +``` +Expected: FAIL — `body["createRequestId"]` is `null` (the passthrough does not exist yet). + +- [ ] **Step 3: Implement — extraction + passthrough** + +Create `crates/freshell-server/src/tabs_snapshots_create_body.rs` containing the `pane_to_create_body` function moved VERBATIM from `tabs_snapshots.rs:177-249` (doc comment included), with two changes: visibility becomes `pub(crate)`, and the terminal arm gains the passthrough. Full file content: + +```rust +//! `pane_to_create_body` — extracted from `tabs_snapshots.rs` to honor the +//! 1,000-line file cap (port/AGENTS.md) when the createRequestId passthrough +//! was added. Included via `#[path]` from `tabs_snapshots.rs`, matching the +//! sibling pattern used by `tabs_snapshots_marker.rs` / `tabs_snapshots_tests.rs`. + +use serde_json::{json, Value}; + +/// Map a snapshot pane to its `POST /api/tabs` body. Invalid session identity +/// fails before spawn; unsupported kinds are skips. Captured terminal, browser, +/// and editor options pass through to the restored pane. +pub(crate) fn pane_to_create_body( + tab_name: Option<&Value>, + pane: &Value, +) -> Result { + let payload = pane.get("payload").cloned().unwrap_or_else(|| json!({})); + let kind = pane.get("kind").and_then(Value::as_str).unwrap_or(""); + let name = tab_name.cloned().unwrap_or(Value::Null); + match kind { + "terminal" => { + let mode = payload + .get("mode") + .and_then(Value::as_str) + .unwrap_or("shell"); + let mut b = json!({ "mode": mode, "name": name }); + if let Some(cwd) = payload.get("initialCwd").filter(|v| v.is_string()) { + b["cwd"] = cwd.clone(); + } + if let Some(shell) = payload.get("shell").filter(|v| v.is_string()) { + b["shell"] = shell.clone(); + } + // Stable pane identity key (reconciliation design §5.5): restore + // re-creates the pane under its CAPTURED key so server-side state + // keyed on it survives; absent on legacy snapshots (the REST + // ingress mints one in that case). + if let Some(crid) = payload.get("createRequestId").filter(|v| v.is_string()) { + b["createRequestId"] = crid.clone(); + } + if let Some(cd) = payload.get("codexDurability").filter(|v| v.is_object()) { + if mode == "codex" { + b["codexDurability"] = cd.clone(); + } + } + // Present identity must be nonempty and match the terminal mode. + if let Some(sref) = payload.get("sessionRef").filter(|v| !v.is_null()) { + let ok = sref.is_object() + && sref.get("provider").and_then(Value::as_str) == Some(mode) + && sref + .get("sessionId") + .and_then(Value::as_str) + .is_some_and(|s| !s.is_empty()); + if !ok { + return Err("session-identity-mismatch"); + } + b["sessionRef"] = sref.clone(); + } + Ok(b) + } + "browser" => match payload.get("url").and_then(Value::as_str) { + Some(url) => { + let mut b = json!({ "browser": url, "name": name }); + if let Some(dt) = payload.get("devToolsOpen").filter(|v| v.is_boolean()) { + b["devToolsOpen"] = dt.clone(); + } + Ok(b) + } + None => Err("missing-url"), + }, + "editor" => match payload.get("filePath") { + Some(file_path) if file_path.is_string() || file_path.is_null() => { + let mut b = json!({ "editor": file_path, "name": name }); + if let Some(lang) = payload.get("language").filter(|v| v.is_string()) { + b["language"] = lang.clone(); + } + if let Some(ro) = payload.get("readOnly").filter(|v| v.is_boolean()) { + b["readOnly"] = ro.clone(); + } + if let Some(vm) = payload.get("viewMode").filter(|v| v.is_string()) { + b["viewMode"] = vm.clone(); + } + if let Some(ww) = payload.get("wordWrap").filter(|v| v.is_boolean()) { + b["wordWrap"] = ww.clone(); + } + Ok(b) + } + _ => Err("missing-filePath"), + }, + _ => Err("unsupported-kind"), + } +} +``` + +IMPORTANT: before writing the file, diff the browser/editor arms above against the current `tabs_snapshots.rs:177-249` and copy the CURRENT text verbatim if it differs in any detail — the only intended delta versus today's code is the `createRequestId` block and the `pub(crate)` visibility. + +In `crates/freshell-server/src/tabs_snapshots.rs`, replace the entire inline function (`:177-249`, including its doc comment) with: + +```rust +#[path = "tabs_snapshots_create_body.rs"] +mod tabs_snapshots_create_body; +use tabs_snapshots_create_body::pane_to_create_body; +``` + +The `use` alias keeps `super::pane_to_create_body` resolving from the `#[path]`-included children (`tabs_snapshots_marker.rs:458` and the test modules, which import via `use super::*;`). + +- [ ] **Step 4: Run tests to verify they pass, and verify the line cap** + +Run: +```bash +cargo test -p freshell-server +wc -l crates/freshell-server/src/tabs_snapshots.rs +``` +Expected: all tests PASS (the new one plus the existing `create_body_carries_full_terminal_state_including_codex_durability` and the end-to-end `restore_round_trips_non_default_pane_state_to_the_client`); `tabs_snapshots.rs` is now well under 1000 lines and the new sibling is far under it too. + +- [ ] **Step 5: Format, lint, commit** + +Run: +```bash +cargo fmt --all +cargo clippy --workspace --all-targets -- -D warnings +git add crates/freshell-server/src/tabs_snapshots.rs crates/freshell-server/src/tabs_snapshots_create_body.rs crates/freshell-server/src/tabs_snapshots_restore_tests.rs +git commit -m "feat(server): snapshot restore carries createRequestId into pane create bodies" +``` + +--- + +### Task 6: E2E — REST-created pane is keyed; reload preserves the key + +**Files:** +- Create: `test/e2e-browser/specs/createrequestid-stabilization-rust.spec.ts` +- Modify: `test/e2e-browser/playwright.config.ts` (two minimal appends: `RUST_ONLY_SPECS` at `:81-114` AND the `rust-chromium` project's `testMatch` at `:167-253` — missing the first makes the match-all `chromium` project run it under the `'legacy'` server default and fail; every registry entry carries a why-comment per the file's convention) + +**Interfaces:** +- Consumes: `test/e2e-browser/helpers/fixtures.js` (`test as base`, `expect`, worker-scoped `testServer` → `serverInfo: { baseUrl, token, port, ... }` with ephemeral ports — never 3001/3002), `helpers/test-harness.js` `TestHarness` (`waitForHarness`, `waitForConnection`, `getPaneLayout(tabId)`, `dispatch`), the REST envelope `{status, data}` with `x-auth-token` auth, localStorage key `freshell.layout.v3`, and the server-mint format from Task 3 (32 hex chars — discriminates a server-minted key from a client-side 21-char nanoid fallback, which is what makes these tests prove the server behavior rather than the client's own mint). +- Produces: end-to-end proof of both spec acceptance criteria: "REST-created pane has one" and "reload page → same createRequestId per pane". + +- [ ] **Step 1: Write the spec** + +Create `test/e2e-browser/specs/createrequestid-stabilization-rust.spec.ts`: + +```ts +/** + * Lane A1 (P1.6, restart-resilience campaign): createRequestId key + * stabilization. Rust-only: the mint under test lives in the Rust REST + * ingress (crates/freshell-freshagent spawn_terminal_pane). + * + * Proves, end to end: + * 1. A pane created via POST /api/tabs carries a SERVER-minted + * createRequestId (32-hex Uuid::simple — a client-side fallback mint + * would be a 21-char nanoid, so the format assertion discriminates). + * 2. A full page reload hydrates the SAME createRequestId (no re-mint). + */ +import os from 'node:os' +import { test as base, expect } from '../helpers/fixtures.js' +import { TestHarness } from '../helpers/test-harness.js' + +const test = base + +function unwrapData(body: any): any { + return body && typeof body === 'object' && 'data' in body ? body.data : body +} + +async function createTab( + baseUrl: string, + token: string, + payload: Record, +): Promise<{ status: number; tabId?: string; paneId?: string; body: any }> { + const res = await fetch(`${baseUrl}/api/tabs`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-auth-token': token }, + body: JSON.stringify(payload), + }) + const rawBody = await res.json().catch(() => undefined) + const data = unwrapData(rawBody) as { tabId?: string; paneId?: string } | undefined + return { status: res.status, tabId: data?.tabId, paneId: data?.paneId, body: rawBody } +} + +function collectTerminalCreateRequestIds(node: any, out: string[] = []): string[] { + if (!node) return out + if (node.type === 'leaf') { + if (node.content?.kind === 'terminal' && typeof node.content.createRequestId === 'string') { + out.push(node.content.createRequestId) + } + return out + } + collectTerminalCreateRequestIds(node.children?.[0], out) + collectTerminalCreateRequestIds(node.children?.[1], out) + return out +} + +test.describe('createRequestId stabilization (rust REST ingress + reload)', () => { + test('REST-created terminal pane carries a server-minted createRequestId', async ({ page, e2eServerKind, serverInfo }) => { + expect(e2eServerKind).toBe('rust') + const { baseUrl, token } = serverInfo + + // Connect the browser FIRST: the server broadcasts ui.command{tab.create} + // over the live WS connection when a tab is created via REST. + await page.goto(`${baseUrl}/?token=${token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + + const created = await createTab(baseUrl, token, { + mode: 'shell', cwd: os.tmpdir(), name: 'crid-rest-tab', + }) + expect(created.status, `POST /api/tabs failed: ${JSON.stringify(created.body)}`).toBe(200) + expect(created.tabId).toBeTruthy() + + await expect + .poll(async () => collectTerminalCreateRequestIds(await harness.getPaneLayout(created.tabId!)), { + timeout: 15_000, + }) + .toHaveLength(1) + + const [key] = collectTerminalCreateRequestIds(await harness.getPaneLayout(created.tabId!)) + // 32 lowercase hex = Uuid::new_v4().simple() minted by the Rust REST + // ingress; a 21-char nanoid here would mean the client minted a fallback + // key because the server sent none. + expect(key).toMatch(/^[0-9a-f]{32}$/) + }) + + test('page reload hydrates the same createRequestId for the pane', async ({ page, e2eServerKind, serverInfo }) => { + expect(e2eServerKind).toBe('rust') + const { baseUrl, token } = serverInfo + + await page.goto(`${baseUrl}/?token=${token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + + const created = await createTab(baseUrl, token, { + mode: 'shell', cwd: os.tmpdir(), name: 'crid-reload-tab', + }) + expect(created.status).toBe(200) + expect(created.tabId).toBeTruthy() + + await expect + .poll(async () => collectTerminalCreateRequestIds(await harness.getPaneLayout(created.tabId!)), { + timeout: 15_000, + }) + .toHaveLength(1) + const [keyBefore] = collectTerminalCreateRequestIds(await harness.getPaneLayout(created.tabId!)) + expect(keyBefore).toMatch(/^[0-9a-f]{32}$/) + + // Defeat the persist debounce before reloading (house pattern). + await page.evaluate(() => { + (window as any).__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'persist/flushNow' }) + }) + const layoutRaw = await page.evaluate(() => localStorage.getItem('freshell.layout.v3')) + expect(layoutRaw, 'layout must be persisted before reload').toBeTruthy() + expect(layoutRaw).toContain(keyBefore) + + await page.reload({ waitUntil: 'domcontentloaded' }) + await harness.waitForHarness() + await harness.waitForConnection() + + await expect + .poll(async () => collectTerminalCreateRequestIds(await harness.getPaneLayout(created.tabId!)), { + timeout: 15_000, + }) + .toHaveLength(1) + const [keyAfter] = collectTerminalCreateRequestIds(await harness.getPaneLayout(created.tabId!)) + expect(keyAfter, 'reload must hydrate the SAME pane identity key, not re-mint').toBe(keyBefore) + }) +}) +``` + +If `e2eServerKind` is not directly destructurable in this fixture setup, copy the exact destructuring used by `specs/rest-tab-persistence.spec.ts:529` (`async ({ page, e2eServerKind, serverInfo, testServer })`) — that spec is the direct pattern source for this one. + +- [ ] **Step 2: Register the spec in `playwright.config.ts` (two appends)** + +In `test/e2e-browser/playwright.config.ts`, append to `RUST_ONLY_SPECS` (`:81-114`): + +```ts + // Lane A1 (P1.6): createRequestId stabilization — asserts the Rust REST + // ingress mints the key (Uuid::simple format), so it must run against the + // rust server only. + /createrequestid-stabilization-rust\.spec\.ts$/, +``` + +and append the same regex + comment to the `rust-chromium` project's `testMatch` array (`:167-253`). Keep both appends at the END of their arrays to minimize conflict surface with the 5 sibling lanes (trivial adjacent-line conflicts are expected and fine). + +- [ ] **Step 3: Run the spec** + +Run: +```bash +npm run test:e2e -- --project=rust-chromium createrequestid-stabilization-rust +``` +Expected: 2 passed. Notes: the e2e `globalSetup` runs `npm run build:client && npm run build:server` first, and `RustServer.start()` builds `target/release/freshell-server` if missing — the first run can take several minutes. The fixture asserts its own ephemeral port (`expect(info.port).not.toBe(3001)` is the house invariant); never point this spec at 3001/3002 or any running server. + +- [ ] **Step 4: Commit** + +```bash +git add test/e2e-browser/specs/createrequestid-stabilization-rust.spec.ts test/e2e-browser/playwright.config.ts +git commit -m "test(e2e): createRequestId survives reload and is server-minted for REST panes" +``` + +--- + +### Task 7: Full verification, push branch, STOP (no PR) + +**Files:** Modify `port/oracle/DEVIATIONS.md` (append EDEV-08), `port/contract/nondeterministic-fields.md` (one row) — otherwise verification + push only + +**Interfaces:** +- Consumes: all prior tasks' commits. +- Produces: a pushed branch with red→green proof; explicitly NO pull request. + +- [ ] **Step 1: Sibling-lane drift re-check (cheap, read-only)** + +Run: +```bash +for w in /home/dan/code/freshell/.worktrees/*/; do echo "== $w"; git -C "$w" diff --name-only origin/main...HEAD 2>/dev/null; done +``` +Check whether any sibling lane has landed changes to `crates/freshell-terminal/src/registry.rs` (Lane A5's 10th `scrollback` param — if present, add the extra trailing arg at the Task 3 call site), `crates/freshell-ws/src/tabs_persist_tests.rs` / `tabs_persist_validation.rs`, or `src/store/panesSlice.ts`. As of plan validation (2026-07-25) all five sibling branches were plan-doc-only. + +- [ ] **Step 2: Rust gates** + +Run: +```bash +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test -p freshell-freshagent -p freshell-ws -p freshell-server -p freshell-terminal +``` +Expected: all PASS (freshell-terminal included as the consumer of the now-populated `create_request_id` — we changed no code there, this proves it). + +- [ ] **Step 3: Coordinated client/server suites (gate-aware)** + +Run: +```bash +npm run test:status # if the gate is held by another agent, WAIT — do not kill +npm run typecheck +FRESHELL_TEST_SUMMARY="lane A1 createRequestId final verification" npm test +``` +Expected: PASS. + +- [ ] **Step 4: E2E re-run** + +Run: +```bash +npm run test:e2e -- --project=rust-chromium createrequestid-stabilization-rust +``` +Expected: 2 passed. + +- [ ] **Step 5: Port-parity bookkeeping (DEVIATIONS ledger + nondeterministic-fields inventory)** + +Task 3 made the Rust server emit `paneContent.createRequestId` in `ui.command{tab.create}` / +`pane.split` broadcasts where the legacy Node server emits none (verified: no parity gate +diffs these payloads — V5 report — but `port/AGENTS.md:34-37` requires every deliberate +old-vs-new divergence to be logged in `port/oracle/DEVIATIONS.md` with an objective defect +criterion and a pinning positive test, which now exists and is green as of Steps 2-4). +Two docs-only edits; do NOT touch any other port/ file. + +Edit 1 — append to `port/oracle/DEVIATIONS.md` immediately AFTER the last line of EDEV-07 +(its `- user_impact:` paragraph ending `...with its session identity intact.`, currently +line 854) and BEFORE the `