From 8508e1eb85337a6346a2cd12aca61fa2a32db7c9 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:19:46 -0700 Subject: [PATCH 1/5] feat(tui): recut journal projection cache foundation --- .../crates/cmux-tui-core/src/agent_hooks.rs | 330 ++- cmux-tui/crates/cmux-tui-core/src/mux.rs | 470 ++++- .../src/mux/public_projections.rs | 213 +- .../src/resource_router/auxiliary.rs | 1 + .../cmux-tui-core/src/workspace_registry.rs | 10 +- .../agent_projection_store.rs | 1837 +++++++++++++++++ .../workspace_registry/journal_extensions.rs | 7 + .../public_projection_store.rs | 141 +- .../src/workspace_registry/resource_store.rs | 377 +++- .../src/workspace_registry/session_journal.rs | 266 ++- 10 files changed, 3519 insertions(+), 133 deletions(-) create mode 100644 cmux-tui/crates/cmux-tui-core/src/workspace_registry/agent_projection_store.rs diff --git a/cmux-tui/crates/cmux-tui-core/src/agent_hooks.rs b/cmux-tui/crates/cmux-tui-core/src/agent_hooks.rs index 75842b6046f..99a54752d74 100644 --- a/cmux-tui/crates/cmux-tui-core/src/agent_hooks.rs +++ b/cmux-tui/crates/cmux-tui-core/src/agent_hooks.rs @@ -8,12 +8,40 @@ use crate::{ }; pub const AGENT_HOOK_PRODUCER_ID: &str = "cmux_agent"; -pub const AGENT_HOOK_MANIFEST_VERSION: u32 = 1; +pub const AGENT_HOOK_MANIFEST_VERSION: u32 = 2; const AGENT_HOOK_FORMAT: &str = "cmux.agent-hook.v1"; +const AGENT_CANONICAL_NATIVE_FORMAT: &str = "cmux.agent-native.canonical.v1"; const MAX_AGENT_SOURCE_BYTES: usize = 64; const MAX_NATIVE_EVENT_BYTES: usize = 128; const NORMALIZED_TEXT_BYTES: usize = 8 * 1024; +const MAX_OPAQUE_IDENTIFIER_BYTES: usize = 512; +const MAX_LABEL_BYTES: usize = 128; const REDACTED_AGENT_VALUE: &str = "[redacted]"; +const AGENT_SESSION_SUBJECT_FORMAT: &[u8] = b"cmux.agent-session.v1\0"; +const PROPERTIES_INFO_ID_PATH: &[&str] = &["properties", "info", "id"]; +const EVENT_PROPERTIES_INFO_ID_PATH: &[&str] = &["event", "properties", "info", "id"]; +const EXPLICIT_AGENT_SESSION_ID_PATHS: &[&[&str]] = &[ + &["session_id"], + &["sessionId"], + &["sessionID"], + &["conversation_id"], + &["thread_id"], + &["session", "id"], + &["properties", "sessionID"], + &["properties", "sessionId"], + &["event", "properties", "sessionID"], + &["event", "properties", "sessionId"], + &["event", "properties", "info", "sessionID"], + &["event", "properties", "info", "sessionId"], + &["event", "session_id"], + &["event", "sessionId"], + &["event", "thread", "id"], + &["context", "session_id"], + &["context", "sessionId"], + &["context", "thread", "id"], +]; +const AMBIGUOUS_AGENT_SESSION_ID_PATHS: &[&[&str]] = + &[PROPERTIES_INFO_ID_PATH, EVENT_PROPERTIES_INFO_ID_PATH]; const AGENT_EVENT_KINDS: [&str; 12] = [ "agent.session.started", @@ -40,13 +68,24 @@ pub fn agent_hook_journal_ingress( validate_native_event(native_event)?; let terminal_id = terminal_id.map(TerminalPublicId::parse).transpose()?; let native = redact_agent_native(native_event, native); - let mut normalized = normalized_fields(&native); + let agent_session_id = validate_agent_session_identifiers(&native)?; + let mut normalized = normalized_fields(&native, agent_session_id); add_agent_topology(source, native_event, terminal_id.as_ref(), &mut normalized); let kind = semantic_kind(source, native_event, &normalized); - let mut subjects = Vec::with_capacity(4); - if let Some(terminal_id) = terminal_id { + let native = canonical_native_payload(source, native_event, &normalized); + let mut subjects = Vec::with_capacity(5); + if let Some(terminal_id) = terminal_id.as_ref() { subjects.push(JournalSubject { kind: "terminal".into(), id: terminal_id.to_string() }); } + if let Some(terminal_id) = terminal_id.as_ref() + && !normalized_agent_is_nested(&normalized) + && let Some(source_session) = normalized + .get("agent_session_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + { + subjects.push(agent_session_subject(terminal_id.as_str(), source, source_session)); + } for (field, kind) in [ ("agent_tree_id", "agent_tree"), ("agent_node_id", "agent_node"), @@ -76,6 +115,36 @@ pub fn agent_hook_journal_ingress( }) } +pub(crate) fn agent_session_subject( + terminal_id: &str, + provider: &str, + source_session: &str, +) -> JournalSubject { + let mut digest = Sha256::new(); + digest.update(AGENT_SESSION_SUBJECT_FORMAT); + for component in [terminal_id, provider, source_session] { + digest.update((component.len() as u64).to_be_bytes()); + digest.update(component.as_bytes()); + } + let digest = digest.finalize(); + let mut id = String::with_capacity(digest.len() * 2); + const HEX: &[u8; 16] = b"0123456789abcdef"; + for byte in digest.iter().copied() { + id.push(char::from(HEX[usize::from(byte >> 4)])); + id.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + JournalSubject { kind: "agent_session".into(), id } +} + +pub(crate) fn normalized_agent_is_nested(normalized: &Map) -> bool { + normalized.get("agent_depth").and_then(Value::as_u64).is_some_and(|depth| depth > 0) + || normalized.get("parent_agent_node_id").is_some() + || normalized + .get("agent_relation") + .and_then(Value::as_str) + .is_some_and(|relation| relation != "root") +} + fn redact_agent_native(native_event: &str, mut native: Value) -> Value { if semantic_key(native_event) == "input" { return json!({"redacted":true,"reason":"raw_input"}); @@ -187,7 +256,65 @@ pub(crate) fn built_in_agent_producer_manifest() -> JournalProducerManifest { }, "native_event":{"type":"string","minLength":1,"maxLength":MAX_NATIVE_EVENT_BYTES}, "normalized":{"type":"object"}, - "native":{} + "native":{ + "type":"object", + "required":["format","provider","native_event","identifiers","checkpoint","topology","lifecycle"], + "properties":{ + "format":{"const":AGENT_CANONICAL_NATIVE_FORMAT}, + "provider":{ + "type":"string", + "minLength":1, + "maxLength":MAX_AGENT_SOURCE_BYTES, + "pattern":"^[a-z0-9_-]+$" + }, + "native_event":{"type":"string","minLength":1,"maxLength":MAX_NATIVE_EVENT_BYTES}, + "identifiers":{ + "type":"object", + "properties":{ + "agent_session_id":{"type":"string"}, + "turn_id":{"type":"string"}, + "tool_use_id":{"type":"string"}, + "native_agent_id":{"type":"string"}, + "native_child_agent_id":{"type":"string"}, + "native_parent_agent_id":{"type":"string"}, + "native_root_agent_id":{"type":"string"}, + "root_agent_session_id":{"type":"string"}, + "parent_agent_session_id":{"type":"string"} + }, + "additionalProperties":false + }, + "checkpoint":{ + "type":"object", + "properties":{ + "cwd":{"type":"string"}, + "transcript_path":{"type":"string"} + }, + "additionalProperties":false + }, + "topology":{ + "type":"object", + "properties":{ + "agent_tree_id":{"type":"string"}, + "agent_node_id":{"type":"string"}, + "parent_agent_node_id":{"type":"string"}, + "agent_relation":{"type":"string"}, + "agent_identity_quality":{"type":"string"} + }, + "additionalProperties":false + }, + "lifecycle":{ + "type":"object", + "properties":{ + "tool_name":{"type":"string"}, + "agent_name":{"type":"string"}, + "agent_type":{"type":"string"}, + "agent_depth":{"type":"integer","minimum":0} + }, + "additionalProperties":false + } + }, + "additionalProperties":false + } }, "additionalProperties":false }); @@ -312,34 +439,14 @@ fn is_child_completion(event: &str) -> bool { ) } -fn normalized_fields(native: &Value) -> Map { +fn normalized_fields(native: &Value, agent_session_id: Option<&str>) -> Map { let mut normalized = Map::new(); + if let Some(value) = + agent_session_id.and_then(|value| normalized_provider_string("agent_session_id", value)) + { + normalized.insert("agent_session_id".into(), Value::String(value)); + } for (field, paths) in [ - ( - "agent_session_id", - &[ - &["session_id"][..], - &["sessionId"][..], - &["sessionID"][..], - &["conversation_id"][..], - &["thread_id"][..], - &["session", "id"][..], - &["properties", "sessionID"][..], - &["properties", "sessionId"][..], - &["properties", "info", "id"][..], - &["event", "properties", "sessionID"][..], - &["event", "properties", "sessionId"][..], - &["event", "properties", "info", "id"][..], - &["event", "properties", "info", "sessionID"][..], - &["event", "properties", "info", "sessionId"][..], - &["event", "session_id"][..], - &["event", "sessionId"][..], - &["event", "thread", "id"][..], - &["context", "session_id"][..], - &["context", "sessionId"][..], - &["context", "thread", "id"][..], - ][..], - ), ( "turn_id", &[ @@ -574,9 +681,10 @@ fn normalized_fields(native: &Value) -> Map { ][..], ), ] { - if let Some(value) = first_string_at(native, paths) { - normalized - .insert(field.into(), Value::String(truncate_utf8(value, NORMALIZED_TEXT_BYTES))); + if let Some(value) = first_string_at(native, paths) + && let Some(value) = normalized_provider_string(field, value) + { + normalized.insert(field.into(), Value::String(value)); } } if let Some(depth) = first_value_at( @@ -598,6 +706,153 @@ fn normalized_fields(native: &Value) -> Map { normalized } +fn normalized_provider_string(field: &str, value: &str) -> Option { + match field { + "message" => None, + "agent_session_id" + | "turn_id" + | "tool_use_id" + | "native_agent_id" + | "native_child_agent_id" + | "native_parent_agent_id" + | "native_root_agent_id" + | "root_agent_session_id" + | "parent_agent_session_id" => safe_opaque_identifier(value).then(|| value.to_string()), + "cwd" | "transcript_path" => { + let value = truncate_utf8(value, NORMALIZED_TEXT_BYTES); + safe_checkpoint_path(&value).then_some(value) + } + "tool_name" | "agent_name" | "agent_type" => { + let value = truncate_utf8(value, MAX_LABEL_BYTES); + safe_label(&value).then_some(value) + } + _ => None, + } +} + +fn validate_agent_session_identifiers(native: &Value) -> anyhow::Result> { + let explicit = + validate_agent_session_identifier_paths(native, EXPLICIT_AGENT_SESSION_ID_PATHS)?; + if explicit.is_some() { + return Ok(explicit); + } + validate_agent_session_identifier_paths(native, AMBIGUOUS_AGENT_SESSION_ID_PATHS) +} + +fn validate_agent_session_identifier_paths<'a>( + native: &'a Value, + paths: &[&[&str]], +) -> anyhow::Result> { + let mut session_identifier: Option<&str> = None; + for path in paths { + let Some(value) = agent_session_identifier_at_path(native, path) else { + continue; + }; + anyhow::ensure!( + safe_opaque_identifier(value), + "agent session identifier must contain 1 to {MAX_OPAQUE_IDENTIFIER_BYTES} bytes and no control characters" + ); + let value = value.trim(); + if let Some(expected) = session_identifier { + anyhow::ensure!(value == expected, "conflicting agent session identifiers"); + } else { + session_identifier = Some(value); + } + } + Ok(session_identifier) +} + +fn agent_session_identifier_at_path<'a>(native: &'a Value, path: &[&str]) -> Option<&'a str> { + let info = if path == PROPERTIES_INFO_ID_PATH { + native.get("properties")?.get("info")? + } else if path == EVENT_PROPERTIES_INFO_ID_PATH { + native.get("event")?.get("properties")?.get("info")? + } else { + return path + .iter() + .try_fold(native, |value, component| value.get(*component)) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()); + }; + if ["sessionID", "sessionId"].iter().any(|field| { + info.get(*field).and_then(Value::as_str).is_some_and(|value| !value.trim().is_empty()) + }) { + return None; + } + info.get("id").and_then(Value::as_str).filter(|value| !value.trim().is_empty()) +} + +fn safe_opaque_identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_OPAQUE_IDENTIFIER_BYTES + && !value.chars().any(char::is_control) +} + +fn safe_checkpoint_path(value: &str) -> bool { + !value.is_empty() + && value.len() <= NORMALIZED_TEXT_BYTES + && !value.contains("://") + && !value.chars().any(char::is_control) +} + +fn safe_label(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_LABEL_BYTES + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) +} + +fn canonical_native_payload( + source: &str, + native_event: &str, + normalized: &Map, +) -> Value { + json!({ + "format":AGENT_CANONICAL_NATIVE_FORMAT, + "provider":source, + "native_event":native_event, + "identifiers":canonical_field_group(normalized, &[ + "agent_session_id", + "turn_id", + "tool_use_id", + "native_agent_id", + "native_child_agent_id", + "native_parent_agent_id", + "native_root_agent_id", + "root_agent_session_id", + "parent_agent_session_id", + ]), + "checkpoint":canonical_field_group(normalized, &[ + "cwd", + "transcript_path", + ]), + "topology":canonical_field_group(normalized, &[ + "agent_tree_id", + "agent_node_id", + "parent_agent_node_id", + "agent_relation", + "agent_identity_quality", + ]), + "lifecycle":canonical_field_group(normalized, &[ + "tool_name", + "agent_name", + "agent_type", + "agent_depth", + ]), + }) +} + +fn canonical_field_group(normalized: &Map, fields: &[&str]) -> Value { + let mut group = Map::new(); + for field in fields { + if let Some(value) = normalized.get(*field) { + group.insert((*field).into(), value.clone()); + } + } + Value::Object(group) +} + fn add_agent_topology( source: &str, native_event: &str, @@ -744,12 +999,15 @@ fn stable_topology_id(prefix: &str, components: &[&str]) -> String { } fn first_string_at<'a>(native: &'a Value, paths: &[&[&str]]) -> Option<&'a str> { + first_raw_string_at(native, paths).map(str::trim) +} + +fn first_raw_string_at<'a>(native: &'a Value, paths: &[&[&str]]) -> Option<&'a str> { paths.iter().find_map(|path| { path.iter() .try_fold(native, |value, component| value.get(*component)) .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) + .filter(|value| !value.trim().is_empty()) }) } diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index c4dbd152906..77841414122 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -7,7 +7,9 @@ mod resource_topology; pub(crate) use resource_content::ResourceEffectProjection; -use public_projections::{RestoredPublicProjections, restore_public_projections}; +use public_projections::{ + RestoredPublicProjections, TerminalAgentRecords, restore_public_projections, +}; use std::collections::{HashMap, HashSet, VecDeque}; use std::fmt; use std::ops::{Deref, DerefMut}; @@ -623,6 +625,20 @@ impl DeadlineFanoutPool { true } + /// Put a continuation behind work that is already queued. The caller is + /// one of this pool's active jobs, so its admission retires as soon as it + /// returns and the replacement does not increase steady-state load. + fn resubmit_current(&self, job: DeadlineFanoutJob) -> bool { + let mut state = self.inner.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.shutdown { + return false; + } + state.jobs.push_back(job); + state.admitted_jobs = state.admitted_jobs.saturating_add(1); + self.inner.changed.notify_one(); + true + } + #[cfg(test)] fn worker_count(&self) -> usize { self.inner.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).worker_count @@ -1008,6 +1024,7 @@ pub enum AgentState { Blocked, Idle, Done, + Interrupted, Unknown, } @@ -1018,6 +1035,7 @@ impl AgentState { AgentState::Blocked => "blocked", AgentState::Idle => "idle", AgentState::Done => "done", + AgentState::Interrupted => "interrupted", AgentState::Unknown => "unknown", } } @@ -1097,6 +1115,18 @@ struct TerminalAgentRecord { updated_at_ms: u64, } +#[derive(Debug, Clone)] +struct AgentProjectionCacheRefresh { + version: u64, + after_terminal_id: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct JournalRestorePlan { + pub(crate) head_sequence: u64, + pub(crate) preview: Value, +} + enum AgentReportTarget<'a> { Surface(SurfaceId), Resource { selectors: &'a crate::ResourceSelectors, terminal_id: &'a TerminalPublicId }, @@ -1976,7 +2006,15 @@ pub struct Mux { default_colors: Mutex, durable_terminal_defaults: AtomicBool, sidebar_plugin: Mutex, - agent_records: Mutex>, + agent_records: Mutex, + agent_projection_rebuild_running: AtomicBool, + agent_projection_cache_refresh: Mutex>, + #[cfg(test)] + agent_projection_refresh_failure: AtomicBool, + #[cfg(test)] + agent_projection_rebuild_after_step: Mutex, Receiver<()>)>>, + #[cfg(test)] + journal_before_publish: Mutex>>, /// Nonterminal notifications remain placement-local. Terminal unread /// state is keyed separately by stable content identity so every view of /// one terminal shares the same attention marker. @@ -1992,6 +2030,10 @@ pub struct Mux { /// reread SQLite by cursor, so missed or coalesced notifications are safe. journal_event_epoch: Mutex, journal_event_changed: Condvar, + /// Resource listeners may read derived caches after every wake. Publish + /// this signal only after those caches include the durable commit. + resource_event_epoch: Mutex, + resource_event_changed: Condvar, #[cfg(test)] journal_segment_prepare_hook: Mutex>>, terminal_exit_waiters: TerminalExitWaiters, @@ -2235,7 +2277,7 @@ impl Mux { agent_records, terminal_notifications, notification_ledger, - } = restore_public_projections(&state, registry.public_projections()?)?; + } = restore_public_projections(&state, registry.public_projections_for_cache_restore()?)?; let journal_producers = registry.journal_producer_manifests()?; let session_public_id = registry.session_id().clone(); let journal_kernel = crate::journal_kernel::JournalKernel::new( @@ -2339,7 +2381,15 @@ impl Mux { default_colors: Mutex::new(default_colors), durable_terminal_defaults: AtomicBool::new(has_terminal_defaults), sidebar_plugin: Mutex::new(SidebarPluginRuntime::default()), - agent_records: Mutex::new(agent_records), + agent_records: Mutex::new(agent_records.into()), + agent_projection_rebuild_running: AtomicBool::new(false), + agent_projection_cache_refresh: Mutex::new(None), + #[cfg(test)] + agent_projection_refresh_failure: AtomicBool::new(false), + #[cfg(test)] + agent_projection_rebuild_after_step: Mutex::new(None), + #[cfg(test)] + journal_before_publish: Mutex::new(None), placement_notifications: Mutex::new(HashMap::new()), terminal_notifications: Mutex::new(terminal_notifications), notification_ledger: Mutex::new(notification_ledger), @@ -2350,6 +2400,8 @@ impl Mux { journal_hook_runtime: Arc::new(crate::journal_hooks::JournalHookRuntime::default()), journal_event_epoch: Mutex::new(0), journal_event_changed: Condvar::new(), + resource_event_epoch: Mutex::new(0), + resource_event_changed: Condvar::new(), #[cfg(test)] journal_segment_prepare_hook: Mutex::new(None), terminal_exit_waiters: TerminalExitWaiters::default(), @@ -2377,6 +2429,7 @@ impl Mux { test_surface_runtime, session, }); + mux.start_agent_projection_rebuild_worker()?; crate::journal_ingress::start(&mux, journal_ingress_receiver)?; mux.materialize_interrupted_resource_workspaces()?; mux.materialize_restored_browsers(&contents)?; @@ -4674,6 +4727,17 @@ impl Mux { } fn publish_journal_event(&self) { + #[cfg(test)] + if let Some(hook) = self.journal_before_publish.lock().unwrap().clone() { + hook(); + } + self.publish_journal_commit(); + let mut epoch = self.resource_event_epoch.lock().unwrap(); + *epoch = epoch.wrapping_add(1); + self.resource_event_changed.notify_all(); + } + + fn publish_journal_commit(&self) { self.journal_kernel.notify_commit(); let mut epoch = self.journal_event_epoch.lock().unwrap(); *epoch = epoch.wrapping_add(1); @@ -4835,7 +4899,17 @@ impl Mux { remaining.min(sqlite_wait_cap), admit_commit, )?; - self.publish_journal_event(); + let projection_current = agent_terminal_ids_from_journal_ingresses( + events.iter().filter_map(|event| match *event { + crate::journal_ingress::JournalIngressEvent::Producer { ingress, .. } => { + Some(ingress) + } + _ => None, + }), + ) + .and_then(|terminal_ids| self.sync_agent_records_for_terminals(®istry, terminal_ids)); + drop(registry); + self.publish_committed_journal(projection_current); Ok(commits) } @@ -4862,6 +4936,14 @@ impl Mux { .set_journal_before_commit_for_test(entered, release); } + #[cfg(test)] + pub(crate) fn install_journal_before_publish_for_test( + &self, + hook: Arc, + ) { + *self.journal_before_publish.lock().unwrap() = Some(hook); + } + #[cfg(test)] pub(crate) fn install_journal_after_commit_admission_for_test( &self, @@ -4888,11 +4970,16 @@ impl Mux { } pub(crate) fn resource_event_epoch(&self) -> u64 { - self.journal_event_epoch() + *self.resource_event_epoch.lock().unwrap() } pub(crate) fn wait_for_resource_event(&self, epoch: u64, timeout: Duration) -> u64 { - self.wait_for_journal_event(epoch, timeout) + let current = self.resource_event_epoch.lock().unwrap(); + if *current != epoch { + return *current; + } + let (current, _) = self.resource_event_changed.wait_timeout(current, timeout).unwrap(); + *current } pub(crate) fn resource_events_after( @@ -4971,6 +5058,107 @@ impl Mux { Ok(commit) } + fn start_agent_projection_rebuild_worker(self: &Arc) -> anyhow::Result<()> { + if !self.workspace_registry.lock().unwrap().agent_projection_rebuild_pending()? { + return Ok(()); + } + if self + .agent_projection_rebuild_running + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Ok(()); + } + let weak = Arc::downgrade(self); + if !self + .deadline_fanout_pool + .submit(Box::new(move || Self::run_agent_projection_rebuild_worker(weak))) + { + self.agent_projection_rebuild_running.store(false, Ordering::Release); + anyhow::bail!("could not schedule agent projection rebuild"); + } + Ok(()) + } + + fn run_agent_projection_rebuild_worker(weak: Weak) { + let Some(mux) = weak.upgrade() else { return }; + if mux.shutting_down.load(Ordering::Acquire) { + mux.agent_projection_rebuild_running.store(false, Ordering::Release); + return; + } + let result = (|| -> anyhow::Result<(bool, bool)> { + if mux.agent_projection_cache_refresh.lock().unwrap().is_some() { + return mux.continue_agent_projection_cache_refresh(); + } + let step = + mux.workspace_registry.lock().unwrap().continue_agent_projection_rebuild_page()?; + if !step.checkpoint_ready { + return Ok((false, step.pending)); + } + if step.refresh_required { + mux.begin_agent_projection_cache_refresh()?; + return mux.continue_agent_projection_cache_refresh(); + } + Ok((true, step.pending)) + })(); + match result { + Ok((checkpoint_ready, pending)) => { + if checkpoint_ready { + mux.publish_journal_event(); + } + #[cfg(test)] + if checkpoint_ready { + mux.notify_agent_projection_rebuild_step_for_test(); + } + if !pending { + // Release ownership before the final pending check. An + // ingress in either side of this handshake then starts a + // new worker itself or is observed here. + mux.agent_projection_rebuild_running.store(false, Ordering::Release); + if !mux.shutting_down.load(Ordering::Acquire) { + let rebuild_pending = mux + .workspace_registry + .lock() + .unwrap() + .agent_projection_rebuild_pending(); + match rebuild_pending { + Ok(true) => { + if let Err(error) = mux.start_agent_projection_rebuild_worker() { + eprintln!( + "cmux-tui: restart agent projection rebuild: {error:#}" + ); + mux.request_daemon_shutdown(); + } + } + Ok(false) => {} + Err(error) => { + eprintln!("cmux-tui: check agent projection rebuild: {error:#}"); + mux.request_daemon_shutdown(); + } + } + } + return; + } + let continuation = Arc::downgrade(&mux); + if mux.deadline_fanout_pool.resubmit_current(Box::new(move || { + Self::run_agent_projection_rebuild_worker(continuation); + })) { + return; + } + mux.agent_projection_rebuild_running.store(false, Ordering::Release); + if !mux.shutting_down.load(Ordering::Acquire) { + eprintln!("cmux-tui: could not reschedule agent projection rebuild"); + mux.request_daemon_shutdown(); + } + } + Err(error) => { + mux.agent_projection_rebuild_running.store(false, Ordering::Release); + eprintln!("cmux-tui: rebuild agent projections: {error:#}"); + mux.request_daemon_shutdown(); + } + } + } + pub(crate) fn append_journal_ingress( &self, ingress: &crate::JournalIngress, @@ -4986,18 +5174,188 @@ impl Mux { idempotency_key.into(), ); } - let commit = self.workspace_registry.lock().unwrap().append_journal_ingress( - ingress, - &validated, - origin, - idempotency_key, - )?; + let mut registry = self.workspace_registry.lock().unwrap(); + let commit = + registry.append_journal_ingress(ingress, &validated, origin, idempotency_key)?; + let projection_current = self.sync_agent_records_from_journal_ingress(®istry, ingress); + drop(registry); if !commit.replayed { - self.publish_journal_event(); + self.publish_committed_journal(projection_current); + } else { + projection_current?; } Ok(commit) } + fn publish_committed_journal(&self, projection_current: anyhow::Result) { + match projection_current { + Ok(true) => self.publish_journal_event(), + Ok(false) => self.publish_journal_commit(), + Err(error) => { + // SQLite already accepted the journal transaction. Wake + // durable readers, but keep resource readers asleep because + // the derived agent cache does not contain this commit. + self.publish_journal_commit(); + eprintln!("cmux-tui: refresh agent cache after durable journal commit: {error:#}"); + self.request_daemon_shutdown(); + } + } + } + + fn sync_agent_records_from_journal_ingress( + &self, + registry: &WorkspaceRegistry, + ingress: &crate::JournalIngress, + ) -> anyhow::Result { + let terminal_ids = agent_terminal_ids_from_journal_ingresses(std::iter::once(ingress))?; + self.sync_agent_records_for_terminals(registry, terminal_ids) + } + + fn sync_agent_records_for_terminals( + &self, + registry: &WorkspaceRegistry, + terminal_ids: HashSet, + ) -> anyhow::Result { + let refresh_version = self + .agent_projection_cache_refresh + .lock() + .unwrap() + .as_ref() + .map(|refresh| refresh.version); + if let Some(version) = refresh_version { + self.stage_agent_records_for_terminals(registry, terminal_ids, version)?; + return Ok(false); + } + if registry.agent_projection_rebuild_pending()? { + return Ok(false); + } + self.refresh_agent_records_for_terminals(registry, terminal_ids)?; + Ok(true) + } + + fn stage_agent_records_for_terminals( + &self, + registry: &WorkspaceRegistry, + terminal_ids: HashSet, + version: u64, + ) -> anyhow::Result<()> { + let mut records = Vec::with_capacity(terminal_ids.len()); + for terminal_id in terminal_ids { + let Some(projection) = registry.agent_projection_for_cache_refresh(&terminal_id)? + else { + continue; + }; + records.push(( + projection.terminal_id, + public_projections::terminal_agent_record( + &projection.state, + &projection.source, + projection.source_session, + projection.updated_at_ms, + )?, + )); + } + self.agent_records.lock().unwrap().stage_or_insert(version, records) + } + + fn refresh_agent_records_for_terminals( + &self, + registry: &WorkspaceRegistry, + terminal_ids: HashSet, + ) -> anyhow::Result<()> { + #[cfg(test)] + if self.agent_projection_refresh_failure.swap(false, Ordering::AcqRel) { + anyhow::bail!("forced agent projection refresh failure"); + } + if terminal_ids.is_empty() { + return Ok(()); + } + + let mut projections = Vec::with_capacity(terminal_ids.len()); + for terminal_id in terminal_ids { + projections.extend(registry.public_agent_projections(Some(&terminal_id), None)?); + } + let mut records = self.agent_records.lock().unwrap(); + for projection in projections { + let record = public_projections::terminal_agent_record( + &projection.state, + &projection.source, + projection.source_session, + projection.updated_at_ms, + )?; + records.insert(projection.terminal_id, record); + } + Ok(()) + } + + fn begin_agent_projection_cache_refresh(&self) -> anyhow::Result<()> { + let version = self.agent_records.lock().unwrap().begin_staging()?; + let mut refresh = self.agent_projection_cache_refresh.lock().unwrap(); + anyhow::ensure!(refresh.is_none(), "agent projection cache refresh is already active"); + *refresh = Some(AgentProjectionCacheRefresh { version, after_terminal_id: None }); + Ok(()) + } + + fn continue_agent_projection_cache_refresh(&self) -> anyhow::Result<(bool, bool)> { + #[cfg(test)] + if self.agent_projection_refresh_failure.swap(false, Ordering::AcqRel) { + anyhow::bail!("forced agent projection refresh failure"); + } + let refresh = self + .agent_projection_cache_refresh + .lock() + .unwrap() + .clone() + .context("agent projection cache refresh is absent")?; + // Keep the established registry -> cache lock order for this bounded + // page. A newer direct write then either precedes the read or clears + // this staged value after the page releases the registry. + let registry = self.workspace_registry.lock().unwrap(); + let page = + registry.agent_projection_rebuild_change_page(refresh.after_terminal_id.as_ref())?; + let records = page + .projections + .into_iter() + .map(|projection| { + Ok(( + projection.terminal_id, + public_projections::terminal_agent_record( + &projection.state, + &projection.source, + projection.source_session, + projection.updated_at_ms, + )?, + )) + }) + .collect::>>()?; + self.agent_records.lock().unwrap().stage(refresh.version, records)?; + drop(registry); + if !page.complete { + let last_terminal_id = + page.last_terminal_id.context("agent projection refresh page has no cursor")?; + let mut state = self.agent_projection_cache_refresh.lock().unwrap(); + let active = state.as_mut().context("agent projection cache refresh disappeared")?; + anyhow::ensure!( + active.version == refresh.version, + "agent projection cache refresh version changed" + ); + active.after_terminal_id = Some(last_terminal_id); + return Ok((false, true)); + } + + // Every staged entry is hidden until this one version change. This + // keeps direct readers from observing a partial fixed checkpoint. + self.agent_records.lock().unwrap().publish(refresh.version)?; + // Clear only after publication succeeds. A failure keeps the durable + // terminal set available for a later process restart. + let registry = self.workspace_registry.lock().unwrap(); + registry.clear_agent_projection_rebuild_changes()?; + let rebuild_pending = registry.agent_projection_rebuild_pending()?; + drop(registry); + *self.agent_projection_cache_refresh.lock().unwrap() = None; + Ok((true, rebuild_pending)) + } + pub(crate) fn journal_hook_states( &self, ) -> anyhow::Result> { @@ -5144,6 +5502,40 @@ impl Mux { reducer.finish(head_sequence) } + pub(crate) fn prepare_journal_restore( + &self, + selector: &str, + ) -> anyhow::Result { + let preview = self.journal_restore_preview(selector)?; + let head_sequence = preview["head_sequence"] + .as_str() + .context("restore preview omitted head_sequence")? + .parse() + .context("restore preview head_sequence is invalid")?; + Ok(JournalRestorePlan { head_sequence, preview }) + } + + pub(crate) fn journal_projection_status(&self) -> anyhow::Result { + anyhow::bail!("journal restore implementation pending") + } + + pub(crate) fn journal_list(&self) -> anyhow::Result { + anyhow::bail!("journal restore implementation pending") + } + + pub(crate) fn journal_inspect(&self, _selector: Option<&str>) -> anyhow::Result { + anyhow::bail!("journal restore implementation pending") + } + + pub(crate) fn restore_journal_projections_with_receipt( + &self, + _plan: JournalRestorePlan, + _origin: &str, + _idempotency_key: &str, + ) -> anyhow::Result<(Value, crate::workspace_registry::JournalRestoreCommit)> { + anyhow::bail!("journal restore implementation pending") + } + pub(crate) fn journal_segments(&self) -> anyhow::Result> { self.workspace_registry.lock().unwrap().journal_segments() } @@ -5216,11 +5608,41 @@ impl Mux { self.workspace_registry.lock().unwrap().resource_agent_projection_count_for_test() } + #[cfg(test)] + pub(crate) fn agent_projection_rebuild_pending_for_test(&self) -> anyhow::Result { + self.workspace_registry.lock().unwrap().agent_projection_rebuild_pending() + } + #[cfg(test)] pub(crate) fn corrupt_agent_projection_for_test(&self, terminal_id: &TerminalPublicId) { self.workspace_registry.lock().unwrap().corrupt_agent_projection_for_test(terminal_id); } + #[cfg(test)] + pub(crate) fn fail_next_agent_projection_refresh_for_test(&self) { + self.agent_projection_refresh_failure.store(true, Ordering::Release); + } + + #[cfg(test)] + pub(crate) fn install_agent_projection_rebuild_after_step_for_test( + &self, + entered: SyncSender<()>, + release: Receiver<()>, + ) { + *self.agent_projection_rebuild_after_step.lock().unwrap() = Some((entered, release)); + } + + #[cfg(test)] + fn notify_agent_projection_rebuild_step_for_test(&self) { + let Some((entered, release)) = + self.agent_projection_rebuild_after_step.lock().unwrap().take() + else { + return; + }; + entered.send(()).unwrap(); + release.recv().unwrap(); + } + pub fn terminal_registry_snapshot(&self) -> anyhow::Result { self.workspace_registry.lock().unwrap().terminal_snapshot() } @@ -8368,7 +8790,11 @@ impl Mux { let mut records = self.agent_records.lock().unwrap(); let record = match records.get(&terminal_id) { Some(existing) - if existing.source == AgentSource::Hook && source == AgentSource::Socket => + if existing.source == AgentSource::Hook + && source == AgentSource::Socket + && (existing.session.is_none() + || source_session.is_none() + || existing.session == source_session) => { existing.clone() } @@ -8481,7 +8907,7 @@ impl Mux { surface: Option, state: Option, ) -> Vec { - let records = self.agent_records.lock().unwrap().clone(); + let records = self.agent_records.lock().unwrap().snapshot(); let state_snapshot = self.state.lock().unwrap(); let requested_terminal = surface.and_then(|surface| { state_snapshot @@ -14671,6 +15097,18 @@ impl Mux { } } +fn agent_terminal_ids_from_journal_ingresses<'a>( + ingresses: impl IntoIterator, +) -> anyhow::Result> { + ingresses + .into_iter() + .filter(|ingress| ingress.producer_id == crate::AGENT_HOOK_PRODUCER_ID) + .flat_map(|ingress| ingress.subjects.iter()) + .filter(|subject| subject.kind == "terminal") + .map(|subject| TerminalPublicId::parse(subject.id.clone()).map_err(Into::into)) + .collect() +} + fn persist_public_topology_result( operation: &str, result: &mut Value, diff --git a/cmux-tui/crates/cmux-tui-core/src/mux/public_projections.rs b/cmux-tui/crates/cmux-tui-core/src/mux/public_projections.rs index 965968c0c1b..45c61542e44 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux/public_projections.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux/public_projections.rs @@ -1,7 +1,7 @@ use anyhow::Context; use super::*; -use crate::workspace_registry::RegistryPublicProjections; +use crate::workspace_registry::{RegistryAgentProjection, RegistryPublicProjections}; #[derive(Debug)] pub(super) struct RestoredPublicProjections { @@ -13,6 +13,163 @@ pub(super) struct RestoredPublicProjections { pub(super) notification_ledger: VecDeque, } +#[derive(Debug)] +struct PendingTerminalAgentRecord { + version: u64, + record: TerminalAgentRecord, +} + +#[derive(Debug)] +struct VersionedTerminalAgentRecord { + published: Option, + pending: Option, +} + +#[derive(Debug)] +pub(super) struct TerminalAgentRecords { + entries: HashMap, + published_version: u64, + next_version: u64, +} + +impl From> for TerminalAgentRecords { + fn from(records: HashMap) -> Self { + Self { + entries: records + .into_iter() + .map(|(terminal_id, record)| { + ( + terminal_id, + VersionedTerminalAgentRecord { published: Some(record), pending: None }, + ) + }) + .collect(), + published_version: 0, + next_version: 0, + } + } +} + +impl TerminalAgentRecords { + fn visible_record( + entry: &VersionedTerminalAgentRecord, + published_version: u64, + ) -> Option<&TerminalAgentRecord> { + entry + .pending + .as_ref() + .filter(|pending| pending.version <= published_version) + .map(|pending| &pending.record) + .or(entry.published.as_ref()) + } + + pub(super) fn get(&self, terminal_id: &TerminalPublicId) -> Option<&TerminalAgentRecord> { + self.entries + .get(terminal_id) + .and_then(|entry| Self::visible_record(entry, self.published_version)) + } + + pub(super) fn insert( + &mut self, + terminal_id: TerminalPublicId, + record: TerminalAgentRecord, + ) -> Option { + let entry = self + .entries + .entry(terminal_id) + .or_insert(VersionedTerminalAgentRecord { published: None, pending: None }); + let previous = Self::visible_record(entry, self.published_version).cloned(); + entry.published = Some(record); + entry.pending = None; + previous + } + + pub(super) fn begin_staging(&mut self) -> anyhow::Result { + self.next_version = + self.next_version.checked_add(1).context("agent cache publication version overflow")?; + Ok(self.next_version) + } + + pub(super) fn stage( + &mut self, + version: u64, + records: Vec<(TerminalPublicId, TerminalAgentRecord)>, + ) -> anyhow::Result<()> { + anyhow::ensure!( + version > self.published_version && version <= self.next_version, + "agent cache staging version {version} is invalid" + ); + for (terminal_id, record) in records { + let entry = self + .entries + .entry(terminal_id) + .or_insert(VersionedTerminalAgentRecord { published: None, pending: None }); + if entry + .pending + .as_ref() + .is_some_and(|pending| pending.version <= self.published_version) + { + entry.published = entry.pending.take().map(|pending| pending.record); + } + anyhow::ensure!( + entry.pending.as_ref().is_none_or(|pending| pending.version == version), + "agent cache has a newer unpublished staging version" + ); + entry.pending = Some(PendingTerminalAgentRecord { version, record }); + } + Ok(()) + } + + pub(super) fn stage_or_insert( + &mut self, + version: u64, + records: Vec<(TerminalPublicId, TerminalAgentRecord)>, + ) -> anyhow::Result<()> { + if version > self.published_version { + return self.stage(version, records); + } + anyhow::ensure!( + version == self.published_version, + "agent cache synchronization version {version} is stale" + ); + for (terminal_id, record) in records { + self.insert(terminal_id, record); + } + Ok(()) + } + + pub(super) fn publish(&mut self, version: u64) -> anyhow::Result<()> { + anyhow::ensure!( + version > self.published_version && version <= self.next_version, + "agent cache publication version {version} is invalid" + ); + self.published_version = version; + Ok(()) + } + + pub(super) fn remove(&mut self, terminal_id: &TerminalPublicId) -> Option { + self.entries + .remove(terminal_id) + .and_then(|entry| Self::visible_record(&entry, self.published_version).cloned()) + } + + pub(super) fn snapshot(&self) -> HashMap { + self.entries + .iter() + .filter_map(|(terminal_id, entry)| { + Self::visible_record(entry, self.published_version) + .cloned() + .map(|record| (terminal_id.clone(), record)) + }) + .collect() + } + + #[cfg(test)] + pub(super) fn clear(&mut self) { + self.entries.clear(); + } +} + pub(super) fn restore_public_projections( state: &State, projections: RegistryPublicProjections, @@ -58,23 +215,7 @@ pub(super) fn restore_public_projections( .context("notification count exceeds uint64")? .saturating_add(1); - let mut agent_records = HashMap::with_capacity(projections.agents.len()); - for agent in projections.agents { - let previous = agent_records.insert( - agent.terminal_id.clone(), - TerminalAgentRecord { - state: agent_state(&agent.state)?, - source: agent_source(&agent.source)?, - session: agent.source_session, - updated_at_ms: agent.updated_at_ms, - }, - ); - anyhow::ensure!( - previous.is_none(), - "multiple durable agents resolve to terminal {}", - agent.terminal_id - ); - } + let agent_records = restore_agent_projections(projections.agents)?; Ok(RestoredPublicProjections { default_colors, @@ -86,6 +227,41 @@ pub(super) fn restore_public_projections( }) } +pub(super) fn restore_agent_projections( + agents: Vec, +) -> anyhow::Result> { + let mut agent_records = HashMap::with_capacity(agents.len()); + for agent in agents { + let terminal_id = agent.terminal_id; + let record = terminal_agent_record( + &agent.state, + &agent.source, + agent.source_session, + agent.updated_at_ms, + )?; + let previous = agent_records.insert(terminal_id.clone(), record); + anyhow::ensure!( + previous.is_none(), + "multiple durable agents resolve to terminal {terminal_id}" + ); + } + Ok(agent_records) +} + +pub(super) fn terminal_agent_record( + state: &str, + source: &str, + session: Option, + updated_at_ms: u64, +) -> anyhow::Result { + Ok(TerminalAgentRecord { + state: agent_state(state)?, + source: agent_source(source)?, + session, + updated_at_ms, + }) +} + fn notification_level(value: &str) -> anyhow::Result { match value { "info" => Ok(NotificationLevel::Info), @@ -101,6 +277,7 @@ fn agent_state(value: &str) -> anyhow::Result { "blocked" => Ok(AgentState::Blocked), "idle" => Ok(AgentState::Idle), "done" => Ok(AgentState::Done), + "interrupted" => Ok(AgentState::Interrupted), "unknown" => Ok(AgentState::Unknown), other => anyhow::bail!("invalid durable agent state {other:?}"), } diff --git a/cmux-tui/crates/cmux-tui-core/src/resource_router/auxiliary.rs b/cmux-tui/crates/cmux-tui-core/src/resource_router/auxiliary.rs index e90aa832d1d..033f40c76bc 100644 --- a/cmux-tui/crates/cmux-tui-core/src/resource_router/auxiliary.rs +++ b/cmux-tui/crates/cmux-tui-core/src/resource_router/auxiliary.rs @@ -122,6 +122,7 @@ fn parse_agent_state(value: &Value) -> Result { Some("blocked") => Ok(AgentState::Blocked), Some("idle") => Ok(AgentState::Idle), Some("done") => Ok(AgentState::Done), + Some("interrupted") => Ok(AgentState::Interrupted), Some("unknown") => Ok(AgentState::Unknown), _ => Err(validation_error("invalid agent state", json!({"state":value}))), } diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs index bdbb0b2288c..f685e7df057 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs @@ -30,6 +30,7 @@ use crate::resource::{ #[cfg(unix)] use crate::terminal_host_runtime::TerminalHostLiveness; +mod agent_projection_store; mod effect_store; mod journal_extensions; mod public_projection_store; @@ -37,6 +38,7 @@ mod resource_store; mod session_journal; mod terminal_exit_store; +use agent_projection_store::rebuild_agent_projections_from_journal; pub(crate) use effect_store::ResourceWorkspaceClose; pub use effect_store::{ ResourceCreationPreparation, ResourceCreationRecovery, ResourceEffectOutcome, @@ -55,11 +57,12 @@ pub use journal_extensions::{ pub(crate) use journal_extensions::{ JournalCheckpointCommit, JournalCheckpointSummary, JournalContentBlob, JournalHookAttempt, JournalHookDelivery, JournalHookDeliveryResult, JournalHookScan, JournalHookState, - JournalSegmentSealCommit, JournalSegmentSealStart, + JournalRestoreCommit, JournalSegmentSealCommit, JournalSegmentSealStart, }; -pub use public_projection_store::RegistryPublicProjections; +pub(crate) use public_projection_store::RegistryAgentProjection; #[cfg(test)] -pub use public_projection_store::{RegistryAgentProjection, RegistryNotificationProjection}; +pub(crate) use public_projection_store::RegistryNotificationProjection; +pub use public_projection_store::RegistryPublicProjections; pub(crate) use resource_store::validate_registry_screen_projection; #[allow(unused_imports)] pub use resource_store::{ @@ -2611,6 +2614,7 @@ impl WorkspaceRegistry { "workspace registry belongs to session {stored_name:?}, not {session_name:?}" ); } + rebuild_agent_projections_from_journal(&connection, false)?; let registry_id = required_meta(&connection, "registry_id")?; validate_identifier("registry id", ®istry_id)?; let session_id = SessionPublicId::parse(required_meta(&connection, "session_public_id")?)?; diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/agent_projection_store.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/agent_projection_store.rs new file mode 100644 index 00000000000..f7daecf9b44 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/agent_projection_store.rs @@ -0,0 +1,1837 @@ +use super::*; + +use crate::resource::AgentPublicId; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +const RECOVERY_FORMAT: &str = "cmux.agent-recovery.v1"; +const RECOVERY_PRODUCER_ID: &str = "agent-recovery-v1"; +const PREJOURNAL_MIGRATION_FORMAT: &str = "cmux.agent-projection-migration.v1"; +const PREJOURNAL_MIGRATION_PRODUCER_ID: &str = "agent-projection-v1"; +const AGENT_PROJECTION_JOURNAL_CURSOR_KEY: &str = "agent_projection_journal_sequence_v1"; +const AGENT_PROJECTION_JOURNAL_CANDIDATE_KEY: &str = + "agent_projection_journal_candidate_sequence_v1"; +const AGENT_PROJECTION_JOURNAL_REBUILD_TARGET_KEY: &str = + "agent_projection_journal_rebuild_target_sequence_v1"; +const AGENT_PROJECTION_JOURNAL_LIVE_SEQUENCE_KEY: &str = + "agent_projection_journal_live_sequence_v1"; +const AGENT_PROJECTION_PREJOURNAL_MIGRATION_CURSOR_KEY: &str = + "agent_projection_prejournal_migration_terminal_v1"; +const UNKNOWN_AGENT_PROVIDER_GENERATION_KEY: &str = ""; +const AGENT_PROJECTION_PREJOURNAL_MIGRATION_PAGE_SIZE: usize = 64; +const AGENT_PROJECTION_JOURNAL_REBUILD_PAGE_SIZE: usize = 1_024; +const AGENT_SESSION_GENERATION_RETENTION: usize = 64; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AgentProjectionRow { + terminal_id: TerminalPublicId, + state: String, + source: String, + updated_at_ms: u64, + source_session: Option, + provider: Option, + turn_id: Option, + committed_sequence: u64, + result: Option, + begins_session: bool, + begins_turn: bool, +} + +pub(super) struct AgentProjectionJournalInput<'a> { + pub(super) sequence: u64, + pub(super) kind: &'a str, + pub(super) occurred_at_ms: u64, + pub(super) producer: &'a JournalProducer, + pub(super) subjects: &'a [JournalSubject], + pub(super) payload: &'a Value, + pub(super) resource_revision: Option, + pub(super) rebuilding_generation_history: bool, + pub(super) replaying_projection_journal: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct AgentProjectionRebuildStep { + pub(crate) checkpoint_ready: bool, + pub(crate) pending: bool, + pub(crate) refresh_required: bool, +} + +pub(crate) struct AgentProjectionRebuildChangePage { + pub(crate) projections: Vec, + pub(crate) last_terminal_id: Option, + pub(crate) complete: bool, +} + +pub(super) fn apply_agent_projection_journal_record( + transaction: &Transaction<'_>, + input: AgentProjectionJournalInput<'_>, +) -> anyhow::Result> { + let AgentProjectionJournalInput { + sequence, + kind, + occurred_at_ms, + producer, + subjects, + payload, + resource_revision, + rebuilding_generation_history, + replaying_projection_journal, + } = input; + let advances_cursor = kind.starts_with("agent."); + let Some(next) = projection_from_journal_record( + sequence, + kind, + occurred_at_ms, + producer, + subjects, + payload, + resource_revision, + )? + else { + if advances_cursor { + advance_agent_projection_journal_cursor(transaction, sequence)?; + } + return Ok(None); + }; + if !terminal_is_live(transaction, &next.terminal_id)? { + if advances_cursor { + advance_agent_projection_journal_cursor(transaction, sequence)?; + } + return Ok(None); + } + let current = stored_projection(transaction, &next.terminal_id)?; + if replaying_projection_journal + && !rebuilding_generation_history + && deferred_live_agent_session_is_superseded(transaction, &next)? + { + if advances_cursor { + advance_agent_projection_journal_cursor(transaction, sequence)?; + } + return Ok(None); + } + // Pre-journal and generation migration use the stored projections as their + // baseline, so live events must keep those projections current. Ordered + // journal replay instead owns a fixed historical prefix. Validate new + // structured identities before commit, then leave their projection work + // after the fixed prefix and remember where permissive history ends. + if !replaying_projection_journal + && !rebuilding_generation_history + && agent_projection_journal_rebuild_target(transaction)?.is_some() + { + validate_deferred_projection_transition(transaction, current.as_ref(), &next)?; + validate_deferred_agent_session_generation(transaction, current.as_ref(), &next)?; + record_agent_session_generation(transaction, current.as_ref(), &next, false)?; + note_agent_projection_journal_live_sequence(transaction, sequence)?; + return Ok(None); + } + validate_projection_transition(current.as_ref(), &next)?; + let selected = merge_projection(current.clone(), next.clone()); + if replaying_projection_journal || agent_projection_rebuild_changes_pending(transaction)? { + record_agent_projection_rebuild_change(transaction, &next.terminal_id)?; + } + upsert_projection(transaction, &selected)?; + if selected.committed_sequence == next.committed_sequence { + record_agent_session_generation( + transaction, + current.as_ref(), + &next, + rebuilding_generation_history, + )?; + } else if rebuilding_generation_history { + record_superseded_agent_session_generation(transaction, &next)?; + } + if advances_cursor { + advance_agent_projection_journal_cursor(transaction, sequence)?; + } + Ok(Some(next.terminal_id)) +} + +fn validate_projection_transition( + current: Option<&AgentProjectionRow>, + next: &AgentProjectionRow, +) -> anyhow::Result<()> { + if next.source != "socket" { + return Ok(()); + } + let Some(current) = current else { + return Ok(()); + }; + if next.committed_sequence < current.committed_sequence { + return Ok(()); + } + let current_is_active = matches!(current.state.as_str(), "working" | "blocked" | "idle"); + anyhow::ensure!( + !current_is_active + || !matches!(current.source.as_str(), "hook" | "socket") + || current.source_session.is_none() + || next.source_session.is_some(), + "agent socket report omits active {} session {:?}", + current.source, + current.source_session + ); + let conflicting_structured_identity = current.source_session.is_some() + && next.source_session.is_some() + && (current.source_session != next.source_session + || current.provider.is_some() + && next.provider.is_some() + && current.provider != next.provider); + anyhow::ensure!( + current.source != "hook" + || next.source != "socket" + || !current_is_active + || !conflicting_structured_identity, + "agent socket report session {:?} conflicts with active hook session {:?}", + next.source_session, + current.source_session + ); + anyhow::ensure!( + current.source != "socket" + || next.source != "socket" + || !current_is_active + || !conflicting_structured_identity, + "agent socket report session {:?} conflicts with active socket session {:?}", + next.source_session, + current.source_session + ); + Ok(()) +} + +fn validate_deferred_projection_transition( + transaction: &Transaction<'_>, + current: Option<&AgentProjectionRow>, + next: &AgentProjectionRow, +) -> anyhow::Result<()> { + if next.source != "socket" { + return Ok(()); + } + let active = transaction + .query_row( + "SELECT provider, source_session + FROM resource_agent_session_generations + WHERE terminal_id = ?1 AND superseded = 0", + [next.terminal_id.as_str()], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + let Some((active_provider, active_session)) = active else { + return validate_projection_transition(current, next); + }; + let Some(next_session) = next.source_session.as_deref() else { + anyhow::bail!("agent socket report omits active agent session {active_session:?}"); + }; + let next_provider = agent_generation_provider(next.provider.as_deref()); + anyhow::ensure!( + active_provider == next_provider && active_session == next_session, + "agent socket report session {:?} conflicts with active agent session {:?}", + next.source_session, + active_session, + ); + Ok(()) +} + +fn validate_deferred_agent_session_generation( + transaction: &Transaction<'_>, + current: Option<&AgentProjectionRow>, + next: &AgentProjectionRow, +) -> anyhow::Result<()> { + let Some(source_session) = next.source_session.as_deref() else { + return Ok(()); + }; + let provider = agent_generation_provider(next.provider.as_deref()); + let existing = transaction + .query_row( + "SELECT generation, superseded + FROM resource_agent_session_generations + WHERE terminal_id = ?1 AND provider = ?2 AND source_session = ?3", + params![next.terminal_id.as_str(), provider, source_session], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, bool>(1)?)), + ) + .optional()?; + if let Some((generation, superseded)) = existing { + anyhow::ensure!(generation > 0, "agent session generation is not positive"); + if superseded { + anyhow::bail!( + "agent {} report session {:?} conflicts with active agent session {:?}: session belongs to superseded generation {generation}", + next.source, + next.source_session, + current.and_then(|projection| projection.source_session.as_deref()), + ); + } + let active_matches = transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM resource_agent_session_generations + WHERE terminal_id = ?1 AND provider = ?2 AND source_session = ?3 + AND generation = ?4 AND superseded = 0 + )", + params![next.terminal_id.as_str(), provider, source_session, generation], + |row| row.get::<_, bool>(0), + )?; + anyhow::ensure!(active_matches, "current agent session generation is inconsistent"); + return Ok(()); + } + + let journal_identity = + ensure_agent_session_journal_identity(transaction, next, provider, source_session)?; + anyhow::ensure!( + !agent_session_identity_precedes_deferred_live_boundary( + transaction, + &journal_identity, + next.committed_sequence, + )?, + "agent {} report session {:?} belongs to a compacted superseded generation", + next.source, + next.source_session, + ); + Ok(()) +} + +fn agent_session_identity_precedes_deferred_live_boundary( + transaction: &Transaction<'_>, + journal_identity: &str, + committed_sequence: u64, +) -> anyhow::Result { + // The first event accepted after the fixed replay prefix records this + // boundary. Later events for that same live session are valid, while an + // identity that existed before the boundary belongs to older history. + let boundary = + agent_projection_journal_live_sequence(transaction)?.unwrap_or(committed_sequence); + agent_session_identity_precedes_record(transaction, journal_identity, boundary) +} + +fn deferred_live_agent_session_is_superseded( + transaction: &Transaction<'_>, + next: &AgentProjectionRow, +) -> anyhow::Result { + let Some(source_session) = next.source_session.as_deref() else { + return Ok(false); + }; + let Some(live_sequence) = agent_projection_journal_live_sequence(transaction)? else { + return Ok(false); + }; + let provider = agent_generation_provider(next.provider.as_deref()); + let superseded = transaction + .query_row( + "SELECT superseded + FROM resource_agent_session_generations + WHERE terminal_id = ?1 AND provider = ?2 AND source_session = ?3", + params![next.terminal_id.as_str(), provider, source_session], + |row| row.get::<_, bool>(0), + ) + .optional()? + .unwrap_or(false); + if !superseded { + return Ok(false); + } + let journal_identity = crate::agent_hooks::agent_session_subject( + next.terminal_id.as_str(), + provider, + source_session, + ) + .id; + Ok(!agent_session_identity_precedes_record(transaction, &journal_identity, live_sequence)?) +} + +/// Keep accepted structured sessions in the same transaction as their journal +/// projection. A retired identity must stay retired after the current session +/// becomes final and after the registry reopens. +fn record_agent_session_generation( + transaction: &Transaction<'_>, + current: Option<&AgentProjectionRow>, + next: &AgentProjectionRow, + rebuilding_generation_history: bool, +) -> anyhow::Result<()> { + let Some(source_session) = next.source_session.as_deref() else { + return Ok(()); + }; + let provider = agent_generation_provider(next.provider.as_deref()); + let journal_identity = + ensure_agent_session_journal_identity(transaction, next, provider, source_session)?; + let existing = transaction + .query_row( + "SELECT generation, superseded + FROM resource_agent_session_generations + WHERE terminal_id = ?1 AND provider = ?2 AND source_session = ?3", + params![next.terminal_id.as_str(), provider, source_session], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, bool>(1)?)), + ) + .optional()?; + let active = transaction + .query_row( + "SELECT provider, source_session, generation + FROM resource_agent_session_generations + WHERE terminal_id = ?1 AND superseded = 0", + [next.terminal_id.as_str()], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?)), + ) + .optional()?; + let preserve_deferred_live_active = rebuilding_generation_history + && active_agent_session_started_in_deferred_live_tail(transaction, &next.terminal_id)?; + if let Some((generation, superseded)) = existing { + transaction.execute( + "UPDATE resource_agent_session_generations + SET journal_identity = COALESCE(journal_identity, ?4) + WHERE terminal_id = ?1 AND provider = ?2 AND source_session = ?3", + params![next.terminal_id.as_str(), provider, source_session, journal_identity], + )?; + anyhow::ensure!(generation > 0, "agent session generation is not positive"); + if superseded { + if preserve_deferred_live_active { + return Ok(()); + } + let stored_current_matches = current.is_some_and(|projection| { + agent_generation_provider(projection.provider.as_deref()) == provider + && projection.source_session.as_deref() == Some(source_session) + }); + let restore_stored_current_during_backfill = !rebuilding_generation_history + && stored_current_matches + && resource_store::resource_agent_generation_backfill_pending(transaction)?; + if rebuilding_generation_history || restore_stored_current_during_backfill { + if let Some((active_provider, active_session, active_generation)) = &active { + anyhow::ensure!( + *active_generation > 0, + "active agent session generation is not positive" + ); + transaction.execute( + "UPDATE resource_agent_session_generations + SET superseded = 1 + WHERE terminal_id = ?1 AND provider = ?2 + AND source_session = ?3 AND superseded = 0", + params![next.terminal_id.as_str(), active_provider, active_session], + )?; + } + let reactivated = transaction.execute( + "UPDATE resource_agent_session_generations + SET superseded = 0 + WHERE terminal_id = ?1 AND provider = ?2 + AND source_session = ?3 AND superseded = 1", + params![next.terminal_id.as_str(), provider, source_session], + )?; + anyhow::ensure!(reactivated == 1, "replayed agent generation disappeared"); + return Ok(()); + } + let active_source = current + .filter(|projection| { + active.as_ref().is_some_and(|(provider, session, _)| { + agent_generation_provider(projection.provider.as_deref()) + == provider.as_str() + && projection.source_session.as_deref() == Some(session.as_str()) + }) + }) + .map(|projection| projection.source.as_str()) + .unwrap_or("agent"); + anyhow::bail!( + "agent {} report session {:?} conflicts with active {} session {:?}: session belongs to superseded generation {generation}", + next.source, + next.source_session, + active_source, + active.as_ref().map(|(_, session, _)| session), + ); + } + anyhow::ensure!( + active.as_ref().is_some_and(|(active_provider, session, active_generation)| { + active_provider.as_str() == provider + && session == source_session + && *active_generation == generation + }), + "current agent session generation is inconsistent" + ); + return Ok(()); + } + + if !rebuilding_generation_history + && agent_session_identity_precedes_record( + transaction, + &journal_identity, + next.committed_sequence, + )? + { + anyhow::bail!( + "agent {} report session {:?} belongs to a compacted superseded generation", + next.source, + next.source_session, + ); + } + + if preserve_deferred_live_active { + return record_superseded_agent_session_generation(transaction, next); + } + + if let Some((active_provider, active_session, active_generation)) = active { + anyhow::ensure!(active_generation > 0, "active agent session generation is not positive"); + transaction.execute( + "UPDATE resource_agent_session_generations + SET superseded = 1 + WHERE terminal_id = ?1 AND provider = ?2 + AND source_session = ?3 AND superseded = 0", + params![next.terminal_id.as_str(), active_provider, active_session], + )?; + } + let generation = next_agent_session_generation(transaction, &next.terminal_id)?; + transaction.execute( + "INSERT INTO resource_agent_session_generations( + terminal_id, provider, source_session, generation, superseded, journal_identity + ) VALUES(?1, ?2, ?3, ?4, 0, ?5)", + params![next.terminal_id.as_str(), provider, source_session, generation, journal_identity,], + )?; + if !rebuilding_generation_history { + compact_agent_session_generations(transaction, Some(&next.terminal_id))?; + } + Ok(()) +} + +fn active_agent_session_started_in_deferred_live_tail( + transaction: &Transaction<'_>, + terminal_id: &TerminalPublicId, +) -> anyhow::Result { + let Some(live_sequence) = agent_projection_journal_live_sequence(transaction)? else { + return Ok(false); + }; + let live_sequence = i64::try_from(live_sequence) + .context("agent projection live sequence exceeds SQLite range")?; + transaction + .query_row( + "SELECT EXISTS( + SELECT 1 + FROM resource_agent_session_generations AS generation + WHERE generation.terminal_id = ?1 + AND generation.superseded = 0 + AND generation.journal_identity IS NOT NULL + AND EXISTS( + SELECT 1 FROM journal_subject_index AS live_subject + WHERE live_subject.kind = 'agent_session' + AND live_subject.id = generation.journal_identity + AND live_subject.sequence >= ?2 + ) + AND NOT EXISTS( + SELECT 1 FROM journal_subject_index AS historical_subject + WHERE historical_subject.kind = 'agent_session' + AND historical_subject.id = generation.journal_identity + AND historical_subject.sequence < ?2 + ) + )", + params![terminal_id.as_str(), live_sequence], + |row| row.get::<_, bool>(0), + ) + .map_err(Into::into) +} + +/// A missing provider is one explicit legacy namespace. It never aliases a +/// named provider, but missing-provider reports still fence each other. +fn agent_generation_provider(provider: Option<&str>) -> &str { + provider.unwrap_or(UNKNOWN_AGENT_PROVIDER_GENERATION_KEY) +} + +fn next_agent_session_generation( + transaction: &Transaction<'_>, + terminal_id: &TerminalPublicId, +) -> anyhow::Result { + let maximum = transaction.query_row( + "SELECT COALESCE(MAX(generation), 0) + FROM resource_agent_session_generations + WHERE terminal_id = ?1", + [terminal_id.as_str()], + |row| row.get::<_, i64>(0), + )?; + maximum.checked_add(1).context("agent session generation exhausted") +} + +fn agent_session_identity_precedes_record( + transaction: &Transaction<'_>, + journal_identity: &str, + committed_sequence: u64, +) -> anyhow::Result { + let committed_sequence = + i64::try_from(committed_sequence).context("agent journal sequence exceeds SQLite range")?; + transaction + .query_row( + "SELECT EXISTS( + SELECT 1 FROM journal_subject_index + WHERE kind = 'agent_session' AND id = ?1 AND sequence < ?2 + )", + params![journal_identity, committed_sequence], + |row| row.get::<_, bool>(0), + ) + .map_err(Into::into) +} + +fn ensure_agent_session_journal_identity( + transaction: &Transaction<'_>, + next: &AgentProjectionRow, + provider: &str, + source_session: &str, +) -> anyhow::Result { + let subject = crate::agent_hooks::agent_session_subject( + next.terminal_id.as_str(), + provider, + source_session, + ); + let sequence = i64::try_from(next.committed_sequence) + .context("agent journal sequence exceeds SQLite range")?; + transaction.execute( + "INSERT OR IGNORE INTO journal_subject_index(sequence, kind, id) + VALUES(?1, ?2, ?3)", + params![sequence, subject.kind.as_str(), subject.id.as_str()], + )?; + Ok(subject.id) +} + +fn compact_agent_session_generations( + transaction: &Transaction<'_>, + terminal_id: Option<&TerminalPublicId>, +) -> anyhow::Result<()> { + let retention = i64::try_from(AGENT_SESSION_GENERATION_RETENTION)?; + if let Some(terminal_id) = terminal_id { + transaction.execute( + "DELETE FROM resource_agent_session_generations + WHERE terminal_id = ?1 + AND superseded = 1 + AND journal_identity IS NOT NULL + AND generation <= COALESCE(( + SELECT generation + FROM resource_agent_session_generations + WHERE terminal_id = ?1 + AND superseded = 1 + AND journal_identity IS NOT NULL + ORDER BY generation DESC + LIMIT 1 OFFSET ?2 + ), 0)", + params![terminal_id.as_str(), retention], + )?; + } else { + transaction.execute( + "DELETE FROM resource_agent_session_generations + WHERE rowid IN ( + SELECT rowid FROM ( + SELECT rowid, + ROW_NUMBER() OVER ( + PARTITION BY terminal_id ORDER BY generation DESC + ) AS retained_rank + FROM resource_agent_session_generations + WHERE superseded = 1 AND journal_identity IS NOT NULL + ) + WHERE retained_rank > ?1 + )", + [retention], + )?; + } + Ok(()) +} + +fn record_superseded_agent_session_generation( + transaction: &Transaction<'_>, + next: &AgentProjectionRow, +) -> anyhow::Result<()> { + let Some(source_session) = next.source_session.as_deref() else { + return Ok(()); + }; + let provider = agent_generation_provider(next.provider.as_deref()); + let journal_identity = + ensure_agent_session_journal_identity(transaction, next, provider, source_session)?; + let exists = transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM resource_agent_session_generations + WHERE terminal_id = ?1 AND provider = ?2 AND source_session = ?3 + )", + params![next.terminal_id.as_str(), provider, source_session], + |row| row.get::<_, bool>(0), + )?; + if exists { + transaction.execute( + "UPDATE resource_agent_session_generations + SET journal_identity = COALESCE(journal_identity, ?4) + WHERE terminal_id = ?1 AND provider = ?2 AND source_session = ?3", + params![next.terminal_id.as_str(), provider, source_session, journal_identity], + )?; + return Ok(()); + } + let generation = next_agent_session_generation(transaction, &next.terminal_id)?; + transaction.execute( + "INSERT INTO resource_agent_session_generations( + terminal_id, provider, source_session, generation, superseded, journal_identity + ) VALUES(?1, ?2, ?3, ?4, 1, ?5)", + params![next.terminal_id.as_str(), provider, source_session, generation, journal_identity,], + )?; + Ok(()) +} + +pub(super) fn rebuild_agent_projections_from_journal( + connection: &Connection, + allow_archived_kind_backfill: bool, +) -> anyhow::Result<(bool, bool)> { + let tx = connection.unchecked_transaction()?; + if !resource_store::backfill_resource_agent_session_generations_page(&tx)? { + tx.commit()?; + return Ok((false, false)); + } + let mut sequence = agent_projection_journal_cursor(&tx)?; + if sequence.is_none() && prejournal_projection_migration_cursor(&tx)?.is_none() { + initialize_prejournal_projection_migration(&tx)?; + } + if prejournal_projection_migration_cursor(&tx)?.is_some() { + if !migrate_prejournal_projections_page(&tx)? { + tx.commit()?; + return Ok((false, false)); + } + store_agent_projection_journal_cursor(&tx, 0)?; + sequence = Some(0); + } + + let sequence = sequence.context("agent projection journal cursor was not initialized")?; + let head_sequence = session_journal::session_journal_head(&tx)?; + let candidate = match agent_projection_journal_candidate(&tx)? { + Some(candidate) => candidate, + None => { + note_agent_projection_journal_candidate(&tx, head_sequence)?; + head_sequence + } + }; + anyhow::ensure!( + sequence <= head_sequence, + "agent projection journal cursor {sequence} is ahead of journal head {head_sequence}" + ); + anyhow::ensure!( + candidate <= head_sequence, + "agent projection journal candidate {candidate} is ahead of journal head {head_sequence}" + ); + let (checkpoint_ready, refresh_required) = if candidate <= sequence { + store_agent_projection_journal_cursor(&tx, head_sequence)?; + clear_agent_projection_journal_rebuild_target(&tx)?; + if agent_projection_rebuild_changes_pending(&tx)? { + (true, true) + } else { + clear_agent_projection_rebuild_changes(&tx)?; + (true, false) + } + } else { + let target = match agent_projection_journal_rebuild_target(&tx)? { + Some(target) => target, + None => { + clear_agent_projection_rebuild_changes(&tx)?; + store_agent_projection_journal_rebuild_target(&tx, candidate)?; + candidate + } + }; + anyhow::ensure!( + sequence < target && target <= head_sequence, + "agent projection journal rebuild range {sequence}..={target} is invalid for head {head_sequence}" + ); + replay_agent_projection_journal_page(&tx, sequence, target, allow_archived_kind_backfill)? + }; + if !agent_projection_rebuild_active(&tx)? { + compact_agent_session_generations(&tx, None)?; + clear_agent_projection_journal_live_sequence(&tx)?; + } + tx.commit()?; + Ok((checkpoint_ready, refresh_required)) +} + +impl WorkspaceRegistry { + pub(crate) fn agent_projection_rebuild_pending(&self) -> anyhow::Result { + agent_projection_rebuild_active(&self.connection) + } + + #[cfg(test)] + pub(crate) fn continue_agent_projection_rebuild(&self) -> anyhow::Result { + let step = self.continue_agent_projection_rebuild_page()?; + if step.checkpoint_ready && step.refresh_required { + self.clear_agent_projection_rebuild_changes()?; + } + Ok(!self.agent_projection_rebuild_pending()?) + } + + pub(crate) fn continue_agent_projection_rebuild_page( + &self, + ) -> anyhow::Result { + let (checkpoint_ready, refresh_required) = + rebuild_agent_projections_from_journal(&self.connection, true)?; + Ok(AgentProjectionRebuildStep { + checkpoint_ready, + pending: self.agent_projection_rebuild_pending()?, + refresh_required, + }) + } + + #[cfg(test)] + pub(crate) fn visit_agent_projection_rebuild_changes( + &self, + mut visit: F, + ) -> anyhow::Result<()> + where + F: FnMut(RegistryAgentProjection) -> anyhow::Result<()>, + { + for projection in self.agent_projection_rebuild_change_page(None)?.projections { + visit(projection)?; + } + Ok(()) + } + + pub(crate) fn agent_projection_rebuild_change_page( + &self, + after_terminal_id: Option<&TerminalPublicId>, + ) -> anyhow::Result { + let mut statement = self.connection.prepare( + "SELECT projection.terminal_id, + projection.result_json, + projection.committed_revision + FROM resource_agent_projection_rebuild_changes AS changed + JOIN resource_agent_projections AS projection + ON projection.terminal_id = changed.terminal_id + JOIN resource_terminals AS terminal + ON terminal.public_id = projection.terminal_id + WHERE terminal.deleted_revision IS NULL + AND (?1 IS NULL OR projection.terminal_id > ?1) + ORDER BY projection.terminal_id ASC + LIMIT ?2", + )?; + let mut rows = statement.query(params![ + after_terminal_id.map(TerminalPublicId::as_str), + i64::try_from(AGENT_PROJECTION_JOURNAL_REBUILD_PAGE_SIZE) + .context("agent projection refresh page exceeds SQLite")?, + ])?; + let mut projections = Vec::with_capacity(AGENT_PROJECTION_JOURNAL_REBUILD_PAGE_SIZE); + while let Some(row) = rows.next()? { + let terminal_id = TerminalPublicId::parse(row.get::<_, String>(0)?)?; + let result_json = row.get::<_, String>(1)?; + let committed_revision = row.get::<_, i64>(2)?; + projections.push(public_projection_store::decode_agent_projection( + &result_json, + &terminal_id, + &self.session_id, + committed_revision, + )?); + } + let complete = projections.len() < AGENT_PROJECTION_JOURNAL_REBUILD_PAGE_SIZE; + let last_terminal_id = projections.last().map(|projection| projection.terminal_id.clone()); + Ok(AgentProjectionRebuildChangePage { projections, last_terminal_id, complete }) + } + + pub(crate) fn agent_projection_for_cache_refresh( + &self, + terminal_id: &TerminalPublicId, + ) -> anyhow::Result> { + let stored = self + .connection + .query_row( + "SELECT result_json, committed_revision + FROM resource_agent_projections + WHERE terminal_id = ?1", + [terminal_id.as_str()], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)), + ) + .optional()?; + stored + .map(|(result_json, committed_revision)| { + public_projection_store::decode_agent_projection( + &result_json, + terminal_id, + &self.session_id, + committed_revision, + ) + }) + .transpose() + } + + pub(crate) fn clear_agent_projection_rebuild_changes(&self) -> anyhow::Result<()> { + clear_agent_projection_rebuild_changes(&self.connection) + } + + #[cfg(test)] + pub(crate) fn hold_agent_projection_rebuild_for_test(&mut self) -> anyhow::Result<()> { + let transaction = self.connection.transaction()?; + let head = session_journal::session_journal_head(&transaction)?; + store_agent_projection_journal_rebuild_target(&transaction, head)?; + transaction.commit()?; + Ok(()) + } + + #[cfg(test)] + pub(crate) fn seed_agent_projection_checkpoint_for_test(&self) -> anyhow::Result<()> { + const EVENT_COUNT: usize = AGENT_PROJECTION_JOURNAL_REBUILD_PAGE_SIZE + 1; + + let producer = JournalProducer { kind: "test".into(), id: "checkpoint-test".into() }; + let payload = json!({}); + let transaction = self.connection.unchecked_transaction()?; + for index in 0..EVENT_COUNT { + let event_id = format!("event_agent_checkpoint_{index:04}"); + session_journal::append_journal_record( + &transaction, + &session_journal::JournalAppend { + event_id: &event_id, + schema_version: 1, + kind: "agent.unknown", + class: JournalClass::Observation, + replay: JournalReplayPolicy::Advisory, + occurred_at_ms: index as u64, + producer: &producer, + authority: None, + causation_id: None, + correlation_id: None, + causation_depth: 0, + subjects: &[], + sensitivity: JournalSensitivity::Metadata, + payload: &payload, + content: None, + resource_revision: None, + previous_resource_revision: None, + }, + )?; + } + transaction.execute( + "DELETE FROM meta + WHERE key IN ( + 'agent_projection_journal_sequence_v1', + 'agent_projection_journal_candidate_sequence_v1', + 'agent_projection_journal_rebuild_target_sequence_v1' + )", + [], + )?; + transaction.commit()?; + + let _ = rebuild_agent_projections_from_journal(&self.connection, true)?; + let target = agent_projection_journal_rebuild_target(&self.connection)? + .context("checkpoint test rebuild target is absent")?; + + let transaction = self.connection.unchecked_transaction()?; + session_journal::append_journal_record( + &transaction, + &session_journal::JournalAppend { + event_id: "event_checkpoint_later_candidate", + schema_version: 1, + kind: "agent.unknown", + class: JournalClass::Observation, + replay: JournalReplayPolicy::Advisory, + occurred_at_ms: EVENT_COUNT as u64, + producer: &producer, + authority: None, + causation_id: None, + correlation_id: None, + causation_depth: 0, + subjects: &[], + sensitivity: JournalSensitivity::Metadata, + payload: &payload, + content: None, + resource_revision: None, + previous_resource_revision: None, + }, + )?; + transaction.commit()?; + + let cursor = agent_projection_journal_cursor(&self.connection)? + .context("checkpoint test rebuild cursor is absent")?; + let candidate = agent_projection_journal_candidate(&self.connection)? + .context("checkpoint test rebuild candidate is absent")?; + anyhow::ensure!(cursor < target, "checkpoint test target already completed"); + anyhow::ensure!(target < candidate, "checkpoint test has no later candidate"); + Ok(()) + } +} + +fn agent_projection_rebuild_active(connection: &Connection) -> anyhow::Result { + Ok(resource_store::resource_agent_generation_backfill_pending(connection)? + || prejournal_projection_migration_cursor(connection)?.is_some() + || agent_projection_journal_rebuild_target(connection)?.is_some() + || agent_projection_rebuild_changes_pending(connection)?) +} + +fn agent_projection_rebuild_changes_pending(connection: &Connection) -> anyhow::Result { + let pending = connection.query_row( + "SELECT EXISTS(SELECT 1 FROM resource_agent_projection_rebuild_changes LIMIT 1)", + [], + |row| row.get::<_, bool>(0), + )?; + Ok(pending) +} + +fn record_agent_projection_rebuild_change( + transaction: &Transaction<'_>, + terminal_id: &TerminalPublicId, +) -> anyhow::Result<()> { + transaction.execute( + "INSERT OR IGNORE INTO resource_agent_projection_rebuild_changes( + terminal_id, previous_result_json, previous_committed_revision + ) VALUES( + ?1, + (SELECT result_json FROM resource_agent_projections WHERE terminal_id = ?1), + (SELECT committed_revision FROM resource_agent_projections WHERE terminal_id = ?1) + )", + [terminal_id.as_str()], + )?; + Ok(()) +} + +fn replay_agent_projection_journal_page( + transaction: &Transaction<'_>, + sequence: u64, + target_sequence: u64, + allow_archived_kind_backfill: bool, +) -> anyhow::Result<(bool, bool)> { + if !session_journal::backfill_journal_event_index_kinds_page( + transaction, + AGENT_PROJECTION_JOURNAL_REBUILD_PAGE_SIZE, + allow_archived_kind_backfill, + )? { + return Ok((false, false)); + } + let projection_sequences = { + let mut statement = transaction.prepare( + "SELECT sequence + FROM journal_agent_event_index + WHERE sequence > ?1 AND sequence <= ?2 + ORDER BY sequence ASC + LIMIT ?3", + )?; + let rows = statement.query_map( + params![ + i64::try_from(sequence).context("agent rebuild cursor exceeds SQLite range")?, + i64::try_from(target_sequence) + .context("agent rebuild target exceeds SQLite range")?, + i64::try_from(AGENT_PROJECTION_JOURNAL_REBUILD_PAGE_SIZE) + .context("agent rebuild page size exceeds SQLite range")?, + ], + |row| row.get::<_, i64>(0), + )?; + rows.map(|row| u64::try_from(row?).context("agent rebuild sequence is negative")) + .collect::>>()? + }; + let last_sequence = projection_sequences.last().copied(); + let page_is_full = projection_sequences.len() == AGENT_PROJECTION_JOURNAL_REBUILD_PAGE_SIZE; + let live_sequence = agent_projection_journal_live_sequence(transaction)?; + for record in + session_journal::query_session_journal_sequences(transaction, &projection_sequences)? + { + if !record.kind.starts_with("agent.") { + continue; + } + let changed_terminal = apply_agent_projection_journal_record( + transaction, + AgentProjectionJournalInput { + sequence: record.sequence, + kind: &record.kind, + occurred_at_ms: record.occurred_at_ms, + producer: &record.producer, + subjects: &record.subjects, + payload: &record.payload, + resource_revision: record.resource_revision, + rebuilding_generation_history: live_sequence + .is_none_or(|live_sequence| record.sequence < live_sequence), + replaying_projection_journal: true, + }, + )?; + if let Some(terminal_id) = changed_terminal { + transaction.execute( + "INSERT OR IGNORE INTO resource_agent_projection_rebuild_changes(terminal_id) + VALUES(?1)", + [terminal_id.as_str()], + )?; + } + } + let (checkpoint_ready, refresh_required) = match last_sequence { + Some(last_sequence) if page_is_full && last_sequence < target_sequence => { + store_agent_projection_journal_cursor(transaction, last_sequence)?; + (false, false) + } + _ => { + store_agent_projection_journal_cursor(transaction, target_sequence)?; + // A live append can extend the candidate while this target is in + // progress. Keep rebuild ownership until that newer prefix replays. + if let Some(candidate) = agent_projection_journal_candidate(transaction)? + && candidate > target_sequence + { + store_agent_projection_journal_rebuild_target(transaction, candidate)?; + } else { + clear_agent_projection_journal_rebuild_target(transaction)?; + } + (true, true) + } + }; + Ok((checkpoint_ready, refresh_required)) +} + +fn agent_projection_journal_cursor(connection: &Connection) -> anyhow::Result> { + connection + .query_row( + "SELECT value FROM meta WHERE key = ?1", + [AGENT_PROJECTION_JOURNAL_CURSOR_KEY], + |row| row.get::<_, String>(0), + ) + .optional()? + .map(|value| value.parse::().context("agent projection journal cursor is invalid")) + .transpose() +} + +fn agent_projection_journal_candidate(connection: &Connection) -> anyhow::Result> { + connection + .query_row( + "SELECT value FROM meta WHERE key = ?1", + [AGENT_PROJECTION_JOURNAL_CANDIDATE_KEY], + |row| row.get::<_, String>(0), + ) + .optional()? + .map(|value| { + value.parse::().context("agent projection journal candidate sequence is invalid") + }) + .transpose() +} + +fn agent_projection_journal_rebuild_target(connection: &Connection) -> anyhow::Result> { + connection + .query_row( + "SELECT value FROM meta WHERE key = ?1", + [AGENT_PROJECTION_JOURNAL_REBUILD_TARGET_KEY], + |row| row.get::<_, String>(0), + ) + .optional()? + .map(|value| { + value.parse::().context("agent projection journal rebuild target is invalid") + }) + .transpose() +} + +fn agent_projection_journal_live_sequence(connection: &Connection) -> anyhow::Result> { + connection + .query_row( + "SELECT value FROM meta WHERE key = ?1", + [AGENT_PROJECTION_JOURNAL_LIVE_SEQUENCE_KEY], + |row| row.get::<_, String>(0), + ) + .optional()? + .map(|value| value.parse::().context("agent projection live sequence is invalid")) + .transpose() +} + +fn note_agent_projection_journal_live_sequence( + transaction: &Transaction<'_>, + sequence: u64, +) -> anyhow::Result<()> { + if let Some(existing) = agent_projection_journal_live_sequence(transaction)? { + anyhow::ensure!( + sequence >= existing, + "agent projection live sequence cannot move backwards from {existing} to {sequence}" + ); + return Ok(()); + } + transaction.execute( + "INSERT INTO meta(key, value) VALUES(?1, ?2)", + params![AGENT_PROJECTION_JOURNAL_LIVE_SEQUENCE_KEY, sequence.to_string()], + )?; + Ok(()) +} + +fn clear_agent_projection_journal_live_sequence( + transaction: &Transaction<'_>, +) -> anyhow::Result<()> { + transaction + .execute("DELETE FROM meta WHERE key = ?1", [AGENT_PROJECTION_JOURNAL_LIVE_SEQUENCE_KEY])?; + Ok(()) +} + +fn store_agent_projection_journal_cursor( + transaction: &Transaction<'_>, + sequence: u64, +) -> anyhow::Result<()> { + transaction.execute( + "INSERT INTO meta(key, value) VALUES(?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![AGENT_PROJECTION_JOURNAL_CURSOR_KEY, sequence.to_string()], + )?; + Ok(()) +} + +fn store_agent_projection_journal_rebuild_target( + transaction: &Transaction<'_>, + sequence: u64, +) -> anyhow::Result<()> { + transaction.execute( + "INSERT INTO meta(key, value) VALUES(?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![AGENT_PROJECTION_JOURNAL_REBUILD_TARGET_KEY, sequence.to_string()], + )?; + Ok(()) +} + +fn clear_agent_projection_journal_rebuild_target( + transaction: &Transaction<'_>, +) -> anyhow::Result<()> { + transaction.execute( + "DELETE FROM meta WHERE key = ?1", + [AGENT_PROJECTION_JOURNAL_REBUILD_TARGET_KEY], + )?; + Ok(()) +} + +fn clear_agent_projection_rebuild_changes(connection: &Connection) -> anyhow::Result<()> { + connection.execute("DELETE FROM resource_agent_projection_rebuild_changes", [])?; + Ok(()) +} + +fn initialize_prejournal_projection_migration(transaction: &Transaction<'_>) -> anyhow::Result<()> { + transaction.execute( + "INSERT INTO meta(key, value) VALUES(?1, '') + ON CONFLICT(key) DO NOTHING", + [AGENT_PROJECTION_PREJOURNAL_MIGRATION_CURSOR_KEY], + )?; + Ok(()) +} + +fn prejournal_projection_migration_cursor( + connection: &Connection, +) -> anyhow::Result> { + let cursor = connection + .query_row( + "SELECT value FROM meta WHERE key = ?1", + [AGENT_PROJECTION_PREJOURNAL_MIGRATION_CURSOR_KEY], + |row| row.get::<_, String>(0), + ) + .optional()?; + cursor + .map(|cursor| { + if !cursor.is_empty() { + TerminalPublicId::parse(cursor.clone()) + .context("pre-journal projection migration cursor is invalid")?; + } + Ok(cursor) + }) + .transpose() +} + +fn migrate_prejournal_projections_page(transaction: &Transaction<'_>) -> anyhow::Result { + let Some(after_terminal_id) = prejournal_projection_migration_cursor(transaction)? else { + return Ok(true); + }; + let original_head_sequence = session_journal::session_journal_head(transaction)?; + let mut stored = stored_live_projections_after( + transaction, + &after_terminal_id, + AGENT_PROJECTION_PREJOURNAL_MIGRATION_PAGE_SIZE.saturating_add(1), + )?; + let has_more = stored.len() > AGENT_PROJECTION_PREJOURNAL_MIGRATION_PAGE_SIZE; + if has_more { + stored.truncate(AGENT_PROJECTION_PREJOURNAL_MIGRATION_PAGE_SIZE); + } + for projection in &mut stored { + projection.committed_sequence = match stored_projection_journal_sequence( + transaction, + projection, + original_head_sequence, + )? { + Some(sequence) => sequence, + None => append_prejournal_projection_migration(transaction, projection)?, + }; + upsert_projection(transaction, projection)?; + } + if has_more { + let terminal_id = stored + .last() + .context("pre-journal projection migration page was unexpectedly empty")? + .terminal_id + .as_str(); + transaction.execute( + "UPDATE meta SET value = ?1 WHERE key = ?2", + params![terminal_id, AGENT_PROJECTION_PREJOURNAL_MIGRATION_CURSOR_KEY], + )?; + return Ok(false); + } + transaction.execute( + "DELETE FROM meta WHERE key = ?1", + [AGENT_PROJECTION_PREJOURNAL_MIGRATION_CURSOR_KEY], + )?; + Ok(true) +} + +pub(super) fn advance_agent_projection_journal_cursor( + transaction: &Transaction<'_>, + sequence: u64, +) -> anyhow::Result<()> { + let Some(applied_sequence) = agent_projection_journal_cursor(transaction)? else { + return Ok(()); + }; + if agent_projection_journal_rebuild_target(transaction)? + .is_some_and(|target| applied_sequence < target && sequence > target) + { + return Ok(()); + } + anyhow::ensure!( + sequence >= applied_sequence, + "agent projection journal cursor cannot move backwards from {applied_sequence} to {sequence}" + ); + store_agent_projection_journal_cursor(transaction, sequence) +} + +pub(super) fn note_agent_projection_journal_candidate( + transaction: &Transaction<'_>, + sequence: u64, +) -> anyhow::Result<()> { + let current = agent_projection_journal_candidate(transaction)?.unwrap_or(0); + anyhow::ensure!( + sequence >= current, + "agent projection journal candidate sequence cannot move backwards from {current} to {sequence}" + ); + transaction.execute( + "INSERT INTO meta(key, value) VALUES(?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![AGENT_PROJECTION_JOURNAL_CANDIDATE_KEY, sequence.to_string()], + )?; + Ok(()) +} + +fn append_prejournal_projection_migration( + transaction: &Transaction<'_>, + projection: &AgentProjectionRow, +) -> anyhow::Result { + let session_id = transaction.query_row( + "SELECT value FROM meta WHERE key = 'session_public_id'", + [], + |row| row.get::<_, String>(0), + )?; + let digest = Sha256::digest( + format!("{PREJOURNAL_MIGRATION_FORMAT}/{session_id}/{}", projection.terminal_id).as_bytes(), + ); + let event_id = format!("event_agent_projection_migration_{}", encode_lower_hex(&digest)); + let producer = + JournalProducer { kind: "migration".into(), id: PREJOURNAL_MIGRATION_PRODUCER_ID.into() }; + let mut subjects = vec![ + JournalSubject { kind: "session".into(), id: session_id }, + JournalSubject { kind: "terminal".into(), id: projection.terminal_id.to_string() }, + ]; + if let Some(source_session) = projection.source_session.as_deref() { + subjects.push(crate::agent_hooks::agent_session_subject( + projection.terminal_id.as_str(), + agent_generation_provider(projection.provider.as_deref()), + source_session, + )); + } + let result = projection + .result + .clone() + .context("pre-journal agent projection omitted its exact public result")?; + let payload = json!({ + "format":PREJOURNAL_MIGRATION_FORMAT, + "result":result, + }); + session_journal::append_journal_record( + transaction, + &session_journal::JournalAppend { + event_id: &event_id, + schema_version: 1, + kind: "agent.report", + class: JournalClass::State, + replay: JournalReplayPolicy::Required, + occurred_at_ms: projection.updated_at_ms, + producer: &producer, + authority: None, + causation_id: None, + correlation_id: Some(&event_id), + causation_depth: 0, + subjects: &subjects, + sensitivity: JournalSensitivity::Sensitive, + payload: &payload, + content: None, + resource_revision: None, + previous_resource_revision: None, + }, + ) +} + +fn stored_projection_journal_sequence( + connection: &Connection, + stored: &AgentProjectionRow, + head_sequence: u64, +) -> anyhow::Result> { + let resource_revision = i64::try_from(stored.committed_sequence) + .context("stored agent projection revision exceeds SQLite range")?; + let resource_sequence = connection + .query_row( + "SELECT sequence FROM journal_event_index WHERE resource_revision = ?1", + [resource_revision], + |row| row.get::<_, i64>(0), + ) + .optional()? + .map(u64::try_from) + .transpose() + .context("stored agent projection journal sequence is negative")?; + let mut candidates = resource_sequence.into_iter().collect::>(); + if stored.committed_sequence <= head_sequence + && !candidates.contains(&stored.committed_sequence) + { + candidates.push(stored.committed_sequence); + } + for sequence in candidates { + let record = session_journal::query_session_journal_sequences(connection, &[sequence])? + .pop() + .context("stored agent projection journal record disappeared")?; + let Some(projected) = projection_from_journal_record( + record.sequence, + &record.kind, + record.occurred_at_ms, + &record.producer, + &record.subjects, + &record.payload, + record.resource_revision, + )? + else { + continue; + }; + if projected.terminal_id == stored.terminal_id && projected.result == stored.result { + return Ok(Some(sequence)); + } + } + Ok(None) +} + +fn projection_from_journal_record( + sequence: u64, + kind: &str, + occurred_at_ms: u64, + producer: &JournalProducer, + subjects: &[JournalSubject], + payload: &Value, + resource_revision: Option, +) -> anyhow::Result> { + if kind == "agent.report" { + let trusted_resource_operation = + producer.kind == "resource_operation" && resource_revision.is_some(); + let trusted_migration = producer.kind == "migration" + && producer.id == PREJOURNAL_MIGRATION_PRODUCER_ID + && resource_revision.is_none() + && payload.get("format").and_then(Value::as_str) == Some(PREJOURNAL_MIGRATION_FORMAT); + if !trusted_resource_operation && !trusted_migration { + return Ok(None); + } + return projection_from_resource_report(sequence, payload); + } + if kind == "agent.session.interrupted" + && producer.kind == "recovery_policy" + && producer.id == RECOVERY_PRODUCER_ID + && resource_revision.is_none() + { + return projection_from_recovery_event(sequence, occurred_at_ms, subjects, payload); + } + if producer.kind != "agent_adapter" || producer.id != crate::AGENT_HOOK_PRODUCER_ID { + return Ok(None); + } + if hook_projection_is_nested_agent(payload) { + return Ok(None); + } + let Some(state) = hook_projection_state(kind, payload) else { + return Ok(None); + }; + let Some(terminal_id) = terminal_subject(subjects)? else { + return Ok(None); + }; + let normalized = payload.get("normalized").and_then(Value::as_object); + let source_session = normalized + .and_then(|fields| fields.get("agent_session_id")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let provider = payload + .get("adapter") + .and_then(|adapter| adapter.get("id")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let turn_id = normalized + .and_then(|fields| fields.get("turn_id")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + Ok(Some(AgentProjectionRow { + terminal_id, + state: state.into(), + source: "hook".into(), + updated_at_ms: occurred_at_ms, + source_session, + provider, + turn_id, + committed_sequence: sequence, + result: None, + begins_session: kind == "agent.session.started", + begins_turn: kind == "agent.turn.started", + })) +} + +fn projection_from_resource_report( + sequence: u64, + payload: &Value, +) -> anyhow::Result> { + let Some(result) = payload.get("result").and_then(Value::as_object) else { + return Ok(None); + }; + let Some(terminal_id) = result.get("terminal_id").and_then(Value::as_str) else { + return Ok(None); + }; + let terminal_id = TerminalPublicId::parse(terminal_id)?; + let state = result + .get("state") + .and_then(Value::as_str) + .filter(|state| { + matches!(*state, "working" | "blocked" | "idle" | "done" | "interrupted" | "unknown") + }) + .unwrap_or("unknown") + .to_string(); + let source = result + .get("source") + .and_then(Value::as_str) + .filter(|source| matches!(*source, "hook" | "socket" | "detected")) + .unwrap_or("detected") + .to_string(); + let begins_session = false; + let updated_at_ms = result + .get("updated_at_ms") + .and_then(|value| value.as_str().and_then(|value| value.parse::().ok())) + .or_else(|| result.get("updated_at_ms").and_then(Value::as_u64)) + .unwrap_or(0); + let source_session = result + .get("source_session") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let extra = result.get("extra"); + let provider = extra + .and_then(|extra| extra.get("provider")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let turn_id = extra + .and_then(|extra| extra.get("turn_id")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + Ok(Some(AgentProjectionRow { + terminal_id, + state, + source, + updated_at_ms, + source_session, + provider, + turn_id, + committed_sequence: sequence, + result: Some(Value::Object(result.clone())), + begins_session, + begins_turn: false, + })) +} + +fn projection_from_recovery_event( + sequence: u64, + occurred_at_ms: u64, + subjects: &[JournalSubject], + payload: &Value, +) -> anyhow::Result> { + if payload.get("format").and_then(Value::as_str) != Some(RECOVERY_FORMAT) { + return Ok(None); + } + if payload.get("outcome").and_then(Value::as_str) != Some("classified_interrupted") { + return Ok(None); + } + let Some(terminal_id) = terminal_subject(subjects)? else { + return Ok(None); + }; + let source_session = payload + .get("source_session") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let provider = payload + .get("provider") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + Ok(Some(AgentProjectionRow { + terminal_id, + state: "interrupted".into(), + source: "hook".into(), + updated_at_ms: occurred_at_ms, + source_session, + provider, + turn_id: None, + committed_sequence: sequence, + result: None, + begins_session: false, + begins_turn: false, + })) +} + +fn hook_projection_state(kind: &str, payload: &Value) -> Option<&'static str> { + let native_event = + payload.get("native_event").and_then(Value::as_str).map(lifecycle_key).unwrap_or_default(); + match native_event.as_str() { + "sessionshutdown" | "agentsettled" | "agentend" => return Some("done"), + "sessionstart" => return Some("idle"), + "beforeagentstart" + | "agentstart" + | "turnstart" + | "pretooluse" + | "beforetool" + | "beforeshellexecution" + | "beforemcpexecution" + | "beforereadfile" + | "posttooluse" + | "aftertool" + | "aftershellexecution" + | "aftermcpexecution" + | "afterfileedit" => return Some("working"), + _ => {} + } + match kind { + "agent.session.started" => Some("idle"), + "agent.turn.started" => Some("working"), + "agent.approval.requested" + | "agent.question.requested" + | "agent.plan_review.requested" + | "agent.error.reported" => Some("blocked"), + "agent.turn.completed" => Some("idle"), + "agent.session.ended" => Some("done"), + _ => None, + } +} + +fn hook_projection_is_nested_agent(payload: &Value) -> bool { + let Some(normalized) = payload.get("normalized").and_then(Value::as_object) else { + return false; + }; + crate::agent_hooks::normalized_agent_is_nested(normalized) +} + +fn lifecycle_key(value: &str) -> String { + value.chars().filter(|ch| ch.is_ascii_alphanumeric()).flat_map(char::to_lowercase).collect() +} + +fn terminal_subject(subjects: &[JournalSubject]) -> anyhow::Result> { + subjects + .iter() + .find(|subject| subject.kind == "terminal") + .map(|subject| TerminalPublicId::parse(subject.id.clone())) + .transpose() + .map_err(Into::into) +} + +fn merge_projection( + current: Option, + next: AgentProjectionRow, +) -> AgentProjectionRow { + let Some(current) = current else { + return next; + }; + if next.committed_sequence < current.committed_sequence { + return current; + } + if next.begins_session { + if current.source_session.is_some() && next.source_session.is_none() { + return current; + } + return next; + } + let begins_new_structured_session_with_turn = next.begins_turn + && next.source_session.is_some() + && (current.source_session != next.source_session || current.provider != next.provider); + if begins_new_structured_session_with_turn { + return next; + } + let different_structured_socket_session = current.source == "socket" + && next.source == "socket" + && current.source_session.is_some() + && next.source_session.is_some() + && current.source_session != next.source_session; + if different_structured_socket_session { + let current_is_final = matches!(current.state.as_str(), "done" | "interrupted"); + let next_is_active = matches!(next.state.as_str(), "working" | "blocked" | "idle"); + return if current_is_final && next_is_active { next } else { current }; + } + let current_is_active = matches!(current.state.as_str(), "working" | "blocked" | "idle"); + if current_is_active && current.source != "hook" && next.source == "hook" { + return next; + } + let same_structured_session = current.source_session.is_some() + && current.source_session == next.source_session + && current.provider == next.provider; + let same_structured_turn = current.source_session.is_none() + && next.source_session.is_none() + && current.turn_id.is_some() + && current.turn_id == next.turn_id + && current.provider == next.provider; + if same_structured_session || same_structured_turn { + let different_structured_turn = current.source_session.is_some() + && current.turn_id.is_some() + && next.turn_id.is_some() + && current.turn_id != next.turn_id; + if different_structured_turn && !next.begins_turn { + return current; + } + let begins_new_structured_turn = + current.source_session.is_some() && next.begins_turn && different_structured_turn; + if matches!(current.state.as_str(), "done" | "interrupted") && begins_new_structured_turn { + return next; + } + if current.source == "hook" && next.source == "socket" { + if matches!(next.state.as_str(), "done" | "interrupted") { + return if next.updated_at_ms >= current.updated_at_ms { next } else { current }; + } + return current; + } + if matches!(current.state.as_str(), "done" | "interrupted") { + return current; + } + return next; + } + let current_is_final = matches!(current.state.as_str(), "done" | "interrupted"); + let next_is_active = matches!(next.state.as_str(), "working" | "blocked" | "idle"); + if current_is_final && next_is_active { + return if next.source_session.is_some() { next } else { current }; + } + current +} + +fn stored_projection( + transaction: &Transaction<'_>, + terminal_id: &TerminalPublicId, +) -> anyhow::Result> { + let stored = transaction + .query_row( + "SELECT result_json, committed_revision + FROM resource_agent_projections + WHERE terminal_id = ?1", + [terminal_id.as_str()], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)), + ) + .optional()?; + let Some((result_json, committed_sequence)) = stored else { + return Ok(None); + }; + let result: Value = serde_json::from_str(&result_json)?; + let session_id = SessionPublicId::parse(transaction.query_row( + "SELECT value FROM meta WHERE key = 'session_public_id'", + [], + |row| row.get::<_, String>(0), + )?)?; + public_projection_store::decode_agent_projection( + &result_json, + terminal_id, + &session_id, + committed_sequence, + )?; + let committed_sequence = + u64::try_from(committed_sequence).context("agent projection revision is negative")?; + projection_from_resource_report(committed_sequence, &json!({"result":result})) +} + +fn stored_live_projections_after( + transaction: &Transaction<'_>, + after_terminal_id: &str, + limit: usize, +) -> anyhow::Result> { + let terminal_ids = { + let mut statement = transaction.prepare( + "SELECT projection.terminal_id + FROM resource_agent_projections projection + JOIN resource_terminals terminal + ON terminal.public_id = projection.terminal_id + WHERE terminal.deleted_revision IS NULL + AND projection.terminal_id > ?1 + ORDER BY projection.terminal_id ASC + LIMIT ?2", + )?; + statement + .query_map( + params![ + after_terminal_id, + i64::try_from(limit) + .context("agent projection migration limit exceeds SQLite")?, + ], + |row| row.get::<_, String>(0), + )? + .collect::, _>>()? + }; + terminal_ids + .into_iter() + .map(|terminal_id| { + let terminal_id = TerminalPublicId::parse(terminal_id)?; + stored_projection(transaction, &terminal_id)?.with_context(|| { + format!("agent projection for live terminal {terminal_id} disappeared") + }) + }) + .collect() +} + +fn upsert_projection( + transaction: &Transaction<'_>, + projection: &AgentProjectionRow, +) -> anyhow::Result<()> { + let value = match &projection.result { + Some(result) => result.clone(), + None => projection_result_value(transaction, projection)?, + }; + transaction.execute( + "INSERT INTO resource_agent_projections( + terminal_id, result_json, committed_revision + ) VALUES(?1, ?2, ?3) + ON CONFLICT(terminal_id) DO UPDATE SET + result_json = excluded.result_json, + committed_revision = excluded.committed_revision", + params![ + projection.terminal_id.as_str(), + canonical_json(&value)?, + i64::try_from(projection.committed_sequence) + .context("agent projection sequence exceeds SQLite range")?, + ], + )?; + Ok(()) +} + +fn projection_result_value( + transaction: &Transaction<'_>, + projection: &AgentProjectionRow, +) -> anyhow::Result { + let session_id = transaction.query_row( + "SELECT value FROM meta WHERE key = 'session_public_id'", + [], + |row| row.get::<_, String>(0), + )?; + let agent_id = agent_id(&projection.terminal_id)?; + let mut extra = json!({"provider":projection.provider}); + if let Some(turn_id) = &projection.turn_id { + extra["turn_id"] = json!(turn_id); + } + Ok(json!({ + "id":agent_id, + "session_id":session_id, + "terminal_id":projection.terminal_id, + "state":projection.state, + "source":projection.source, + "updated_at_ms":projection.updated_at_ms.to_string(), + "source_session":projection.source_session, + "extra":extra, + })) +} + +fn terminal_is_live( + transaction: &Transaction<'_>, + terminal_id: &TerminalPublicId, +) -> anyhow::Result { + Ok(transaction + .query_row( + "SELECT 1 FROM resource_terminals + WHERE public_id = ?1 AND deleted_revision IS NULL", + [terminal_id.as_str()], + |_| Ok(()), + ) + .optional()? + .is_some()) +} + +fn agent_id(terminal_id: &TerminalPublicId) -> anyhow::Result { + let digest = Sha256::digest(format!("cmux.protocol/2/agent/{terminal_id}").as_bytes()); + let payload = encode_lower_hex(&digest[..16]); + AgentPublicId::parse(format!("agent_{payload}")).map_err(Into::into) +} + +fn encode_lower_hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for &byte in bytes { + encoded.push(char::from(DIGITS[usize::from(byte >> 4)])); + encoded.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + encoded +} + diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/journal_extensions.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/journal_extensions.rs index 975083c2fb7..978e322f1ee 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/journal_extensions.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/journal_extensions.rs @@ -256,6 +256,13 @@ pub(crate) struct JournalCheckpointCommit { pub journal: JournalAppendCommit, } +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct JournalRestoreCommit { + pub checkpoint_id: Option, + pub state_sha256: Option, + pub journal: JournalAppendCommit, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct JournalSegment { diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/public_projection_store.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/public_projection_store.rs index e910b9253d3..eeb65d51c9e 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/public_projection_store.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/public_projection_store.rs @@ -121,6 +121,7 @@ enum StoredAgentState { Blocked, Idle, Done, + Interrupted, Unknown, } @@ -131,6 +132,7 @@ impl StoredAgentState { Self::Blocked => "blocked", Self::Idle => "idle", Self::Done => "done", + Self::Interrupted => "interrupted", Self::Unknown => "unknown", } } @@ -193,6 +195,22 @@ impl WorkspaceRegistry { }) } + pub(crate) fn public_projections_for_cache_restore( + &self, + ) -> anyhow::Result { + let live_terminals = self.live_terminal_public_ids()?; + let notifications = self.durable_notifications(&live_terminals)?; + let agents = self.stable_durable_agents()?; + let terminal_defaults = self.durable_terminal_defaults()?; + let frontend_projections = self.public_frontend_projections()?; + Ok(RegistryPublicProjections { + notifications, + agents, + terminal_defaults, + frontend_projections, + }) + } + pub(crate) fn public_agent_projections( &self, terminal: Option<&TerminalPublicId>, @@ -299,40 +317,53 @@ impl WorkspaceRegistry { .collect::, _>>()?; let mut agents = Vec::with_capacity(rows.len()); for (projected_terminal_id, result_json, committed_revision) in rows { - let stored: StoredAgent = serde_json::from_str(&result_json).with_context(|| { - format!( - "invalid agent projection for terminal {projected_terminal_id:?} at revision {committed_revision}" - ) - })?; - anyhow::ensure!( - stored.terminal_id.as_str() == projected_terminal_id, - "agent {} projection key {} does not match terminal {}", - stored.id, - projected_terminal_id, - stored.terminal_id - ); - anyhow::ensure!( - stored.session_id == self.session_id, - "agent {} belongs to session {}, expected {}", - stored.id, - stored.session_id, - self.session_id - ); - anyhow::ensure!( - stored.id == agent_id(&stored.terminal_id)?, - "agent {} does not match terminal {}", - stored.id, - stored.terminal_id - ); - let _ = stored.extra; - agents.push(RegistryAgentProjection { - id: stored.id, - terminal_id: stored.terminal_id, - state: stored.state.as_str().to_string(), - source: stored.source.as_str().to_string(), - updated_at_ms: stored.updated_at_ms.get(), - source_session: stored.source_session, - }); + let projected_terminal_id = TerminalPublicId::parse(projected_terminal_id)?; + agents.push(decode_agent_projection( + &result_json, + &projected_terminal_id, + &self.session_id, + committed_revision, + )?); + } + agents.reverse(); + Ok(agents) + } + + fn stable_durable_agents(&self) -> anyhow::Result> { + let mut statement = self.connection.prepare( + "SELECT projection.terminal_id, + CASE + WHEN changed.terminal_id IS NULL THEN projection.result_json + ELSE changed.previous_result_json + END AS result_json, + CASE + WHEN changed.terminal_id IS NULL THEN projection.committed_revision + ELSE changed.previous_committed_revision + END AS committed_revision + FROM resource_agent_projections projection + JOIN resource_terminals terminal + ON terminal.public_id = projection.terminal_id + LEFT JOIN resource_agent_projection_rebuild_changes changed + ON changed.terminal_id = projection.terminal_id + WHERE terminal.deleted_revision IS NULL + AND (changed.terminal_id IS NULL + OR changed.previous_result_json IS NOT NULL) + ORDER BY json_extract(result_json, '$.id') ASC, projection.terminal_id ASC", + )?; + let rows = statement + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?)) + })? + .collect::, _>>()?; + let mut agents = Vec::with_capacity(rows.len()); + for (terminal_id, result_json, committed_revision) in rows { + let terminal_id = TerminalPublicId::parse(terminal_id)?; + agents.push(decode_agent_projection( + &result_json, + &terminal_id, + &self.session_id, + committed_revision, + )?); } agents.reverse(); Ok(agents) @@ -480,6 +511,48 @@ impl WorkspaceRegistry { } } +pub(super) fn decode_agent_projection( + result_json: &str, + projected_terminal_id: &TerminalPublicId, + session_id: &SessionPublicId, + committed_revision: i64, +) -> anyhow::Result { + let stored: StoredAgent = serde_json::from_str(result_json).with_context(|| { + format!( + "invalid agent projection for terminal {projected_terminal_id:?} at revision {committed_revision}" + ) + })?; + anyhow::ensure!( + &stored.terminal_id == projected_terminal_id, + "agent {} projection key {} does not match terminal {}", + stored.id, + projected_terminal_id, + stored.terminal_id + ); + anyhow::ensure!( + &stored.session_id == session_id, + "agent {} belongs to session {}, expected {}", + stored.id, + stored.session_id, + session_id + ); + anyhow::ensure!( + stored.id == agent_id(&stored.terminal_id)?, + "agent {} does not match terminal {}", + stored.id, + stored.terminal_id + ); + let _ = stored.extra; + Ok(RegistryAgentProjection { + id: stored.id, + terminal_id: stored.terminal_id, + state: stored.state.as_str().to_string(), + source: stored.source.as_str().to_string(), + updated_at_ms: stored.updated_at_ms.get(), + source_session: stored.source_session, + }) +} + fn agent_id(terminal_id: &TerminalPublicId) -> anyhow::Result { let digest = Sha256::digest(format!("cmux.protocol/2/agent/{terminal_id}").as_bytes()); let payload = digest[..16].iter().map(|byte| format!("{byte:02x}")).collect::(); diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/resource_store.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/resource_store.rs index 9506ab0c509..86d90e8a5e7 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/resource_store.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/resource_store.rs @@ -7,6 +7,16 @@ use super::*; pub(super) const RESOURCE_MUTATION_REPLAY_CAPACITY: usize = 4096; pub(super) const RESOURCE_MUTATION_PRUNE_INTERVAL: u64 = 128; const RESOURCE_EVENT_PAGE_SIZE: usize = 1024; +const RESOURCE_AGENT_SESSION_GENERATION_BACKFILL_KEY: &str = + "resource_agent_session_generation_backfill_v2"; +const RESOURCE_AGENT_SESSION_GENERATION_BACKFILL_TARGET_KEY: &str = + "resource_agent_session_generation_backfill_target_rowid_v2"; +const RESOURCE_AGENT_SESSION_GENERATION_BACKFILL_CURSOR_KEY: &str = + "resource_agent_session_generation_backfill_cursor_rowid_v2"; +const RESOURCE_AGENT_SESSION_GENERATION_BACKFILL_PROJECTION_CURSOR_KEY: &str = + "resource_agent_session_generation_backfill_projection_cursor_v2"; +const RESOURCE_AGENT_SESSION_GENERATION_BACKFILL_PAGE_SIZE: usize = 256; +const RESOURCE_AGENT_SESSION_GENERATION_FINALIZE_PAGE_SIZE: usize = 64; pub(super) fn create_resource_schema(transaction: &Transaction<'_>) -> anyhow::Result<()> { transaction.execute_batch( @@ -150,6 +160,42 @@ pub(super) fn create_resource_schema(transaction: &Transaction<'_>) -> anyhow::R ) ) ); + CREATE TABLE IF NOT EXISTS resource_agent_projection_rebuild_changes ( + terminal_id TEXT PRIMARY KEY NOT NULL + REFERENCES resource_terminals(public_id) ON DELETE CASCADE, + previous_result_json TEXT CHECK ( + previous_result_json IS NULL OR json_valid(previous_result_json) + ), + previous_committed_revision INTEGER CHECK ( + previous_committed_revision IS NULL OR previous_committed_revision >= 0 + ), + CHECK ( + (previous_result_json IS NULL AND previous_committed_revision IS NULL) + OR ( + previous_result_json IS NOT NULL + AND previous_committed_revision IS NOT NULL + ) + ) + ); + CREATE TABLE IF NOT EXISTS resource_agent_session_generations ( + terminal_id TEXT NOT NULL + REFERENCES resource_terminals(public_id) ON DELETE CASCADE, + provider TEXT NOT NULL, + source_session TEXT NOT NULL CHECK(length(source_session) > 0), + generation INTEGER NOT NULL CHECK(generation > 0), + superseded INTEGER NOT NULL CHECK(superseded IN (0, 1)), + journal_identity TEXT CHECK ( + journal_identity IS NULL OR ( + length(journal_identity) = 64 + AND journal_identity NOT GLOB '*[^0-9a-f]*' + ) + ), + PRIMARY KEY(terminal_id, provider, source_session), + UNIQUE(terminal_id, generation) + ); + CREATE UNIQUE INDEX IF NOT EXISTS resource_agent_session_generation_current + ON resource_agent_session_generations(terminal_id) + WHERE superseded = 0; DROP TRIGGER IF EXISTS resource_agent_projection_terminal_tombstone; CREATE INDEX IF NOT EXISTS resource_mutations_by_operation_revision ON resource_mutations(operation, committed_revision DESC); @@ -162,6 +208,325 @@ pub(super) fn create_resource_schema(transaction: &Transaction<'_>) -> anyhow::R terminal_id DESC );", )?; + ensure_resource_agent_projection_rebuild_snapshot(transaction)?; + ensure_resource_agent_session_journal_identity(transaction)?; + Ok(()) +} + +fn ensure_resource_agent_projection_rebuild_snapshot( + transaction: &Transaction<'_>, +) -> anyhow::Result<()> { + let columns = { + let mut statement = + transaction.prepare("PRAGMA table_info(resource_agent_projection_rebuild_changes)")?; + statement + .query_map([], |row| row.get::<_, String>(1))? + .collect::, _>>()? + }; + let mut upgraded = false; + if !columns.contains("previous_result_json") { + transaction.execute( + "ALTER TABLE resource_agent_projection_rebuild_changes + ADD COLUMN previous_result_json TEXT CHECK ( + previous_result_json IS NULL OR json_valid(previous_result_json) + )", + [], + )?; + upgraded = true; + } + if !columns.contains("previous_committed_revision") { + transaction.execute( + "ALTER TABLE resource_agent_projection_rebuild_changes + ADD COLUMN previous_committed_revision INTEGER CHECK ( + previous_committed_revision IS NULL OR previous_committed_revision >= 0 + )", + [], + )?; + upgraded = true; + } + if upgraded { + // The previous draft schema retained only terminal identities. Its + // best recoverable stable value is the projection present at upgrade. + transaction.execute( + "UPDATE resource_agent_projection_rebuild_changes AS changed + SET previous_result_json = ( + SELECT result_json FROM resource_agent_projections AS projection + WHERE projection.terminal_id = changed.terminal_id + ), + previous_committed_revision = ( + SELECT committed_revision FROM resource_agent_projections AS projection + WHERE projection.terminal_id = changed.terminal_id + )", + [], + )?; + } + Ok(()) +} + +fn ensure_resource_agent_session_journal_identity( + transaction: &Transaction<'_>, +) -> anyhow::Result<()> { + let columns = { + let mut statement = + transaction.prepare("PRAGMA table_info(resource_agent_session_generations)")?; + statement + .query_map([], |row| row.get::<_, String>(1))? + .collect::, _>>()? + }; + if !columns.contains("journal_identity") { + transaction.execute( + "ALTER TABLE resource_agent_session_generations + ADD COLUMN journal_identity TEXT CHECK ( + journal_identity IS NULL OR ( + length(journal_identity) = 64 + AND journal_identity NOT GLOB '*[^0-9a-f]*' + ) + )", + [], + )?; + } + Ok(()) +} + +pub(super) fn resource_agent_generation_backfill_pending( + connection: &Connection, +) -> anyhow::Result { + Ok(meta_value(connection, RESOURCE_AGENT_SESSION_GENERATION_BACKFILL_KEY)?.is_none()) +} + +pub(super) fn backfill_resource_agent_session_generations_page( + transaction: &Transaction<'_>, +) -> anyhow::Result { + if !resource_agent_generation_backfill_pending(transaction)? { + return Ok(true); + } + + let target = match resource_agent_generation_meta_i64( + transaction, + RESOURCE_AGENT_SESSION_GENERATION_BACKFILL_TARGET_KEY, + )? { + Some(target) => target, + None => { + let target = transaction.query_row( + "SELECT COALESCE(MAX(rowid), 0) + FROM resource_mutations + WHERE operation = 'agent.report'", + [], + |row| row.get::<_, i64>(0), + )?; + store_resource_agent_generation_meta_i64( + transaction, + RESOURCE_AGENT_SESSION_GENERATION_BACKFILL_TARGET_KEY, + target, + )?; + target + } + }; + let cursor = resource_agent_generation_meta_i64( + transaction, + RESOURCE_AGENT_SESSION_GENERATION_BACKFILL_CURSOR_KEY, + )? + .unwrap_or(0); + anyhow::ensure!(cursor <= target, "agent generation backfill cursor exceeds its target"); + if cursor < target { + let reports = { + let mut statement = transaction.prepare( + "SELECT rowid, result_json + FROM resource_mutations + WHERE operation = 'agent.report' AND rowid > ?1 AND rowid <= ?2 + ORDER BY rowid ASC + LIMIT ?3", + )?; + statement + .query_map( + params![ + cursor, + target, + i64::try_from(RESOURCE_AGENT_SESSION_GENERATION_BACKFILL_PAGE_SIZE)?, + ], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), + )? + .collect::, _>>()? + }; + for (_, result_json) in &reports { + import_resource_agent_generation(transaction, result_json)?; + } + let next_cursor = reports.last().map(|(rowid, _)| *rowid).unwrap_or(target); + store_resource_agent_generation_meta_i64( + transaction, + RESOURCE_AGENT_SESSION_GENERATION_BACKFILL_CURSOR_KEY, + next_cursor, + )?; + if next_cursor < target { + return Ok(false); + } + } + + let projection_cursor = + meta_value(transaction, RESOURCE_AGENT_SESSION_GENERATION_BACKFILL_PROJECTION_CURSOR_KEY)? + .unwrap_or_default(); + let projections = { + let mut statement = transaction.prepare( + "SELECT terminal_id, result_json + FROM resource_agent_projections + WHERE terminal_id > ?1 + ORDER BY terminal_id ASC + LIMIT ?2", + )?; + statement + .query_map( + params![ + projection_cursor, + i64::try_from(RESOURCE_AGENT_SESSION_GENERATION_FINALIZE_PAGE_SIZE)?, + ], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + )? + .collect::, _>>()? + }; + for (terminal_id, result_json) in &projections { + finalize_resource_agent_generation(transaction, terminal_id, result_json)?; + } + if let Some((terminal_id, _)) = projections.last() { + transaction.execute( + "INSERT INTO meta(key, value) VALUES(?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![RESOURCE_AGENT_SESSION_GENERATION_BACKFILL_PROJECTION_CURSOR_KEY, terminal_id], + )?; + } + if projections.len() == RESOURCE_AGENT_SESSION_GENERATION_FINALIZE_PAGE_SIZE { + return Ok(false); + } + + transaction.execute( + "INSERT INTO meta(key, value) VALUES(?1, '1') + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + [RESOURCE_AGENT_SESSION_GENERATION_BACKFILL_KEY], + )?; + transaction.execute( + "DELETE FROM meta WHERE key IN (?1, ?2, ?3)", + params![ + RESOURCE_AGENT_SESSION_GENERATION_BACKFILL_TARGET_KEY, + RESOURCE_AGENT_SESSION_GENERATION_BACKFILL_CURSOR_KEY, + RESOURCE_AGENT_SESSION_GENERATION_BACKFILL_PROJECTION_CURSOR_KEY, + ], + )?; + Ok(true) +} + +fn resource_agent_generation_meta_i64( + connection: &Connection, + key: &str, +) -> anyhow::Result> { + meta_value(connection, key)? + .map(|value| value.parse().with_context(|| format!("invalid {key} metadata"))) + .transpose() +} + +fn store_resource_agent_generation_meta_i64( + transaction: &Transaction<'_>, + key: &str, + value: i64, +) -> anyhow::Result<()> { + anyhow::ensure!(value >= 0, "{key} cannot be negative"); + transaction.execute( + "INSERT INTO meta(key, value) VALUES(?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![key, value.to_string()], + )?; + Ok(()) +} + +fn import_resource_agent_generation( + transaction: &Transaction<'_>, + result_json: &str, +) -> anyhow::Result<()> { + let Ok(result) = serde_json::from_str::(result_json) else { + return Ok(()); + }; + let Some(terminal_id) = result.get("terminal_id").and_then(Value::as_str) else { + return Ok(()); + }; + let Some(source_session) = + result.get("source_session").and_then(Value::as_str).filter(|value| !value.is_empty()) + else { + return Ok(()); + }; + let terminal_exists = transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM resource_agent_projections WHERE terminal_id = ?1 + )", + [terminal_id], + |row| row.get::<_, bool>(0), + )?; + if !terminal_exists { + return Ok(()); + } + let provider = result + .pointer("/extra/provider") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or(""); + let generation = transaction.query_row( + "SELECT COALESCE(MAX(generation), 0) + 1 + FROM resource_agent_session_generations + WHERE terminal_id = ?1", + [terminal_id], + |row| row.get::<_, i64>(0), + )?; + transaction.execute( + "INSERT OR IGNORE INTO resource_agent_session_generations( + terminal_id, provider, source_session, generation, superseded + ) VALUES(?1, ?2, ?3, ?4, 1)", + params![terminal_id, provider, source_session, generation], + )?; + Ok(()) +} + +fn finalize_resource_agent_generation( + transaction: &Transaction<'_>, + terminal_id: &str, + result_json: &str, +) -> anyhow::Result<()> { + transaction.execute( + "UPDATE resource_agent_session_generations + SET superseded = 1 + WHERE terminal_id = ?1", + [terminal_id], + )?; + let Ok(result) = serde_json::from_str::(result_json) else { + return Ok(()); + }; + let Some(source_session) = + result.get("source_session").and_then(Value::as_str).filter(|value| !value.is_empty()) + else { + return Ok(()); + }; + let provider = result + .pointer("/extra/provider") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or(""); + let activated = transaction.execute( + "UPDATE resource_agent_session_generations + SET superseded = 0 + WHERE terminal_id = ?1 AND provider = ?2 AND source_session = ?3", + params![terminal_id, provider, source_session], + )?; + if activated == 0 { + let generation = transaction.query_row( + "SELECT COALESCE(MAX(generation), 0) + 1 + FROM resource_agent_session_generations + WHERE terminal_id = ?1", + [terminal_id], + |row| row.get::<_, i64>(0), + )?; + transaction.execute( + "INSERT INTO resource_agent_session_generations( + terminal_id, provider, source_session, generation, superseded + ) VALUES(?1, ?2, ?3, ?4, 0)", + params![terminal_id, provider, source_session, generation], + )?; + } Ok(()) } @@ -334,6 +699,9 @@ pub(super) fn migrate_resource_mutations_to_session_scope( pub(super) fn initialize_resource_mutation_retention( transaction: &Transaction<'_>, ) -> anyhow::Result<()> { + if resource_agent_generation_backfill_pending(transaction)? { + return Ok(()); + } compact_resource_mutations(transaction) } @@ -466,15 +834,6 @@ impl WorkspaceRegistry { .ok_or_else(|| anyhow::anyhow!("resource revision exhausted"))?; let sqlite_revision = i64::try_from(revision).context("resource revision exceeds SQLite range")?; - tx.execute( - "INSERT INTO resource_agent_projections( - terminal_id, result_json, committed_revision - ) VALUES(?1, ?2, ?3) - ON CONFLICT(terminal_id) DO UPDATE SET - result_json = excluded.result_json, - committed_revision = excluded.committed_revision", - params![terminal_id.as_str(), result_json, sqlite_revision], - )?; tx.execute( "UPDATE meta SET value = ?1 WHERE key = 'resource_revision'", [revision.to_string()], diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs index e688153e8f7..690d509f481 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs @@ -14,6 +14,9 @@ pub(super) const MAX_JOURNAL_SEGMENT_UNCOMPRESSED_BYTES: usize = 16 * 1024 * 102 pub(super) const MAX_JOURNAL_CONTENT_BYTES: usize = 256 * 1024; const MIGRATION_EVENT_ID: &str = "event_session_journal_v9_migration"; const MIGRATION_EVENT_KIND: &str = "session.journal.migrated"; +const JOURNAL_EVENT_KIND_BACKFILL_CURSOR_KEY: &str = "journal_event_index_kind_backfill_cursor_v1"; +const JOURNAL_EVENT_KIND_BACKFILL_COMPLETE_KEY: &str = + "journal_event_index_kind_backfill_complete_v1"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -261,6 +264,7 @@ pub(super) fn create_session_journal_schema(transaction: &Transaction<'_>) -> an CREATE TABLE IF NOT EXISTS journal_event_index ( event_id TEXT PRIMARY KEY NOT NULL, sequence INTEGER UNIQUE NOT NULL CHECK(sequence > 0), + kind TEXT, causation_depth INTEGER NOT NULL CHECK(causation_depth >= 0), causation_id TEXT, causal_hook_id TEXT, @@ -275,6 +279,9 @@ pub(super) fn create_session_journal_schema(transaction: &Transaction<'_>) -> an ) ) ); + CREATE TABLE IF NOT EXISTS journal_agent_event_index ( + sequence INTEGER PRIMARY KEY NOT NULL CHECK(sequence > 0) + ); INSERT OR IGNORE INTO journal_event_index(event_id, sequence, causation_depth) SELECT event_id, sequence, causation_depth FROM session_journal; CREATE TRIGGER IF NOT EXISTS session_journal_reject_update @@ -348,6 +355,7 @@ pub(super) fn ensure_journal_event_index_schema( let added_causal_hook_id = !columns.contains("causal_hook_id"); let added_resource_revision = !columns.contains("resource_revision"); let added_previous_resource_revision = !columns.contains("previous_resource_revision"); + let added_kind = !columns.contains("kind"); if added_causation_id { transaction.execute("ALTER TABLE journal_event_index ADD COLUMN causation_id TEXT", [])?; } @@ -365,6 +373,9 @@ pub(super) fn ensure_journal_event_index_schema( [], )?; } + if added_kind { + transaction.execute("ALTER TABLE journal_event_index ADD COLUMN kind TEXT", [])?; + } let backfilled = transaction .query_row("SELECT 1 FROM meta WHERE key = 'journal_event_index_causation_v1'", [], |_| { Ok(()) @@ -452,6 +463,188 @@ pub(super) fn ensure_journal_event_index_schema( Ok(()) } +pub(super) fn backfill_journal_event_index_kinds_page( + transaction: &Transaction<'_>, + active_limit: usize, + allow_archived: bool, +) -> anyhow::Result { + anyhow::ensure!(active_limit > 0, "journal kind backfill page is empty"); + if journal_event_kind_backfill_complete(transaction)? { + return Ok(true); + } + let mut cursor = journal_event_kind_backfill_cursor(transaction)?; + let page = { + let mut statement = transaction.prepare( + "SELECT sequence, kind + FROM journal_event_index + WHERE sequence > ?1 + ORDER BY sequence ASC + LIMIT ?2", + )?; + let rows = statement.query_map( + params![ + i64::try_from(cursor).context("journal kind cursor exceeds SQLite")?, + i64::try_from(active_limit) + .context("journal kind backfill limit exceeds SQLite")?, + ], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option>(1)?)), + )?; + rows.map(|row| { + let (sequence, kind) = row?; + Ok((u64::try_from(sequence).context("journal sequence is negative")?, kind)) + }) + .collect::>>()? + }; + + for (sequence, stored_kind) in page { + if let Some(kind) = stored_kind { + index_agent_journal_sequence(transaction, sequence, &kind)?; + cursor = sequence; + continue; + } + let active_kind = transaction + .query_row( + "SELECT kind FROM session_journal WHERE sequence = ?1", + [i64::try_from(sequence).context("journal sequence exceeds SQLite")?], + |row| row.get::<_, String>(0), + ) + .optional()?; + if let Some(kind) = active_kind { + transaction.execute( + "UPDATE journal_event_index SET kind = ?1 + WHERE sequence = ?2 AND kind IS NULL", + params![kind, i64::try_from(sequence).context("journal sequence exceeds SQLite")?,], + )?; + index_agent_journal_sequence(transaction, sequence, &kind)?; + cursor = sequence; + continue; + } + if !allow_archived { + break; + } + let segment = transaction + .query_row( + "SELECT segment_id, start_sequence, end_sequence, record_count, codec, + content, uncompressed_bytes, sha256 + FROM journal_segments + WHERE start_sequence <= ?1 AND end_sequence >= ?1 + ORDER BY start_sequence DESC + LIMIT 1", + [i64::try_from(sequence).context("journal sequence exceeds SQLite")?], + journal_segment_row, + ) + .optional()? + .with_context(|| { + format!("journal event index kind backfill cannot find sequence {sequence}") + })?; + anyhow::ensure!( + usize::try_from(segment.6)? <= MAX_JOURNAL_SEGMENT_UNCOMPRESSED_BYTES, + "journal segment {} exceeds the kind backfill byte limit", + segment.0 + ); + let decoded = decode_journal_segment(segment)?; + let segment_end = decoded.end_sequence; + let mut saw_sequence = false; + let mut update = transaction.prepare( + "UPDATE journal_event_index SET kind = ?1 + WHERE sequence = ?2 AND kind IS NULL", + )?; + // One immutable segment has a verified byte bound. Decode it once, + // populate both indexes, and yield before the next segment. + for record in decoded.records { + saw_sequence |= record.sequence == sequence; + update.execute(params![ + record.kind, + i64::try_from(record.sequence).context("journal sequence exceeds SQLite")?, + ])?; + index_agent_journal_sequence(transaction, record.sequence, &record.kind)?; + } + anyhow::ensure!(saw_sequence, "journal segment omitted sequence {sequence}"); + cursor = segment_end; + store_journal_event_kind_backfill_cursor(transaction, cursor)?; + return finish_journal_event_kind_backfill(transaction, cursor); + } + + store_journal_event_kind_backfill_cursor(transaction, cursor)?; + finish_journal_event_kind_backfill(transaction, cursor) +} + +fn index_agent_journal_sequence( + transaction: &Transaction<'_>, + sequence: u64, + kind: &str, +) -> anyhow::Result<()> { + if kind.starts_with("agent.") { + transaction.execute( + "INSERT OR IGNORE INTO journal_agent_event_index(sequence) VALUES(?1)", + [i64::try_from(sequence).context("journal sequence exceeds SQLite")?], + )?; + } + Ok(()) +} + +fn journal_event_kind_backfill_cursor(connection: &Connection) -> anyhow::Result { + connection + .query_row( + "SELECT value FROM meta WHERE key = ?1", + [JOURNAL_EVENT_KIND_BACKFILL_CURSOR_KEY], + |row| row.get::<_, String>(0), + ) + .optional()? + .map(|value| value.parse::().context("journal kind backfill cursor is invalid")) + .transpose() + .map(|cursor| cursor.unwrap_or(0)) +} + +fn journal_event_kind_backfill_complete(connection: &Connection) -> anyhow::Result { + Ok(connection + .query_row( + "SELECT 1 FROM meta WHERE key = ?1", + [JOURNAL_EVENT_KIND_BACKFILL_COMPLETE_KEY], + |_| Ok(()), + ) + .optional()? + .is_some()) +} + +fn store_journal_event_kind_backfill_cursor( + transaction: &Transaction<'_>, + cursor: u64, +) -> anyhow::Result<()> { + transaction.execute( + "INSERT INTO meta(key, value) VALUES(?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![JOURNAL_EVENT_KIND_BACKFILL_CURSOR_KEY, cursor.to_string()], + )?; + Ok(()) +} + +fn finish_journal_event_kind_backfill( + transaction: &Transaction<'_>, + cursor: u64, +) -> anyhow::Result { + let head = transaction.query_row( + "SELECT COALESCE(MAX(sequence), 0) FROM journal_event_index", + [], + |row| row.get::<_, i64>(0), + )?; + let head = u64::try_from(head).context("journal event index head is negative")?; + anyhow::ensure!( + cursor <= head, + "journal kind backfill cursor {cursor} is ahead of event index head {head}" + ); + if cursor < head { + return Ok(false); + } + transaction.execute( + "INSERT OR IGNORE INTO meta(key, value) VALUES(?1, '1')", + [JOURNAL_EVENT_KIND_BACKFILL_COMPLETE_KEY], + )?; + transaction + .execute("DELETE FROM meta WHERE key = ?1", [JOURNAL_EVENT_KIND_BACKFILL_CURSOR_KEY])?; + Ok(true) +} + pub(super) fn migrate_resource_events_to_session_journal( transaction: &Transaction<'_>, ) -> anyhow::Result<()> { @@ -592,7 +785,7 @@ pub(super) fn append_resource_journal_record( patch: Option<&ResourcePatch>, result: &Value, changes: &Value, -) -> anyhow::Result<()> { +) -> anyhow::Result { append_resource_journal_record_at( transaction, revision, @@ -706,7 +899,7 @@ fn append_resource_journal_record_at( result: &Value, changes: &Value, occurred_at_ms: u64, -) -> anyhow::Result<()> { +) -> anyhow::Result { validate_identifier("journal operation", operation)?; let kind = semantic_journal_kind(operation); let session_id = transaction.query_row( @@ -720,6 +913,23 @@ fn append_resource_journal_record_at( } collect_subjects(result, &mut subjects); collect_subjects(changes, &mut subjects); + if operation == "agent.report" + && let Some(terminal_id) = result.get("terminal_id").and_then(Value::as_str) + && let Some(source_session) = + result.get("source_session").and_then(Value::as_str).filter(|value| !value.is_empty()) + { + let provider = result + .get("extra") + .and_then(|extra| extra.get("provider")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or(""); + subjects.insert(crate::agent_hooks::agent_session_subject( + terminal_id, + provider, + source_session, + )); + } expand_topology_subjects(transaction, &mut subjects)?; let subjects = subjects.into_iter().collect::>(); let producer = JournalProducer { kind: "resource_operation".into(), id: origin.into() }; @@ -750,8 +960,7 @@ fn append_resource_journal_record_at( resource_revision: Some(revision), previous_resource_revision: Some(previous_revision), }, - )?; - Ok(()) + ) } pub(super) fn append_journal_record( @@ -831,12 +1040,13 @@ pub(super) fn append_journal_record( }; transaction.execute( "INSERT INTO journal_event_index( - event_id, sequence, causation_depth, causation_id, causal_hook_id, + event_id, sequence, kind, causation_depth, causation_id, causal_hook_id, resource_revision, previous_resource_revision - ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ append.event_id, sequence, + append.kind, i64::from(append.causation_depth), append.causation_id, causal_hook_id, @@ -858,7 +1068,26 @@ pub(super) fn append_journal_record( FROM json_each(?2)", params![sequence, subjects_json], )?; - u64::try_from(sequence).context("journal sequence is negative") + let sequence = u64::try_from(sequence).context("journal sequence is negative")?; + index_agent_journal_sequence(transaction, sequence, append.kind)?; + if append.kind.starts_with("agent.") { + agent_projection_store::note_agent_projection_journal_candidate(transaction, sequence)?; + agent_projection_store::apply_agent_projection_journal_record( + transaction, + agent_projection_store::AgentProjectionJournalInput { + sequence, + kind: append.kind, + occurred_at_ms: append.occurred_at_ms, + producer: append.producer, + subjects: append.subjects, + payload: append.payload, + resource_revision: append.resource_revision, + rebuilding_generation_history: false, + replaying_projection_journal: false, + }, + )?; + } + Ok(sequence) } impl WorkspaceRegistry { @@ -881,16 +1110,7 @@ pub(super) fn query_session_journal_after( limit <= MAX_JOURNAL_PAGE_SIZE, "journal page limit exceeds {MAX_JOURNAL_PAGE_SIZE}" ); - let head_sequence = connection.query_row( - "SELECT MAX( - COALESCE((SELECT MAX(sequence) FROM session_journal), 0), - COALESCE((SELECT MAX(end_sequence) FROM journal_segments), 0) - )", - [], - |row| row.get::<_, i64>(0), - )?; - let head_sequence = - u64::try_from(head_sequence).context("journal head sequence is negative")?; + let head_sequence = session_journal_head(connection)?; anyhow::ensure!( sequence <= head_sequence, "cursor.invalid: journal sequence {sequence} is ahead of {head_sequence}" @@ -940,6 +1160,18 @@ pub(super) fn query_session_journal_after( Ok(SessionJournalPage { head_sequence, records }) } +pub(super) fn session_journal_head(connection: &Connection) -> anyhow::Result { + let head_sequence = connection.query_row( + "SELECT MAX( + COALESCE((SELECT MAX(sequence) FROM session_journal), 0), + COALESCE((SELECT MAX(end_sequence) FROM journal_segments), 0) + )", + [], + |row| row.get::<_, i64>(0), + )?; + u64::try_from(head_sequence).context("journal head sequence is negative") +} + pub(super) fn query_session_journal_sequences( connection: &Connection, sequences: &[u64], From a4a7e8aa91403a0184a320baa4303d89283669f0 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:26:32 -0700 Subject: [PATCH 2/5] test(tui): expose journal restore behavior gaps --- cmux-tui/crates/cmux-tui-core/src/mux.rs | 249 +++++++++++++++++++++++ 1 file changed, 249 insertions(+) diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index 77841414122..3a1feb1114a 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -26800,4 +26800,253 @@ mod tests { mux.authorize_provider_workspace_authority(AUTHORITY_TWO).unwrap(); *mux.workspace_close_after_selector_resolution.lock().unwrap() = None; } + + fn journal_restore_test_root(label: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "cmux-journal-restore-{label}-{}", + crate::workspace_registry::new_uuid_v4() + )) + } + + fn journal_restore_test_mux(label: &str) -> (std::path::PathBuf, Arc) { + let root = journal_restore_test_root(label); + let mux = Mux::open_persistent( + format!("journal-restore-{label}"), + SurfaceOptions::default(), + &root, + ) + .unwrap(); + (root, mux) + } + + fn finish_journal_restore_test(root: std::path::PathBuf, mux: Arc) { + mux.shutdown(); + drop(mux); + std::fs::remove_dir_all(root).unwrap(); + } + + fn journal_restore_surface_and_terminal( + mux: &Arc, + ) -> (SurfaceId, TerminalPublicId) { + let surface = mux.new_workspace(None, None).unwrap(); + let terminal_id = surface.terminal_public_id().cloned().unwrap(); + (surface.id, terminal_id) + } + + fn journal_restore_plan_after_agent( + mux: &Arc, + surface: SurfaceId, + ) -> (crate::workspace_registry::JournalCheckpointCommit, JournalRestorePlan) { + let checkpoint = mux + .create_journal_checkpoint("journal-restore-test", "checkpoint-before-agent") + .unwrap(); + mux.report_agent( + surface, + AgentState::Working, + AgentSource::Hook, + Some("restore-session".into()), + ) + .unwrap(); + let plan = mux + .prepare_journal_restore(&checkpoint.checkpoint.checkpoint_id) + .unwrap(); + (checkpoint, plan) + } + + fn journal_restore_required_manifest() -> crate::JournalProducerManifest { + crate::JournalProducerManifest { + producer_id: "restore-required-test".into(), + namespace: "plugin.restore_required_test".into(), + manifest_version: 1, + max_sensitivity: crate::JournalSensitivity::Metadata, + permissions: vec!["journal.append.plugin.restore_required_test".into()], + events: vec![crate::JournalEventSchema { + kind: "plugin.restore_required_test.event".into(), + schema_version: 1, + class: crate::JournalClass::State, + replay: crate::JournalReplayPolicy::Required, + sensitivity: crate::JournalSensitivity::Metadata, + payload_schema: serde_json::json!({"type":"object"}), + }], + } + } + + fn append_journal_restore_required_record(mux: &Arc, key: &str) { + let manifest = journal_restore_required_manifest(); + mux.put_journal_producer(&manifest, "journal-restore-test", "restore-producer") + .unwrap(); + let event = manifest.events[0].clone(); + let ingress = crate::JournalIngress { + producer_id: manifest.producer_id.clone(), + manifest_version: manifest.manifest_version, + kind: event.kind, + schema_version: event.schema_version, + occurred_at_ms: None, + subjects: Vec::new(), + sensitivity: None, + payload: serde_json::json!({"unknown_required":true}), + causation_id: None, + correlation_id: None, + }; + mux.append_journal_ingress(&ingress, "journal-restore-test", key).unwrap(); + } + + #[test] + fn journal_restore_updates_durable_and_memory_projection_once() { + let (root, mux) = journal_restore_test_mux("projection"); + let (surface, terminal_id) = journal_restore_surface_and_terminal(&mux); + let (_checkpoint, plan) = journal_restore_plan_after_agent(&mux, surface); + mux.corrupt_agent_projection_for_test(&terminal_id); + let before_epoch = mux.journal_event_epoch(); + + let (result, commit) = mux + .restore_journal_projections_with_receipt( + plan, + "journal-restore-test", + "restore-projection", + ) + .unwrap(); + + assert!(!commit.journal.replayed); + assert_eq!(result["restored"], true); + assert_eq!(mux.resource_agent_projection_count_for_test().unwrap(), 1); + let agents = mux.list_agents(Some(surface), None); + assert_eq!(agents.len(), 1); + assert_eq!(agents[0].terminal_id, terminal_id); + assert_eq!(agents[0].state, AgentState::Working); + assert!(mux.journal_event_epoch() > before_epoch); + finish_journal_restore_test(root, mux); + } + + #[test] + fn journal_restore_replay_is_idempotent_after_head_advances() { + let (root, mux) = journal_restore_test_mux("idempotent"); + let (surface, terminal_id) = journal_restore_surface_and_terminal(&mux); + let (_checkpoint, plan) = journal_restore_plan_after_agent(&mux, surface); + let replay_plan = plan.clone(); + + let (_, first) = mux + .restore_journal_projections_with_receipt( + plan, + "journal-restore-test", + "restore-idempotent", + ) + .unwrap(); + let epoch_after_first = mux.journal_event_epoch(); + let first_agents = mux.list_agents(Some(surface), None); + assert_eq!(first_agents.len(), 1); + + let (result, replay) = mux + .restore_journal_projections_with_receipt( + replay_plan, + "journal-restore-test", + "restore-idempotent", + ) + .unwrap(); + assert!(replay.journal.replayed); + assert_eq!(replay.journal.sequence, first.journal.sequence); + assert_eq!(result["sequence"], first.journal.sequence.to_string()); + assert_eq!(mux.journal_event_epoch(), epoch_after_first); + let replay_agents = mux.list_agents(Some(surface), None); + assert_eq!(replay_agents.len(), first_agents.len()); + assert_eq!(replay_agents[0].terminal_id, first_agents[0].terminal_id); + assert_eq!(replay_agents[0].state, first_agents[0].state); + assert_eq!(replay_agents[0].source, first_agents[0].source); + assert_eq!(replay_agents[0].session, first_agents[0].session); + assert_eq!(replay_agents[0].terminal_id, terminal_id); + finish_journal_restore_test(root, mux); + } + + #[test] + fn journal_restore_rejects_concurrent_head_change_without_mutation() { + let (root, mux) = journal_restore_test_mux("head-fence"); + let (surface, _terminal_id) = journal_restore_surface_and_terminal(&mux); + let checkpoint = mux + .create_journal_checkpoint("journal-restore-test", "checkpoint-head-fence") + .unwrap(); + let plan = mux + .prepare_journal_restore(&checkpoint.checkpoint.checkpoint_id) + .unwrap(); + mux.report_agent( + surface, + AgentState::Working, + AgentSource::Hook, + Some("head-fence-session".into()), + ) + .unwrap(); + + let error = mux + .restore_journal_projections_with_receipt( + plan, + "journal-restore-test", + "restore-head-fence", + ) + .unwrap_err() + .to_string(); + assert!(error.contains("journal head changed while preparing restore"), "{error}"); + let records = mux.session_journal_after(0, 256).unwrap().records; + assert!(!records.iter().any(|record| record.kind == "journal.restore.applied")); + finish_journal_restore_test(root, mux); + } + + #[test] + fn journal_restore_rejects_incompatible_required_records_without_mutation() { + let (root, mux) = journal_restore_test_mux("incompatible"); + let checkpoint = mux + .create_journal_checkpoint("journal-restore-test", "checkpoint-incompatible") + .unwrap(); + append_journal_restore_required_record(&mux, "restore-incompatible"); + let plan = mux + .prepare_journal_restore(&checkpoint.checkpoint.checkpoint_id) + .unwrap(); + assert_eq!(plan.preview["fully_reducible"], false); + assert_eq!(plan.preview["unsupported_required_record_count"], "1"); + + let error = mux + .restore_journal_projections_with_receipt( + plan, + "journal-restore-test", + "restore-incompatible", + ) + .unwrap_err() + .to_string(); + assert!(error.contains("journal restore is not fully reducible"), "{error}"); + let records = mux.session_journal_after(0, 256).unwrap().records; + assert!(!records.iter().any(|record| record.kind == "journal.restore.applied")); + finish_journal_restore_test(root, mux); + } + + #[test] + fn journal_inspect_keeps_immutable_history_diagnostics_actionable() { + let (root, mux) = journal_restore_test_mux("immutable"); + let checkpoint = mux + .create_journal_checkpoint("journal-restore-test", "checkpoint-immutable") + .unwrap(); + let database = mux + .workspace_registry + .lock() + .unwrap() + .session_journal_database_path() + .unwrap(); + let connection = rusqlite::Connection::open(database).unwrap(); + let error = connection + .execute( + "UPDATE journal_checkpoints SET sha256 = ?1 WHERE checkpoint_id = ?2", + rusqlite::params!["00".repeat(32), checkpoint.checkpoint.checkpoint_id.clone()], + ) + .unwrap_err() + .to_string(); + assert!(error.contains("journal checkpoints are immutable"), "{error}"); + drop(connection); + + let inspected = mux + .journal_inspect(Some(&checkpoint.checkpoint.checkpoint_id)) + .unwrap(); + assert_eq!( + inspected["checkpoint"]["checkpoint_id"], + checkpoint.checkpoint.checkpoint_id + ); + assert_eq!(inspected["preview"]["checkpoint_id"], checkpoint.checkpoint.checkpoint_id); + finish_journal_restore_test(root, mux); + } } From ae943727247e9804814ce1c5993d9a1389711dff Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:34:55 -0700 Subject: [PATCH 3/5] fix(tui): restore journal projections with fenced receipt --- .../cmux-tui-core/src/journal_checkpoint.rs | 7 +- cmux-tui/crates/cmux-tui-core/src/mux.rs | 223 +++++++++++++++--- .../src/mux/public_projections.rs | 14 ++ cmux-tui/crates/cmux-tui-core/src/resource.rs | 8 + .../cmux-tui-core/src/resource_router.rs | 3 + cmux-tui/crates/cmux-tui-core/src/server.rs | 52 ++++ .../cmux-tui-core/src/workspace_registry.rs | 20 +- .../agent_projection_store.rs | 157 +++++++++++- .../workspace_registry/journal_extensions.rs | 158 ++++++++++++- 9 files changed, 593 insertions(+), 49 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/journal_checkpoint.rs b/cmux-tui/crates/cmux-tui-core/src/journal_checkpoint.rs index ffadbcfb9ff..a96e9baf835 100644 --- a/cmux-tui/crates/cmux-tui-core/src/journal_checkpoint.rs +++ b/cmux-tui/crates/cmux-tui-core/src/journal_checkpoint.rs @@ -344,7 +344,12 @@ impl RestoreReducer { } fn apply_required_record(&mut self, record: &SessionJournalRecord) -> anyhow::Result { - if matches!(record.kind.as_str(), "journal.checkpoint.created" | "journal.segment.sealed") { + if matches!( + record.kind.as_str(), + "journal.checkpoint.created" + | "journal.segment.sealed" + | "journal.restore.applied" + ) { return Ok(true); } if record.kind == "journal.producer.installed" { diff --git a/cmux-tui/crates/cmux-tui-core/src/mux.rs b/cmux-tui/crates/cmux-tui-core/src/mux.rs index 3a1feb1114a..a34d7e9ec0c 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux.rs @@ -2201,15 +2201,25 @@ impl Mux { session: impl Into, surface_options: SurfaceOptions, state_root: &Path, + ) -> anyhow::Result> { + Self::open_persistent_with_restore(session, surface_options, state_root, true) + } + + pub fn open_persistent_with_restore( + session: impl Into, + surface_options: SurfaceOptions, + state_root: &Path, + restore_journal: bool, ) -> anyhow::Result> { let session = session.into(); - let registry = WorkspaceRegistry::open(state_root, &session)?; - Self::from_workspace_registry( + let registry = WorkspaceRegistry::open_with_restore(state_root, &session, restore_journal)?; + Self::from_workspace_registry_with_restore( session, surface_options, registry, ProviderWorkspaceState::default(), false, + restore_journal, ) } @@ -2218,10 +2228,26 @@ impl Mux { surface_options: SurfaceOptions, state_root: &Path, authority: ProviderWorkspaceAuthority, + ) -> anyhow::Result> { + Self::open_persistent_provider_managed_with_restore( + session, + surface_options, + state_root, + authority, + true, + ) + } + + pub fn open_persistent_provider_managed_with_restore( + session: impl Into, + surface_options: SurfaceOptions, + state_root: &Path, + authority: ProviderWorkspaceAuthority, + restore_journal: bool, ) -> anyhow::Result> { let session = session.into(); - let registry = WorkspaceRegistry::open(state_root, &session)?; - Self::from_workspace_registry( + let registry = WorkspaceRegistry::open_with_restore(state_root, &session, restore_journal)?; + Self::from_workspace_registry_with_restore( session, surface_options, registry, @@ -2232,6 +2258,7 @@ impl Mux { authority: Some(authority), }, false, + restore_journal, ) } @@ -2240,12 +2267,28 @@ impl Mux { surface_options: SurfaceOptions, state_root: &Path, mux_generation: impl Into, + ) -> anyhow::Result> { + Self::open_persistent_provider_managed_pending_with_restore( + session, + surface_options, + state_root, + mux_generation, + true, + ) + } + + pub fn open_persistent_provider_managed_pending_with_restore( + session: impl Into, + surface_options: SurfaceOptions, + state_root: &Path, + mux_generation: impl Into, + restore_journal: bool, ) -> anyhow::Result> { let mux_generation = mux_generation.into(); validate_mux_generation(&mux_generation)?; let session = session.into(); - let registry = WorkspaceRegistry::open(state_root, &session)?; - Self::from_workspace_registry( + let registry = WorkspaceRegistry::open_with_restore(state_root, &session, restore_journal)?; + Self::from_workspace_registry_with_restore( session, surface_options, registry, @@ -2256,15 +2299,34 @@ impl Mux { authority: None, }, false, + restore_journal, ) } fn from_workspace_registry( + session: String, + surface_options: SurfaceOptions, + registry: WorkspaceRegistry, + provider_workspace: ProviderWorkspaceState, + test_surface_runtime: bool, + ) -> anyhow::Result> { + Self::from_workspace_registry_with_restore( + session, + surface_options, + registry, + provider_workspace, + test_surface_runtime, + true, + ) + } + + fn from_workspace_registry_with_restore( session: String, mut surface_options: SurfaceOptions, registry: WorkspaceRegistry, provider_workspace: ProviderWorkspaceState, #[cfg_attr(not(test), allow(unused_variables))] test_surface_runtime: bool, + restore_journal: bool, ) -> anyhow::Result> { let snapshot = registry.snapshot()?; let topology = registry.resource_topology_snapshot()?; @@ -2429,7 +2491,9 @@ impl Mux { test_surface_runtime, session, }); - mux.start_agent_projection_rebuild_worker()?; + if restore_journal { + mux.start_agent_projection_rebuild_worker()?; + } crate::journal_ingress::start(&mux, journal_ingress_receiver)?; mux.materialize_interrupted_resource_workspaces()?; mux.materialize_restored_browsers(&contents)?; @@ -5474,66 +5538,147 @@ impl Mux { self.workspace_registry.lock().unwrap().journal_checkpoints() } - pub(crate) fn journal_restore_preview(&self, selector: &str) -> anyhow::Result { - let checkpoint = self - .workspace_registry - .lock() - .unwrap() - .journal_checkpoint(selector)? - .with_context(|| format!("journal checkpoint {selector:?} does not exist"))?; + fn journal_restore_plan_inner( + &self, + selector: &str, + ) -> anyhow::Result> { + let (checkpoint, head_sequence) = { + let registry = self.workspace_registry.lock().unwrap(); + let checkpoint = registry.journal_checkpoint(selector)?; + let head_sequence = registry.session_journal_after(0, 1)?.head_sequence; + (checkpoint, head_sequence) + }; + let Some(checkpoint) = checkpoint else { + return Ok(None); + }; + anyhow::ensure!( + checkpoint.source_sequence <= head_sequence, + "journal checkpoint {} is newer than journal head {}", + checkpoint.checkpoint_id, + head_sequence + ); let mut reducer = crate::journal_checkpoint::RestoreReducer::new(&checkpoint)?; let mut sequence = checkpoint.source_sequence; - let mut target_head = None; - let head_sequence = loop { + while sequence < head_sequence { let page = self.session_journal_after(sequence, 1024)?; - let head = *target_head.get_or_insert(page.head_sequence); - let empty = page.records.is_empty(); + anyhow::ensure!( + page.head_sequence >= head_sequence, + "journal head moved backwards while preparing restore" + ); + let mut advanced = false; for record in page.records { - if record.sequence > head { + if record.sequence > head_sequence { break; } + anyhow::ensure!( + record.sequence > sequence, + "journal restore page did not advance after sequence {sequence}" + ); sequence = record.sequence; reducer.apply(&record)?; + advanced = true; } - if empty || sequence >= head { - break head; - } - }; - reducer.finish(head_sequence) + anyhow::ensure!( + advanced, + "journal head {head_sequence} is not readable after sequence {sequence}" + ); + } + let preview = reducer.finish(head_sequence)?; + { + let registry = self.workspace_registry.lock().unwrap(); + // Validate the exact reduced collection before the mutation + // transaction. This keeps malformed historical state fail closed + // without touching the live projection or receipt tables. + registry.validate_reduced_agent_state(&preview["state"], head_sequence)?; + } + Ok(Some(JournalRestorePlan { head_sequence, preview })) } pub(crate) fn prepare_journal_restore( &self, selector: &str, ) -> anyhow::Result { - let preview = self.journal_restore_preview(selector)?; - let head_sequence = preview["head_sequence"] - .as_str() - .context("restore preview omitted head_sequence")? - .parse() - .context("restore preview head_sequence is invalid")?; - Ok(JournalRestorePlan { head_sequence, preview }) + self.journal_restore_plan_inner(selector)? + .with_context(|| format!("journal checkpoint {selector:?} does not exist")) + } + + pub(crate) fn journal_restore_preview(&self, selector: &str) -> anyhow::Result { + Ok(self.prepare_journal_restore(selector)?.preview) } pub(crate) fn journal_projection_status(&self) -> anyhow::Result { - anyhow::bail!("journal restore implementation pending") + self.workspace_registry.lock().unwrap().agent_projection_restore_status() } pub(crate) fn journal_list(&self) -> anyhow::Result { - anyhow::bail!("journal restore implementation pending") + let head_sequence = self.session_journal_after(0, 1)?.head_sequence; + let checkpoints = self.journal_checkpoints()?; + let segments = self.journal_segments()?; + let projection = self.journal_projection_status()?; + Ok(json!({ + "head_sequence": head_sequence.to_string(), + "checkpoints": checkpoints, + "segments": segments, + "projection": projection, + })) } - pub(crate) fn journal_inspect(&self, _selector: Option<&str>) -> anyhow::Result { - anyhow::bail!("journal restore implementation pending") + pub(crate) fn journal_inspect(&self, selector: Option<&str>) -> anyhow::Result { + let projection = self.journal_projection_status()?; + let selector = selector.unwrap_or("latest"); + let Some(plan) = self.journal_restore_plan_inner(selector)? else { + let head_sequence = self.session_journal_after(0, 1)?.head_sequence; + return Ok(json!({ + "head_sequence": head_sequence.to_string(), + "checkpoint": Value::Null, + "preview": Value::Null, + "projection": projection, + })); + }; + let summary = self + .journal_checkpoints()? + .into_iter() + .find(|summary| { + summary.checkpoint_id + == plan.preview["checkpoint_id"].as_str().unwrap_or_default() + }) + .context("selected journal checkpoint has no summary")?; + Ok(json!({ + "head_sequence": plan.head_sequence.to_string(), + "checkpoint": summary, + "preview": plan.preview, + "projection": projection, + })) } pub(crate) fn restore_journal_projections_with_receipt( &self, - _plan: JournalRestorePlan, - _origin: &str, - _idempotency_key: &str, + plan: JournalRestorePlan, + origin: &str, + idempotency_key: &str, ) -> anyhow::Result<(Value, crate::workspace_registry::JournalRestoreCommit)> { - anyhow::bail!("journal restore implementation pending") + anyhow::ensure!( + plan.preview["fully_reducible"] == Value::Bool(true), + "journal restore is not fully reducible" + ); + let checkpoint_id = plan.preview["checkpoint_id"].as_str(); + let state_sha256 = plan.preview["state_sha256"].as_str(); + let mut registry = self.workspace_registry.lock().unwrap(); + let (commit, projections, result) = registry.apply_journal_restore_state( + plan.head_sequence, + &plan.preview["state"], + origin, + idempotency_key, + checkpoint_id, + state_sha256, + )?; + drop(registry); + if !commit.journal.replayed { + let records = public_projections::restore_agent_projections(projections)?; + self.agent_records.lock().unwrap().replace(records); + self.publish_journal_event(); + } + Ok((result, commit)) } pub(crate) fn journal_segments(&self) -> anyhow::Result> { diff --git a/cmux-tui/crates/cmux-tui-core/src/mux/public_projections.rs b/cmux-tui/crates/cmux-tui-core/src/mux/public_projections.rs index 45c61542e44..7b5b82869e2 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux/public_projections.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux/public_projections.rs @@ -147,6 +147,20 @@ impl TerminalAgentRecords { Ok(()) } + pub(super) fn replace(&mut self, records: HashMap) { + self.next_version = self.next_version.saturating_add(1); + self.published_version = self.next_version; + self.entries = records + .into_iter() + .map(|(terminal_id, record)| { + ( + terminal_id, + VersionedTerminalAgentRecord { published: Some(record), pending: None }, + ) + }) + .collect(); + } + pub(super) fn remove(&mut self, terminal_id: &TerminalPublicId) -> Option { self.entries .remove(terminal_id) diff --git a/cmux-tui/crates/cmux-tui-core/src/resource.rs b/cmux-tui/crates/cmux-tui-core/src/resource.rs index 0eb0b3430d8..be3d41b3b04 100644 --- a/cmux-tui/crates/cmux-tui-core/src/resource.rs +++ b/cmux-tui/crates/cmux-tui-core/src/resource.rs @@ -156,6 +156,12 @@ pub enum ResourceOperation { SessionJournalHookList, #[serde(rename = "session.journal.hook.put")] SessionJournalHookPut, + #[serde(rename = "session.journal.inspect")] + SessionJournalInspect, + #[serde(rename = "session.journal.list")] + SessionJournalList, + #[serde(rename = "session.journal.restore")] + SessionJournalRestore, #[serde(rename = "session.journal.restore.preview")] SessionJournalRestorePreview, #[serde(rename = "session.journal.segment.list")] @@ -445,6 +451,8 @@ impl ResourceOperation { | Self::SessionJournalProducerList | Self::SessionJournalHookList | Self::SessionJournalCheckpointList + | Self::SessionJournalInspect + | Self::SessionJournalList | Self::SessionJournalRestorePreview | Self::SessionJournalSegmentList | Self::ClientList diff --git a/cmux-tui/crates/cmux-tui-core/src/resource_router.rs b/cmux-tui/crates/cmux-tui-core/src/resource_router.rs index 65c32cd6b52..5b7df706a51 100644 --- a/cmux-tui/crates/cmux-tui-core/src/resource_router.rs +++ b/cmux-tui/crates/cmux-tui-core/src/resource_router.rs @@ -1005,6 +1005,9 @@ const fn operation_owner(operation: ResourceOperation) -> OperationOwner { | ResourceOperation::SessionJournalHookPut | ResourceOperation::SessionJournalCheckpointCreate | ResourceOperation::SessionJournalCheckpointList + | ResourceOperation::SessionJournalInspect + | ResourceOperation::SessionJournalList + | ResourceOperation::SessionJournalRestore | ResourceOperation::SessionJournalRestorePreview | ResourceOperation::SessionJournalSegmentList | ResourceOperation::SessionJournalSegmentSeal diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 5cf8fcc38da..16ac268f4e4 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -5548,6 +5548,9 @@ const fn handles_resource_connection_operation(operation: ResourceOperation) -> | ResourceOperation::SessionJournalHookPut | ResourceOperation::SessionJournalCheckpointCreate | ResourceOperation::SessionJournalCheckpointList + | ResourceOperation::SessionJournalInspect + | ResourceOperation::SessionJournalList + | ResourceOperation::SessionJournalRestore | ResourceOperation::SessionJournalRestorePreview | ResourceOperation::SessionJournalSegmentList | ResourceOperation::SessionJournalSegmentSeal @@ -5785,6 +5788,9 @@ fn handle_resource_connection_message( | ResourceOperation::SessionJournalHookPut | ResourceOperation::SessionJournalCheckpointCreate | ResourceOperation::SessionJournalCheckpointList + | ResourceOperation::SessionJournalInspect + | ResourceOperation::SessionJournalList + | ResourceOperation::SessionJournalRestore | ResourceOperation::SessionJournalRestorePreview | ResourceOperation::SessionJournalSegmentList | ResourceOperation::SessionJournalSegmentSeal => { @@ -8075,12 +8081,45 @@ fn handle_journal_extension_request( })).collect::>()}) }) .map_err(|error| journal_extension_error("session.journal.checkpoint.list", error)), + ResourceOperation::SessionJournalList => mux + .journal_list() + .map_err(|error| journal_extension_error("session.journal.list", error)), + ResourceOperation::SessionJournalInspect => { + let checkpoint = request.fields.get("checkpoint").and_then(Value::as_str); + mux.journal_inspect(checkpoint) + .map_err(|error| journal_extension_error("session.journal.inspect", error)) + } ResourceOperation::SessionJournalRestorePreview => { let selector = request.fields.get("checkpoint").and_then(Value::as_str).unwrap_or("latest"); mux.journal_restore_preview(selector) .map_err(|error| journal_extension_error("session.journal.restore.preview", error)) } + ResourceOperation::SessionJournalRestore => { + let selector = + request.fields.get("checkpoint").and_then(Value::as_str).unwrap_or("latest"); + let plan = mux + .prepare_journal_restore(selector) + .map_err(|error| journal_extension_error("session.journal.restore", error))?; + if plan.preview["fully_reducible"] != Value::Bool(true) { + return Err(journal_restore_blocked_error(&plan.preview)); + } + let idempotency_key = request + .envelope + .idempotency_key + .as_deref() + .expect("catalog requires mutation idempotency"); + mux.restore_journal_projections_with_receipt(plan, origin, idempotency_key) + .map(|(value, commit)| { + json!({ + "value":value, + "generation":session_id, + "revision":commit.journal.sequence.to_string(), + "replayed":commit.journal.replayed, + }) + }) + .map_err(|error| journal_extension_error("session.journal.restore", error)) + } ResourceOperation::SessionJournalSegmentList => mux .journal_segments() .map(|segments| json!({"segments":segments})) @@ -8120,6 +8159,19 @@ fn handle_journal_extension_request( } } +fn journal_restore_blocked_error(preview: &Value) -> ResourceError { + ResourceError::operation_failed( + "session.journal.restore", + "journal restore is not fully reducible; no projection was changed", + json!({ + "action":"run session journal inspect --checkpoint , then repair or remove the unsupported required record before retrying", + "checkpoint_id":preview["checkpoint_id"].clone(), + "unsupported_required_record_count":preview["unsupported_required_record_count"].clone(), + "unsupported_required_records":preview["unsupported_required_records"].clone(), + }), + ) +} + fn journal_extension_error(operation: &str, error: anyhow::Error) -> ResourceError { let message = error.to_string(); eprintln!("cmux-tui: {operation} failed: {error:#}"); diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs index f685e7df057..0e79c8a444f 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs @@ -2260,10 +2260,24 @@ impl WorkspaceRegistry { None, None, None, + true, ) } pub fn open(root: &Path, session_name: &str) -> anyhow::Result { + Self::open_with_restore(root, session_name, true) + } + + /// Opens a durable registry without replaying journal-owned projections. + /// + /// The journal remains the durable source of truth in both modes. Skipping + /// replay leaves derived projection tables and their rebuild cursor + /// untouched until an explicit restore is requested. + pub(crate) fn open_with_restore( + root: &Path, + session_name: &str, + restore_journal: bool, + ) -> anyhow::Result { let session_dir = root.join(session_storage_component(session_name)); let db_path = session_dir.join(WORKSPACE_REGISTRY_FILE); if db_path.is_file() @@ -2295,6 +2309,7 @@ impl WorkspaceRegistry { Some(session_guard), Some(lease), Some(db_path), + restore_journal, ) } @@ -2306,6 +2321,7 @@ impl WorkspaceRegistry { session_guard: Option, lease: Option, database_path: Option, + restore_journal: bool, ) -> anyhow::Result { connection.busy_timeout(std::time::Duration::from_secs(5))?; connection.execute_batch( @@ -2614,7 +2630,9 @@ impl WorkspaceRegistry { "workspace registry belongs to session {stored_name:?}, not {session_name:?}" ); } - rebuild_agent_projections_from_journal(&connection, false)?; + if restore_journal { + rebuild_agent_projections_from_journal(&connection, false)?; + } let registry_id = required_meta(&connection, "registry_id")?; validate_identifier("registry id", ®istry_id)?; let session_id = SessionPublicId::parse(required_meta(&connection, "session_public_id")?)?; diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/agent_projection_store.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/agent_projection_store.rs index f7daecf9b44..1b97c1dd065 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/agent_projection_store.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/agent_projection_store.rs @@ -3,6 +3,7 @@ use super::*; use crate::resource::AgentPublicId; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; +use std::collections::HashSet; const RECOVERY_FORMAT: &str = "cmux.agent-recovery.v1"; const RECOVERY_PRODUCER_ID: &str = "agent-recovery-v1"; @@ -730,6 +731,51 @@ impl WorkspaceRegistry { agent_projection_rebuild_active(&self.connection) } + /// Returns only derived projection progress. Journal rows are never + /// changed by this inspection path. + pub(crate) fn agent_projection_restore_status(&self) -> anyhow::Result { + let cursor = agent_projection_journal_cursor(&self.connection)?; + let candidate = agent_projection_journal_candidate(&self.connection)?; + let target = agent_projection_journal_rebuild_target(&self.connection)?; + let head = session_journal::session_journal_head(&self.connection)?; + Ok(json!({ + "head_sequence": head.to_string(), + "cursor_sequence": cursor.map(|value| value.to_string()), + "candidate_sequence": candidate.map(|value| value.to_string()), + "target_sequence": target.map(|value| value.to_string()), + "pending": agent_projection_rebuild_active(&self.connection)?, + })) + } + + pub(super) fn agent_projection_restore_status_for_transaction( + transaction: &Transaction<'_>, + ) -> anyhow::Result { + let cursor = agent_projection_journal_cursor(transaction)?; + let candidate = agent_projection_journal_candidate(transaction)?; + let target = agent_projection_journal_rebuild_target(transaction)?; + let head = session_journal::session_journal_head(transaction)?; + Ok(json!({ + "head_sequence": head.to_string(), + "cursor_sequence": cursor.map(|value| value.to_string()), + "candidate_sequence": candidate.map(|value| value.to_string()), + "target_sequence": target.map(|value| value.to_string()), + "pending": agent_projection_rebuild_active(transaction)?, + })) + } + + /// Validates the agent collection in a reduced journal state without + /// changing any durable projection. Restore uses the reducer's public + /// state, rather than replaying the live journal a second time, so this + /// validation is part of the same checkpoint-specific plan as preview. + pub(crate) fn validate_reduced_agent_state( + &self, + state: &Value, + committed_revision: u64, + ) -> anyhow::Result> { + reduced_agent_values(state, &self.session_id, committed_revision) + .map(|values| values.into_iter().map(|(_, _, projection)| projection).collect()) + } + #[cfg(test)] pub(crate) fn continue_agent_projection_rebuild(&self) -> anyhow::Result { let step = self.continue_agent_projection_rebuild_page()?; @@ -926,6 +972,116 @@ impl WorkspaceRegistry { } } +/// Replaces the terminal-current compatibility projection with the agent +/// collection produced by a checkpoint reducer. The caller owns the SQLite +/// transaction and must have fenced the journal head and idempotency receipt +/// before invoking this function. +pub(super) fn replace_agent_projections_from_reduced_state( + transaction: &Transaction<'_>, + state: &Value, + committed_revision: u64, +) -> anyhow::Result> { + let session_id = SessionPublicId::parse(transaction.query_row( + "SELECT value FROM meta WHERE key = 'session_public_id'", + [], + |row| row.get::<_, String>(0), + )?)?; + let values = reduced_agent_values(state, &session_id, committed_revision)?; + let committed_revision = + i64::try_from(committed_revision).context("agent projection revision exceeds SQLite")?; + + anyhow::ensure!( + prejournal_projection_migration_cursor(transaction)?.is_none(), + "agent projection migration is still pending" + ); + if let Some(cursor) = agent_projection_journal_cursor(transaction)? { + anyhow::ensure!( + cursor <= u64::try_from(committed_revision)?, + "agent projection cursor {cursor} is ahead of restore revision {committed_revision}" + ); + } + if let Some(candidate) = agent_projection_journal_candidate(transaction)? { + anyhow::ensure!( + candidate <= u64::try_from(committed_revision)?, + "agent projection candidate {candidate} is ahead of restore revision {committed_revision}" + ); + } + for (terminal_id, _, _) in &values { + let exists = transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM resource_terminals + WHERE public_id = ?1 + )", + [terminal_id.as_str()], + |row| row.get::<_, bool>(0), + )?; + anyhow::ensure!( + exists, + "reduced journal state references unknown terminal {terminal_id}" + ); + } + + transaction.execute("DELETE FROM resource_agent_projection_rebuild_changes", [])?; + transaction.execute("DELETE FROM resource_agent_projections", [])?; + for (terminal_id, result_json, _) in &values { + transaction.execute( + "INSERT INTO resource_agent_projections( + terminal_id, result_json, committed_revision + ) VALUES(?1, ?2, ?3)", + params![terminal_id.as_str(), result_json, committed_revision], + )?; + } + store_agent_projection_journal_cursor( + transaction, + u64::try_from(committed_revision).context("agent projection revision is negative")?, + )?; + note_agent_projection_journal_candidate( + transaction, + u64::try_from(committed_revision).context("agent projection revision is negative")?, + )?; + clear_agent_projection_journal_rebuild_target(transaction)?; + clear_agent_projection_journal_live_sequence(transaction)?; + Ok(values.into_iter().map(|(_, _, projection)| projection).collect()) +} + +fn reduced_agent_values( + state: &Value, + session_id: &SessionPublicId, + committed_revision: u64, +) -> anyhow::Result> { + let values = state + .get("session_snapshot") + .and_then(Value::as_object) + .and_then(|snapshot| snapshot.get("agents")) + .and_then(Value::as_array) + .context("reduced journal state omitted session_snapshot.agents")?; + let committed_revision = + i64::try_from(committed_revision).context("agent projection revision exceeds SQLite")?; + let mut terminals = HashSet::with_capacity(values.len()); + values + .iter() + .map(|value| { + let terminal_id = value + .get("terminal_id") + .and_then(Value::as_str) + .context("reduced agent projection omitted terminal_id") + .and_then(|value| TerminalPublicId::parse(value).map_err(Into::into))?; + anyhow::ensure!( + terminals.insert(terminal_id.clone()), + "reduced journal state contains duplicate agent terminal {terminal_id}" + ); + let result_json = canonical_json(value)?; + let projection = public_projection_store::decode_agent_projection( + &result_json, + &terminal_id, + session_id, + committed_revision, + )?; + Ok((terminal_id, result_json, projection)) + }) + .collect() +} + fn agent_projection_rebuild_active(connection: &Connection) -> anyhow::Result { Ok(resource_store::resource_agent_generation_backfill_pending(connection)? || prejournal_projection_migration_cursor(connection)?.is_some() @@ -1834,4 +1990,3 @@ fn encode_lower_hex(bytes: &[u8]) -> String { } encoded } - diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/journal_extensions.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/journal_extensions.rs index 978e322f1ee..5e43be73a79 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/journal_extensions.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/journal_extensions.rs @@ -221,13 +221,15 @@ pub struct JournalCheckpoint { pub created_at_ms: u64, } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize)] pub(crate) struct JournalCheckpointSummary { pub checkpoint_id: String, + #[serde(serialize_with = "serialize_decimal")] pub source_sequence: u64, pub reducer_version: u32, pub content_refs: Vec, pub sha256: String, + #[serde(serialize_with = "serialize_decimal")] pub created_at_ms: u64, } @@ -450,9 +452,15 @@ fn ensure_built_in_agent_producer(transaction: &Transaction<'_>) -> anyhow::Resu let manifest = crate::agent_hooks::built_in_agent_producer_manifest(); let manifest_json = canonical_json(&serde_json::to_value(&manifest)?)?; transaction.execute( - "INSERT OR IGNORE INTO journal_producers( + "INSERT INTO journal_producers( producer_id, namespace, manifest_version, manifest_json, installed_at_ms - ) VALUES(?1, ?2, ?3, ?4, ?5)", + ) VALUES(?1, ?2, ?3, ?4, ?5) + ON CONFLICT(producer_id) DO UPDATE SET + namespace = excluded.namespace, + manifest_version = excluded.manifest_version, + manifest_json = excluded.manifest_json, + installed_at_ms = excluded.installed_at_ms + WHERE journal_producers.manifest_version < excluded.manifest_version", params![ manifest.producer_id, manifest.namespace, @@ -461,13 +469,15 @@ fn ensure_built_in_agent_producer(transaction: &Transaction<'_>) -> anyhow::Resu i64::try_from(unix_epoch_ms()?)?, ], )?; - let installed = transaction.query_row( - "SELECT manifest_json FROM journal_producers WHERE producer_id = ?1", + let (installed_version, installed) = transaction.query_row( + "SELECT manifest_version, manifest_json + FROM journal_producers WHERE producer_id = ?1", [crate::AGENT_HOOK_PRODUCER_ID], - |row| row.get::<_, String>(0), + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), )?; anyhow::ensure!( - serde_json::from_str::(&installed)? == manifest, + installed_version == i64::from(manifest.manifest_version) + && serde_json::from_str::(&installed)? == manifest, "reserved cmux agent producer manifest does not match this binary" ); Ok(()) @@ -2114,6 +2124,126 @@ impl WorkspaceRegistry { }) } + /// Applies the reduced checkpoint projection and records the restore + /// receipt in one writer transaction. The receipt and head checks happen + /// before the reduced state is written, and are repeated inside the + /// transaction to fence a concurrent restore or journal append. + pub(crate) fn apply_journal_restore_state( + &mut self, + expected_head: u64, + state: &Value, + origin: &str, + idempotency_key: &str, + checkpoint_id: Option<&str>, + state_sha256: Option<&str>, + ) -> anyhow::Result<( + JournalRestoreCommit, + Vec, + Value, + )> { + validate_identifier("journal restore origin", origin)?; + validate_identifier("journal restore idempotency key", idempotency_key)?; + let fingerprint = journal_restore_request_fingerprint(checkpoint_id, state_sha256)?; + let tx = self.connection.transaction()?; + if let Some(journal) = operation_receipt( + &tx, + "session.journal.restore", + origin, + idempotency_key, + fingerprint.as_slice(), + )? { + let result = tx + .query_row( + "SELECT result_json + FROM journal_operation_receipts + WHERE operation = 'session.journal.restore' + AND origin = ?1 AND idempotency_key = ?2", + params![origin, idempotency_key], + |row| row.get::<_, String>(0), + ) + .map(|value| serde_json::from_str::(&value))??; + let checkpoint_id = result["checkpoint_id"].as_str().map(str::to_owned); + let state_sha256 = result["state_sha256"].as_str().map(str::to_owned); + return Ok(( + JournalRestoreCommit { checkpoint_id, state_sha256, journal }, + Vec::new(), + result, + )); + } + anyhow::ensure!( + journal_head(&tx)? == expected_head, + "journal head changed while preparing restore; preview the journal again and retry" + ); + let projections = + super::agent_projection_store::replace_agent_projections_from_reduced_state( + &tx, + state, + expected_head, + )?; + let now = unix_epoch_ms()?; + let session_id = transaction_session_id(&tx)?; + let subjects = vec![JournalSubject { kind: "session".into(), id: session_id }]; + let producer = JournalProducer { kind: "journal_admin".into(), id: origin.into() }; + let event_id = random_event_id("restore"); + let payload = json!({ + "format":"cmux.journal-restore.v1", + "checkpoint_id":checkpoint_id, + "state_sha256":state_sha256, + }); + let sequence = append_journal_record( + &tx, + &JournalAppend { + event_id: &event_id, + schema_version: 1, + kind: "journal.restore.applied", + class: JournalClass::State, + replay: JournalReplayPolicy::Required, + occurred_at_ms: now, + producer: &producer, + authority: None, + causation_id: None, + correlation_id: Some(idempotency_key), + causation_depth: 0, + subjects: &subjects, + sensitivity: JournalSensitivity::Metadata, + payload: &payload, + content: None, + resource_revision: None, + previous_resource_revision: None, + }, + )?; + let projection = + super::agent_projection_store::agent_projection_restore_status_for_transaction(&tx)?; + let result = json!({ + "restored":true, + "checkpoint_id":checkpoint_id, + "state_sha256":state_sha256, + "projection":projection, + "published_checkpoint":true, + "sequence":sequence.to_string(), + "event_id":event_id, + }); + insert_operation_receipt( + &tx, + "session.journal.restore", + origin, + idempotency_key, + fingerprint.as_slice(), + sequence, + &result, + )?; + tx.commit()?; + Ok(( + JournalRestoreCommit { + checkpoint_id: checkpoint_id.map(str::to_owned), + state_sha256: state_sha256.map(str::to_owned), + journal: JournalAppendCommit { sequence, event_id, replayed: false }, + }, + projections, + result, + )) + } + pub(crate) fn journal_checkpoints(&self) -> anyhow::Result> { let mut statement = self.connection.prepare( "SELECT checkpoint_id, source_sequence, reducer_version, content_refs_json, @@ -2619,6 +2749,20 @@ fn checkpoint_request_fingerprint() -> sha2::digest::Output { Sha256::digest(b"cmux.session-journal.checkpoint.create.v1") } +fn journal_restore_request_fingerprint( + checkpoint_id: Option<&str>, + state_sha256: Option<&str>, +) -> anyhow::Result> { + Ok(Sha256::digest( + canonical_json(&json!({ + "format":"cmux.session-journal.restore.v1", + "checkpoint_id":checkpoint_id, + "state_sha256":state_sha256, + }))? + .as_bytes(), + )) +} + fn transaction_session_id(transaction: &Transaction<'_>) -> anyhow::Result { transaction .query_row("SELECT value FROM meta WHERE key = 'session_public_id'", [], |row| row.get(0)) From 62fff760090f366d48c3a88d56a603948098be2b Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:14:59 -0700 Subject: [PATCH 4/5] test: define journal CLI contract --- cmux-tui/bindings/conformance/test_runner.py | 29 ++ cmux-tui/crates/cmux-tui/src/cli/command.rs | 16 ++ cmux-tui/crates/cmux-tui/src/main.rs | 21 ++ cmux-tui/crates/cmux-tui/tests/cli.rs | 267 ++++++++++++++++++ .../test_check_resource_api_boundary.py | 77 +++++ 5 files changed, 410 insertions(+) diff --git a/cmux-tui/bindings/conformance/test_runner.py b/cmux-tui/bindings/conformance/test_runner.py index 0fe1a3a5176..e5dbb908559 100644 --- a/cmux-tui/bindings/conformance/test_runner.py +++ b/cmux-tui/bindings/conformance/test_runner.py @@ -11,6 +11,7 @@ from runner import ( Adapter, AdapterSpec, + CLI_ONLY_JOURNAL_OPERATIONS, FIXTURES, LANGUAGES, MAX_STREAM_BYTES, @@ -163,6 +164,34 @@ def test_catalog_is_public_v2_and_has_expected_transported_operations( self.catalog["operations"]["terminal.wait_exit"]["class"], "read" ) + def test_journal_administration_is_explicitly_cli_only(self) -> None: + expected = frozenset( + { + "session.journal.append", + "session.journal.checkpoint.create", + "session.journal.checkpoint.list", + "session.journal.hook.list", + "session.journal.hook.put", + "session.journal.inspect", + "session.journal.list", + "session.journal.producer.list", + "session.journal.producer.put", + "session.journal.restore", + "session.journal.restore.preview", + "session.journal.segment.list", + "session.journal.segment.seal", + } + ) + self.assertEqual(CLI_ONLY_JOURNAL_OPERATIONS, expected) + catalog_admin = frozenset( + operation + for operation in self.catalog["operations"] + if operation.startswith("session.journal.") + and operation != "session.journal.subscribe" + ) + self.assertEqual(catalog_admin, CLI_ONLY_JOURNAL_OPERATIONS) + self.assertNotIn("session.journal.subscribe", CLI_ONLY_JOURNAL_OPERATIONS) + def test_fixtures_cover_every_requested_semantic(self) -> None: names = {case["name"] for case in self.fixtures["fake_cases"]} required = { diff --git a/cmux-tui/crates/cmux-tui/src/cli/command.rs b/cmux-tui/crates/cmux-tui/src/cli/command.rs index 0df1f8f163b..a6998934022 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/command.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/command.rs @@ -3570,6 +3570,22 @@ mod tests { } } + #[test] + fn journal_restore_rejects_expected_revision_when_catalog_omits_it() { + const SESSION: &str = "session_00000000000000000000000000000002"; + let restore = parse(&strings(&[ + "session", + SESSION, + "journal", + "restore", + "--idempotency-key", + "restore-key", + "--expected-revision", + "7", + ])); + assert!(restore.is_err()); + } + #[test] fn nullable_fields_have_explicit_clear_flags() { const CLIENT: &str = "client_00000000000000000000000000000003"; diff --git a/cmux-tui/crates/cmux-tui/src/main.rs b/cmux-tui/crates/cmux-tui/src/main.rs index 2a1d0489f81..26872bbf0bf 100644 --- a/cmux-tui/crates/cmux-tui/src/main.rs +++ b/cmux-tui/crates/cmux-tui/src/main.rs @@ -3264,6 +3264,15 @@ mod tests { assert!(usage.contains("--cloud-identity")); } + #[test] + fn startup_restore_is_enabled_by_default_and_can_be_disabled_once() { + let default = args(&[]); + assert!(!default.no_restore); + let skipped = args(&["--no-restore"]); + assert!(skipped.no_restore); + assert!(is_cli_invocation(&["--no-restore"].map(str::to_string))); + } + #[test] fn startup_help_localizes_the_machine_agent_entrypoint() { let english = usage_for_platform(localization::catalog_for_locale("en_US.UTF-8"), true); @@ -3360,6 +3369,18 @@ mod tests { } } + #[test] + fn provider_mode_rejects_no_restore_before_connecting() { + for provider in [ + ["--machine-provider", "/tmp/provider.sock", "--no-restore"].as_slice(), + ["--cloud", "--no-restore"].as_slice(), + ] { + let parsed = args(provider); + let error = validate_provider_process_args(&parsed).unwrap_err().to_string(); + assert!(error.contains("--no-restore"), "{provider:?}: {error}"); + } + } + #[test] fn existing_session_reuse_preserves_machine_client_mode() { let mut config = config::Config::default(); diff --git a/cmux-tui/crates/cmux-tui/tests/cli.rs b/cmux-tui/crates/cmux-tui/tests/cli.rs index 60a0d496fec..f70fb3d99ea 100644 --- a/cmux-tui/crates/cmux-tui/tests/cli.rs +++ b/cmux-tui/crates/cmux-tui/tests/cli.rs @@ -1293,6 +1293,211 @@ fn json_socket_request(path: &std::path::Path, request: serde_json::Value) -> se response["data"].clone() } +#[cfg(unix)] +fn journal_cli_fixture(args: &[&str], result: serde_json::Value) -> (Output, Option) { + let dir = unique_temp_dir("journal-cli-contract"); + fs::create_dir_all(&dir).unwrap(); + let socket = dir.join("journal.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + listener.set_nonblocking(true).unwrap(); + let (sender, receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match listener.accept() { + Ok((mut stream, _)) => { + let read_half = stream.try_clone().unwrap(); + let mut reader = BufReader::new(read_half); + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + break; + } + let request: serde_json::Value = serde_json::from_str(&line).unwrap(); + if request["cmd"] == "identify" { + let response = serde_json::json!({ + "id": request["id"], + "ok": true, + "data": {"capabilities": ["session-journal-v1"]} + }); + writeln!(stream, "{response}").unwrap(); + stream.flush().unwrap(); + continue; + } + let response = serde_json::json!({ + "protocol": "cmux.protocol/2", + "type": "response", + "id": request["id"], + "ok": true, + "result": result.clone(), + }); + writeln!(stream, "{response}").unwrap(); + stream.flush().unwrap(); + sender.send(request).unwrap(); + return; + } + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + return; + } + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("journal fixture listener failed: {error}"), + } + } + }); + + let output = Command::new(bin()) + .args(["--json", "--socket"]) + .arg(&socket) + .args(args) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + let request = receiver.recv_timeout(Duration::from_secs(6)).ok(); + server.join().unwrap(); + let _ = fs::remove_file(&socket); + let _ = fs::remove_dir_all(&dir); + (output, request) +} + +#[cfg(unix)] +#[test] +fn journal_cli_routes_list_and_inspect_and_preserves_decimal_strings() { + const SESSION: &str = "session_00000000000000000000000000000002"; + const HUGE: &str = "9007199254740993"; + + let (list, request) = journal_cli_fixture( + &["session", SESSION, "journal", "list"], + serde_json::json!({ + "head_sequence": HUGE, + "checkpoints": [{"source_sequence": HUGE, "created_at_ms": HUGE}], + "segments": [{"start_sequence": HUGE, "end_sequence": HUGE}], + "projection": { + "head_sequence": HUGE, + "cursor_sequence": HUGE, + "candidate_sequence": null, + "target_sequence": HUGE, + "pending": false, + }, + }), + ); + assert_success(&list); + let list_json = json_output(&list); + assert_eq!(request.as_ref().unwrap()["operation"], "session.journal.list"); + assert_eq!(list_json["head_sequence"].as_str(), Some(HUGE)); + assert_eq!(list_json["checkpoints"][0]["source_sequence"].as_str(), Some(HUGE)); + assert_eq!(list_json["projection"]["cursor_sequence"].as_str(), Some(HUGE)); + + let (inspect, request) = journal_cli_fixture( + &[ + "session", + SESSION, + "journal", + "inspect", + "--checkpoint", + "latest", + ], + serde_json::json!({ + "head_sequence": HUGE, + "checkpoint": {"source_sequence": HUGE}, + "preview": {"head_sequence": HUGE, "applied_required_records": HUGE}, + "projection": { + "head_sequence": HUGE, + "cursor_sequence": null, + "candidate_sequence": HUGE, + "target_sequence": null, + "pending": true, + }, + }), + ); + assert_success(&inspect); + let inspect_json = json_output(&inspect); + assert_eq!(request.as_ref().unwrap()["operation"], "session.journal.inspect"); + assert_eq!(request.as_ref().unwrap()["params"]["checkpoint"], "latest"); + assert_eq!(inspect_json["preview"]["head_sequence"].as_str(), Some(HUGE)); + assert_eq!(inspect_json["projection"]["candidate_sequence"].as_str(), Some(HUGE)); +} + +#[cfg(unix)] +#[test] +fn journal_restore_cli_requires_and_reuses_an_explicit_idempotency_key() { + const SESSION: &str = "session_00000000000000000000000000000002"; + const HUGE: &str = "9007199254740993"; + let restore_result = serde_json::json!({ + "value": { + "restored": true, + "checkpoint_id": "checkpoint_demo", + "state_sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "projection": { + "head_sequence": HUGE, + "cursor_sequence": HUGE, + "candidate_sequence": null, + "target_sequence": HUGE, + "pending": false, + }, + "published_checkpoint": true, + "sequence": HUGE, + "event_id": "event_restore", + }, + "generation": SESSION, + "revision": HUGE, + "replayed": false, + }); + let restore_args = [ + "session", + SESSION, + "journal", + "restore", + "--checkpoint", + "latest", + "--idempotency-key", + "restore-stable-key", + ]; + let (first, first_request) = journal_cli_fixture(&restore_args, restore_result.clone()); + assert_success(&first); + let (second, second_request) = journal_cli_fixture( + &restore_args, + serde_json::json!({ + "value": restore_result["value"], + "generation": SESSION, + "revision": HUGE, + "replayed": true, + }), + ); + assert_success(&second); + assert_eq!(first_request.as_ref().unwrap()["operation"], "session.journal.restore"); + assert_eq!(first_request.as_ref().unwrap()["params"]["checkpoint"], "latest"); + assert_eq!(first_request.as_ref().unwrap()["idempotency_key"], "restore-stable-key"); + assert_eq!(second_request.as_ref().unwrap()["idempotency_key"], "restore-stable-key"); + assert_eq!(json_output(&first)["value"]["sequence"].as_str(), Some(HUGE)); + assert_eq!(json_output(&second)["replayed"], true); +} + +#[test] +fn journal_restore_cli_rejects_missing_idempotency_key_before_connecting() { + let output = Command::new(bin()) + .args([ + "--json", + "--socket", + "/tmp/cmux-journal-contract-missing-key.sock", + "session", + "current", + "journal", + "restore", + "--checkpoint", + "latest", + ]) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + let error = json_error(&output); + assert_eq!(error["code"], "usage.invalid"); + assert!(error["message"].as_str().unwrap().contains("--idempotency-key")); +} + #[test] fn explicit_socket_keeps_state_in_platform_root() { let dir = unique_temp_dir("explicit-socket-durable-state"); @@ -3661,9 +3866,71 @@ fn help_uses_public_cmux_scopes_and_keeps_startup_options_discoverable() { assert!(startup.contains("--ws ")); assert!(startup.contains("--ws-token ")); assert!(startup.contains("--ws-insecure-bind")); + assert!(startup.contains("--no-restore Skip startup journal projection replay for this run.")); assert!(!startup.contains("cmux-tui")); } +#[test] +fn journal_help_lists_read_and_mutating_administration_paths() { + let output = Command::new(bin()) + .args(["session", "--help"]) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert_success(&output); + let help = String::from_utf8(output.stdout).unwrap(); + assert!(help.contains("cmux session journal list"), "{help}"); + assert!( + help.contains("cmux session journal inspect [--checkpoint latest|]"), + "{help}" + ); + assert!( + help.contains("cmux session journal restore [--checkpoint latest|] --idempotency-key "), + "{help}" + ); +} + +#[test] +fn no_restore_is_a_start_only_option_and_provider_modes_reject_it() { + let help = Command::new(bin()) + .args(["--no-restore", "--help"]) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert_success(&help); + + let attach = Command::new(bin()) + .args(["attach", "--no-restore"]) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert_eq!(attach.status.code(), Some(2)); + assert!( + String::from_utf8(attach.stderr) + .unwrap() + .contains("--no-restore applies only when starting a session") + ); + + for provider_args in [ + vec!["--machine-provider", "/tmp/provider.sock", "--no-restore"], + vec!["--no-restore", "--machine-provider-command", "provider", "--"], + vec!["--cloud", "--no-restore"], + ] { + let output = Command::new(bin()) + .args(&provider_args) + .env_remove("CMUX_TUI_SOCKET") + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2), "{provider_args:?}"); + assert!( + String::from_utf8(output.stderr) + .unwrap() + .contains("--no-restore"), + "{provider_args:?}" + ); + } +} + #[cfg(unix)] #[test] fn plugin_install_use_and_list_work_against_local_git_repo() { diff --git a/cmux-tui/scripts/test_check_resource_api_boundary.py b/cmux-tui/scripts/test_check_resource_api_boundary.py index bcc2e1f5de2..282a4e083f0 100644 --- a/cmux-tui/scripts/test_check_resource_api_boundary.py +++ b/cmux-tui/scripts/test_check_resource_api_boundary.py @@ -295,6 +295,26 @@ def matching_contract(tui: Path, operations: list[str] | None = None) -> None: class PublicBoundaryScanTests(unittest.TestCase): + def test_cli_only_facade_scan_catches_wire_and_enum_spellings(self) -> None: + self.assertTrue( + CHECKER._facade_exposes_operation( + 'const RESTORE = "session.journal.restore";', + "session.journal.restore", + ) + ) + self.assertTrue( + CHECKER._facade_exposes_operation( + "enum Operation { session_journal_restore }", + "session.journal.restore", + ) + ) + self.assertFalse( + CHECKER._facade_exposes_operation( + "enum Operation { session_journal_restore_preview }", + "session.journal.restore", + ) + ) + def test_raw_internal_and_manifest_generated_occurrences_are_allowed(self) -> None: with tempfile.TemporaryDirectory() as directory: tui = Path(directory) @@ -430,6 +450,63 @@ def test_live_typed_catalog_matches_every_registry(self) -> None: self.assertEqual(CHECKER.check_contracts(tui), []) + def test_journal_administration_is_cli_only_across_all_facades(self) -> None: + tui = SCRIPT.parents[1] + catalog = json.loads( + (tui / "spec/resource-operations-v2.json").read_text(encoding="utf-8") + ) + cli_only = { + "session.journal.append", + "session.journal.checkpoint.create", + "session.journal.checkpoint.list", + "session.journal.hook.list", + "session.journal.hook.put", + "session.journal.inspect", + "session.journal.list", + "session.journal.producer.list", + "session.journal.producer.put", + "session.journal.restore", + "session.journal.restore.preview", + "session.journal.segment.list", + "session.journal.segment.seal", + } + catalog_admin = { + operation + for operation in catalog["operations"] + if operation.startswith("session.journal.") + and operation != "session.journal.subscribe" + } + self.assertEqual(catalog_admin, cli_only) + self.assertNotIn("session.journal.subscribe", cli_only) + + cli_spec = (tui / "spec/cli.md").read_text(encoding="utf-8") + self.assertIn("session journal list", cli_spec) + self.assertIn("session journal inspect", cli_spec) + self.assertIn("session journal restore", cli_spec) + self.assertIn("--no-restore", cli_spec) + bindings_spec = (tui / "spec/bindings.md").read_text(encoding="utf-8") + self.assertIn("CLI-only", bindings_spec) + for operation in sorted(cli_only): + self.assertIn(operation, bindings_spec) + + facade_registries = { + "rust": tui / "bindings/rust/src/resource/ops.rs", + "python": tui / "bindings/python/cmux/_operations.py", + "typescript": tui / "bindings/typescript/src/internal/operations.ts", + "go": tui / "bindings/go/internal/wirev2/operations.go", + "java": tui / "bindings/java/src/com/cmux/internal/Operations.java", + "cpp": tui / "bindings/cpp/include/cmux/resource.hpp", + "zig": tui / "bindings/zig/src/resource.zig", + } + for language, path in facade_registries.items(): + source = path.read_text(encoding="utf-8") + exposed = {operation for operation in cli_only if operation in source} + self.assertEqual( + exposed, + set(), + f"{language} facade gained a typed journal administration method", + ) + def test_live_selector_contract_allows_direct_ids_and_rejects_wrong_parents_first(self) -> None: tui = SCRIPT.parents[1] catalog = json.loads( From 1167e7f2b32e66756726fc9245005be526a9f26e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:38:47 -0700 Subject: [PATCH 5/5] fix: wire journal CLI contract and boundary metadata --- cmux-tui/bindings/ERGONOMICS.md | 6 +- cmux-tui/bindings/conformance/runner.py | 19 +++- cmux-tui/bindings/cpp/.cmux-resource-api.json | 11 ++- cmux-tui/bindings/go/.cmux-resource-api.json | 11 ++- .../bindings/java/.cmux-resource-api.json | 11 ++- .../bindings/python/.cmux-resource-api.json | 11 ++- .../bindings/rust/.cmux-resource-api.json | 11 ++- .../typescript/.cmux-resource-api.json | 11 ++- cmux-tui/bindings/zig/.cmux-resource-api.json | 11 ++- cmux-tui/crates/cmux-tui/src/cli.rs | 3 + cmux-tui/crates/cmux-tui/src/cli/command.rs | 71 +++++++++++++- cmux-tui/crates/cmux-tui/src/cli/wire.rs | 5 +- cmux-tui/crates/cmux-tui/src/main.rs | 27 +++++- .../scripts/check-resource-api-boundary.py | 95 +++++++++++++++++++ .../test_check_resource_api_boundary.py | 2 +- cmux-tui/spec/README.md | 2 +- cmux-tui/spec/bindings.md | 13 ++- cmux-tui/spec/cli.md | 12 +++ cmux-tui/spec/inventory.json | 3 + cmux-tui/spec/resource-api-v2.json | 7 ++ cmux-tui/spec/resource-api-v2.md | 13 ++- cmux-tui/spec/resource-operations-v2.json | 87 +++++++++++++++++ cmux-tui/spec/resource-operations-v2.md | 8 +- cmux-tui/spec/session-journal.md | 41 +++++--- 24 files changed, 451 insertions(+), 40 deletions(-) diff --git a/cmux-tui/bindings/ERGONOMICS.md b/cmux-tui/bindings/ERGONOMICS.md index 2a9c8438b6c..4984291e845 100644 --- a/cmux-tui/bindings/ERGONOMICS.md +++ b/cmux-tui/bindings/ERGONOMICS.md @@ -1,7 +1,9 @@ # SDK ergonomics findings The seven public SDKs expose handwritten resource handles over the reviewed -124-operation `cmux.protocol/2` catalog. The raw protocol inventory is a +127-operation `cmux.protocol/2` catalog. Journal administration remains a +trusted local CLI-only surface; the facades expose only +`session.journal.subscribe` for the journal. The raw protocol inventory is a separate compatibility surface with 101 commands and 46 events. Deterministic generation is limited to those private protocol-12 models under each package's explicit `raw` namespace. Consumers do not run a generator or install a @@ -64,7 +66,7 @@ implemented public behavior. None remains protocol work. ## Conformance evidence -The 124 public operations are the API inventory. The public fake-server +The 127 public operations are the API inventory. The public fake-server matrix is test inventory: 20 cases in each language, 140 cases total. It checks exact envelopes, decimal preservation, mutation replay, indeterminate effects, revision conflicts, duplicate-name ambiguity, bounded stream overflow, diff --git a/cmux-tui/bindings/conformance/runner.py b/cmux-tui/bindings/conformance/runner.py index 4885f65865b..2d6257dc273 100644 --- a/cmux-tui/bindings/conformance/runner.py +++ b/cmux-tui/bindings/conformance/runner.py @@ -29,7 +29,24 @@ BUILD = HERE / ".build" / "resource-v2" LANGUAGES = ("python", "typescript", "rust", "go", "java", "cpp", "zig") PROTOCOL = "cmux.protocol/2" -TRANSPORTED_OPERATION_COUNT = 124 +TRANSPORTED_OPERATION_COUNT = 127 +CLI_ONLY_JOURNAL_OPERATIONS = frozenset( + { + "session.journal.append", + "session.journal.checkpoint.create", + "session.journal.checkpoint.list", + "session.journal.hook.list", + "session.journal.hook.put", + "session.journal.inspect", + "session.journal.list", + "session.journal.producer.list", + "session.journal.producer.put", + "session.journal.restore", + "session.journal.restore.preview", + "session.journal.segment.list", + "session.journal.segment.seal", + } +) MAX_REQUEST_BYTES = 4 * 1024 * 1024 MAX_STREAM_MESSAGES = 256 MAX_STREAM_BYTES = 16 * 1024 * 1024 diff --git a/cmux-tui/bindings/cpp/.cmux-resource-api.json b/cmux-tui/bindings/cpp/.cmux-resource-api.json index 83c03a82ce5..ff996dfec87 100644 --- a/cmux-tui/bindings/cpp/.cmux-resource-api.json +++ b/cmux-tui/bindings/cpp/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "d92b567abdfc8d76382c65e222521b3ac76f7183216cb922bceb841475513509", + "catalog_sha256": "8e9cf1d1f5e1ed2819ccf410b6e1fa21f9aae502c2b93c59204aa0dc94804d66", "operations": { "agent.list": { "class": "read" @@ -190,12 +190,21 @@ "session.journal.hook.put": { "class": "mutation" }, + "session.journal.inspect": { + "class": "read" + }, + "session.journal.list": { + "class": "read" + }, "session.journal.producer.list": { "class": "read" }, "session.journal.producer.put": { "class": "mutation" }, + "session.journal.restore": { + "class": "mutation" + }, "session.journal.restore.preview": { "class": "read" }, diff --git a/cmux-tui/bindings/go/.cmux-resource-api.json b/cmux-tui/bindings/go/.cmux-resource-api.json index 83c03a82ce5..ff996dfec87 100644 --- a/cmux-tui/bindings/go/.cmux-resource-api.json +++ b/cmux-tui/bindings/go/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "d92b567abdfc8d76382c65e222521b3ac76f7183216cb922bceb841475513509", + "catalog_sha256": "8e9cf1d1f5e1ed2819ccf410b6e1fa21f9aae502c2b93c59204aa0dc94804d66", "operations": { "agent.list": { "class": "read" @@ -190,12 +190,21 @@ "session.journal.hook.put": { "class": "mutation" }, + "session.journal.inspect": { + "class": "read" + }, + "session.journal.list": { + "class": "read" + }, "session.journal.producer.list": { "class": "read" }, "session.journal.producer.put": { "class": "mutation" }, + "session.journal.restore": { + "class": "mutation" + }, "session.journal.restore.preview": { "class": "read" }, diff --git a/cmux-tui/bindings/java/.cmux-resource-api.json b/cmux-tui/bindings/java/.cmux-resource-api.json index 83c03a82ce5..ff996dfec87 100644 --- a/cmux-tui/bindings/java/.cmux-resource-api.json +++ b/cmux-tui/bindings/java/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "d92b567abdfc8d76382c65e222521b3ac76f7183216cb922bceb841475513509", + "catalog_sha256": "8e9cf1d1f5e1ed2819ccf410b6e1fa21f9aae502c2b93c59204aa0dc94804d66", "operations": { "agent.list": { "class": "read" @@ -190,12 +190,21 @@ "session.journal.hook.put": { "class": "mutation" }, + "session.journal.inspect": { + "class": "read" + }, + "session.journal.list": { + "class": "read" + }, "session.journal.producer.list": { "class": "read" }, "session.journal.producer.put": { "class": "mutation" }, + "session.journal.restore": { + "class": "mutation" + }, "session.journal.restore.preview": { "class": "read" }, diff --git a/cmux-tui/bindings/python/.cmux-resource-api.json b/cmux-tui/bindings/python/.cmux-resource-api.json index 83c03a82ce5..ff996dfec87 100644 --- a/cmux-tui/bindings/python/.cmux-resource-api.json +++ b/cmux-tui/bindings/python/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "d92b567abdfc8d76382c65e222521b3ac76f7183216cb922bceb841475513509", + "catalog_sha256": "8e9cf1d1f5e1ed2819ccf410b6e1fa21f9aae502c2b93c59204aa0dc94804d66", "operations": { "agent.list": { "class": "read" @@ -190,12 +190,21 @@ "session.journal.hook.put": { "class": "mutation" }, + "session.journal.inspect": { + "class": "read" + }, + "session.journal.list": { + "class": "read" + }, "session.journal.producer.list": { "class": "read" }, "session.journal.producer.put": { "class": "mutation" }, + "session.journal.restore": { + "class": "mutation" + }, "session.journal.restore.preview": { "class": "read" }, diff --git a/cmux-tui/bindings/rust/.cmux-resource-api.json b/cmux-tui/bindings/rust/.cmux-resource-api.json index 83c03a82ce5..ff996dfec87 100644 --- a/cmux-tui/bindings/rust/.cmux-resource-api.json +++ b/cmux-tui/bindings/rust/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "d92b567abdfc8d76382c65e222521b3ac76f7183216cb922bceb841475513509", + "catalog_sha256": "8e9cf1d1f5e1ed2819ccf410b6e1fa21f9aae502c2b93c59204aa0dc94804d66", "operations": { "agent.list": { "class": "read" @@ -190,12 +190,21 @@ "session.journal.hook.put": { "class": "mutation" }, + "session.journal.inspect": { + "class": "read" + }, + "session.journal.list": { + "class": "read" + }, "session.journal.producer.list": { "class": "read" }, "session.journal.producer.put": { "class": "mutation" }, + "session.journal.restore": { + "class": "mutation" + }, "session.journal.restore.preview": { "class": "read" }, diff --git a/cmux-tui/bindings/typescript/.cmux-resource-api.json b/cmux-tui/bindings/typescript/.cmux-resource-api.json index 83c03a82ce5..ff996dfec87 100644 --- a/cmux-tui/bindings/typescript/.cmux-resource-api.json +++ b/cmux-tui/bindings/typescript/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "d92b567abdfc8d76382c65e222521b3ac76f7183216cb922bceb841475513509", + "catalog_sha256": "8e9cf1d1f5e1ed2819ccf410b6e1fa21f9aae502c2b93c59204aa0dc94804d66", "operations": { "agent.list": { "class": "read" @@ -190,12 +190,21 @@ "session.journal.hook.put": { "class": "mutation" }, + "session.journal.inspect": { + "class": "read" + }, + "session.journal.list": { + "class": "read" + }, "session.journal.producer.list": { "class": "read" }, "session.journal.producer.put": { "class": "mutation" }, + "session.journal.restore": { + "class": "mutation" + }, "session.journal.restore.preview": { "class": "read" }, diff --git a/cmux-tui/bindings/zig/.cmux-resource-api.json b/cmux-tui/bindings/zig/.cmux-resource-api.json index 83c03a82ce5..ff996dfec87 100644 --- a/cmux-tui/bindings/zig/.cmux-resource-api.json +++ b/cmux-tui/bindings/zig/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "d92b567abdfc8d76382c65e222521b3ac76f7183216cb922bceb841475513509", + "catalog_sha256": "8e9cf1d1f5e1ed2819ccf410b6e1fa21f9aae502c2b93c59204aa0dc94804d66", "operations": { "agent.list": { "class": "read" @@ -190,12 +190,21 @@ "session.journal.hook.put": { "class": "mutation" }, + "session.journal.inspect": { + "class": "read" + }, + "session.journal.list": { + "class": "read" + }, "session.journal.producer.list": { "class": "read" }, "session.journal.producer.put": { "class": "mutation" }, + "session.journal.restore": { + "class": "mutation" + }, "session.journal.restore.preview": { "class": "read" }, diff --git a/cmux-tui/crates/cmux-tui/src/cli.rs b/cmux-tui/crates/cmux-tui/src/cli.rs index 4c6fa72d58f..82491546337 100644 --- a/cmux-tui/crates/cmux-tui/src/cli.rs +++ b/cmux-tui/crates/cmux-tui/src/cli.rs @@ -441,6 +441,9 @@ const SESSION_HELP_SUFFIX: &str = "\ cmux session journal hook put --manifest-json --idempotency-key cmux session journal checkpoint create --idempotency-key cmux session journal checkpoint list + cmux session journal list + cmux session journal inspect [--checkpoint latest|] + cmux session journal restore [--checkpoint latest|] --idempotency-key cmux session journal restore preview [--checkpoint latest|] cmux session journal segment list cmux session journal segment seal --through --idempotency-key diff --git a/cmux-tui/crates/cmux-tui/src/cli/command.rs b/cmux-tui/crates/cmux-tui/src/cli/command.rs index a6998934022..295a0aec6e0 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/command.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/command.rs @@ -413,6 +413,33 @@ fn parse_session( selectors.insert("session", "session", selector)?; request(ResourceOperation::SessionJournalCheckpointList, selectors, flags, Map::new()) } + [selector, "journal", "list"] => { + selectors.insert("session", "session", selector)?; + request(ResourceOperation::SessionJournalList, selectors, flags, Map::new()) + } + [selector, "journal", "inspect"] => { + selectors.insert("session", "session", selector)?; + let mut params = Map::new(); + if let Some(checkpoint) = flags.take("checkpoint") { + params.insert("checkpoint".into(), Value::String(checkpoint)); + } + request(ResourceOperation::SessionJournalInspect, selectors, flags, params) + } + [selector, "journal", "restore"] => { + selectors.insert("session", "session", selector)?; + let idempotency_key = required_idempotency_key(flags)?; + let mut params = Map::new(); + if let Some(checkpoint) = flags.take("checkpoint") { + params.insert("checkpoint".into(), Value::String(checkpoint)); + } + request_with_idempotency( + ResourceOperation::SessionJournalRestore, + selectors, + flags, + params, + idempotency_key, + ) + } [selector, "journal", "restore", "preview"] => { selectors.insert("session", "session", selector)?; let mut params = Map::new(); @@ -1711,6 +1738,27 @@ fn request( finalize_request(WireOperation::Typed(operation), Value::Object(params), flags) } +fn request_with_idempotency( + operation: ResourceOperation, + selectors: &Selectors, + flags: &mut Flags, + params: Map, + idempotency_key: String, +) -> Result { + let plan = request(operation, selectors, flags, params)?; + let CommandPlan::Protocol(mut plan) = plan else { + return Err(UsageError::new("journal restore did not produce a protocol request")); + }; + plan.idempotency_key = Some(idempotency_key); + Ok(CommandPlan::Protocol(plan)) +} + +fn required_idempotency_key(flags: &mut Flags) -> Result { + let key = flags.required("idempotency-key")?; + validate_idempotency_key(&key).map_err(|error| UsageError::new(error.message))?; + Ok(key) +} + const fn correlated_creation(operation: ResourceOperation) -> bool { matches!( operation, @@ -1793,6 +1841,7 @@ fn supports_expected_revision(operation: ResourceOperation) -> bool { | ResourceOperation::SessionJournalCheckpointCreate | ResourceOperation::SessionJournalHookPut | ResourceOperation::SessionJournalProducerPut + | ResourceOperation::SessionJournalRestore | ResourceOperation::SessionJournalSegmentSeal ) } @@ -3843,6 +3892,24 @@ mod tests { vec!["session", SESSION, "journal", "checkpoint", "list"], "session.journal.checkpoint.list", ), + (vec!["session", SESSION, "journal", "list"], "session.journal.list"), + ( + vec!["session", SESSION, "journal", "inspect", "--checkpoint", "latest"], + "session.journal.inspect", + ), + ( + vec![ + "session", + SESSION, + "journal", + "restore", + "--checkpoint", + "latest", + "--idempotency-key", + "restore-case", + ], + "session.journal.restore", + ), ( vec!["session", SESSION, "journal", "restore", "preview", "--checkpoint", "latest"], "session.journal.restore.preview", @@ -4387,9 +4454,9 @@ mod tests { (vec!["sidebar", "view", "reload", "--view", VIEW], "sidebar_view.reload"), ]; - assert_eq!(cases.len(), 117); + assert_eq!(cases.len(), 120); let catalog = operation_catalog(); - assert_eq!(catalog["operations"].as_object().unwrap().len(), 124); + assert_eq!(catalog["operations"].as_object().unwrap().len(), 127); let mut seen = std::collections::BTreeSet::new(); let mut covered_fields = BTreeMap::<&str, std::collections::BTreeSet>::new(); for (args, expected) in &cases { diff --git a/cmux-tui/crates/cmux-tui/src/cli/wire.rs b/cmux-tui/crates/cmux-tui/src/cli/wire.rs index cab817b7343..597045dc3bf 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/wire.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/wire.rs @@ -115,6 +115,9 @@ fn required_server_capability(plan: &RequestPlan) -> Option<&'static str> { | cmux_tui_core::resource::ResourceOperation::SessionJournalHookPut | cmux_tui_core::resource::ResourceOperation::SessionJournalCheckpointCreate | cmux_tui_core::resource::ResourceOperation::SessionJournalCheckpointList + | cmux_tui_core::resource::ResourceOperation::SessionJournalInspect + | cmux_tui_core::resource::ResourceOperation::SessionJournalList + | cmux_tui_core::resource::ResourceOperation::SessionJournalRestore | cmux_tui_core::resource::ResourceOperation::SessionJournalRestorePreview | cmux_tui_core::resource::ResourceOperation::SessionJournalSegmentList | cmux_tui_core::resource::ResourceOperation::SessionJournalSegmentSeal @@ -177,7 +180,7 @@ fn require_server_capability( }); let error = json!({ "code":"operation.unsupported", - "message":"resident session does not support journal subscriptions; restart it with this cmux-tui binary", + "message":"resident session does not support journal operations; restart it with this cmux-tui binary", "details":details, "retryable":false }); diff --git a/cmux-tui/crates/cmux-tui/src/main.rs b/cmux-tui/crates/cmux-tui/src/main.rs index 26872bbf0bf..f633fe8585b 100644 --- a/cmux-tui/crates/cmux-tui/src/main.rs +++ b/cmux-tui/crates/cmux-tui/src/main.rs @@ -383,6 +383,7 @@ START OPTIONS --terminal With attach, show only this terminal (use `cmux terminal list`). --state Durable session-state root (default: platform state dir). --ephemeral Keep workspace state in memory for this run only. + --no-restore Skip startup journal projection replay for this run. --machine-provider Use a dynamic machine provider Unix socket. --machine-provider-command [arg ...] -- @@ -445,6 +446,7 @@ struct Args { terminal: Option, state: Option, ephemeral: bool, + no_restore: bool, machine_provider: Option, machine_provider_command: Option>, cloud: bool, @@ -519,6 +521,7 @@ fn parse_args_result(args: impl IntoIterator) -> Result) -> Result out.ephemeral = true, + "--no-restore" => out.no_restore = true, "--headless" => out.headless = true, "--ws" => { out.ws = Some(args.next().ok_or_else(|| "--ws needs a value".to_string())?); @@ -758,6 +762,9 @@ fn parse_args_result(args: impl IntoIterator) -> Result anyhow::Result<()> { if args.ephemeral { conflicts.push("--ephemeral"); } + if args.no_restore { + conflicts.push("--no-restore"); + } if args.headless { conflicts.push("--headless"); } @@ -1317,6 +1327,7 @@ fn is_cli_invocation(args: &[String]) -> bool { | "--term" => index += 2, "--json" | "--jsonl" | "--quiet" => index += 1, "--ephemeral" + | "--no-restore" | "--cloud" | "--headless" | "--ws-insecure-bind" @@ -1850,23 +1861,29 @@ fn run_server( ); } let provider_management_pending = provider_management_listener.is_some(); + let restore_journal = !args.no_restore; let mux = match (state_root.as_deref(), provider_workspace_authority, provider_management_pending) { - (Some(root), Some(authority), false) => Mux::open_persistent_provider_managed( + (Some(root), Some(authority), false) => Mux::open_persistent_provider_managed_with_restore( args.session.clone(), surface_options, root, authority, + restore_journal, ), - (Some(root), None, true) => Mux::open_persistent_provider_managed_pending( + (Some(root), None, true) => Mux::open_persistent_provider_managed_pending_with_restore( args.session.clone(), surface_options, root, new_mux_generation()?, + restore_journal, + ), + (Some(root), None, false) => Mux::open_persistent_with_restore( + args.session.clone(), + surface_options, + root, + restore_journal, ), - (Some(root), None, false) => { - Mux::open_persistent(args.session.clone(), surface_options, root) - } (None, Some(authority), false) => { Ok(Mux::new_provider_managed(args.session.clone(), surface_options, authority)) } diff --git a/cmux-tui/scripts/check-resource-api-boundary.py b/cmux-tui/scripts/check-resource-api-boundary.py index 56d3e955f7e..e99161d56e7 100644 --- a/cmux-tui/scripts/check-resource-api-boundary.py +++ b/cmux-tui/scripts/check-resource-api-boundary.py @@ -183,6 +183,53 @@ class ScanRule: ) ALL_OPERATION_CLASSES = TRANSPORT_OPERATION_CLASSES + ("local",) STRUCTURAL_SCOPES = frozenset({"workspace", "screen", "pane", "tab"}) +# Journal administration is a trusted local CLI surface. The operations stay +# in the transport catalog and package descriptors for wire parity, but the +# seven handwritten SDK facades expose no typed methods for them. +CLI_ONLY_JOURNAL_OPERATIONS = frozenset( + { + "session.journal.append", + "session.journal.checkpoint.create", + "session.journal.checkpoint.list", + "session.journal.hook.list", + "session.journal.hook.put", + "session.journal.inspect", + "session.journal.list", + "session.journal.producer.list", + "session.journal.producer.put", + "session.journal.restore", + "session.journal.restore.preview", + "session.journal.segment.list", + "session.journal.segment.seal", + } +) +FACADE_OPERATION_REGISTRIES = { + "rust": "bindings/rust/src/resource/ops.rs", + "python": "bindings/python/cmux/_operations.py", + "typescript": "bindings/typescript/src/internal/operations.ts", + "go": "bindings/go/internal/wirev2/operations.go", + "java": "bindings/java/src/com/cmux/internal/Operations.java", + "cpp": "bindings/cpp/include/cmux/resource.hpp", + "zig": "bindings/zig/src/resource.zig", +} + + +def _facade_operation_tokens(operation: str) -> tuple[str, ...]: + """Return wire and common enum spellings for one operation.""" + snake = operation.replace(".", "_") + camel = "".join(part.capitalize() for part in operation.split(".")) + return (operation, snake, snake.upper(), camel) + + +def _facade_exposes_operation(source: str, operation: str) -> bool: + if operation in source: + return True + for token in _facade_operation_tokens(operation)[1:]: + if re.search(rf"(? list[Diagnostic]: "SDK descriptor operation set differs from the typed catalog", ) ) + + for language, relative_path in FACADE_OPERATION_REGISTRIES.items(): + facade_path = tui / relative_path + if not facade_path.exists(): + continue + try: + facade_text = facade_path.read_text(encoding="utf-8") + except OSError as error: + diagnostics.append( + Diagnostic( + facade_path, + 1, + 1, + "boundary.cli-only-journal", + f"{language} facade registry cannot be read: {error}", + ) + ) + continue + exposed = { + operation + for operation in CLI_ONLY_JOURNAL_OPERATIONS + if _facade_exposes_operation(facade_text, operation) + } + if exposed: + diagnostics.append( + Diagnostic( + facade_path, + 1, + 1, + "boundary.cli-only-journal", + f"{language} facade exposes CLI-only journal operations: {sorted(exposed)!r}", + ) + ) return sorted(set(diagnostics)) diff --git a/cmux-tui/scripts/test_check_resource_api_boundary.py b/cmux-tui/scripts/test_check_resource_api_boundary.py index 282a4e083f0..401894161f2 100644 --- a/cmux-tui/scripts/test_check_resource_api_boundary.py +++ b/cmux-tui/scripts/test_check_resource_api_boundary.py @@ -757,7 +757,7 @@ def test_live_catalog_counts_and_local_endpoint_scope_are_frozen(self) -> None: catalog = json.loads( (SCRIPT.parents[1] / "spec/resource-operations-v2.json").read_text(encoding="utf-8") ) - self.assertEqual(len(catalog["operations"]), 124) + self.assertEqual(len(catalog["operations"]), 127) self.assertEqual(len(catalog["local_operations"]), 6) self.assertEqual( set(catalog["types"]["MachineSnapshot"]["fields"]), diff --git a/cmux-tui/spec/README.md b/cmux-tui/spec/README.md index 6c15d08562e..d2741c51c82 100644 --- a/cmux-tui/spec/README.md +++ b/cmux-tui/spec/README.md @@ -12,7 +12,7 @@ high-level SDKs: | --- | --- | | [`resource-api-v2.md`](resource-api-v2.md) | IDs, selectors, envelopes, mutations, streams, limits, and lifecycle rules | | [`resource-api-v2.json`](resource-api-v2.json) | JSON Schema for request, response, and stream envelopes | -| [`resource-operations-v2.json`](resource-operations-v2.json) | Normative catalog of 124 transported and six local operations | +| [`resource-operations-v2.json`](resource-operations-v2.json) | Normative catalog of 127 transported and six local operations | | [`resource-operations-v2.schema.json`](resource-operations-v2.schema.json) | JSON Schema for the operation catalog | | [`resource-operations-v2.md`](resource-operations-v2.md) | Human-readable operation inventory | | [`cli.md`](cli.md) | Noun-first public CLI | diff --git a/cmux-tui/spec/bindings.md b/cmux-tui/spec/bindings.md index d7e4413e19f..8dd0cafad81 100644 --- a/cmux-tui/spec/bindings.md +++ b/cmux-tui/spec/bindings.md @@ -12,8 +12,19 @@ The split is deliberate: handwritten in each language. - Mechanical protocol-v12 models are generated deterministically and exposed only through `raw`. -- A catalog descriptor in every package proves that all 124 transported +- A catalog descriptor in every package proves that all 127 transported operations have the same class and wire name. +- Journal administration remains CLI-only. The 13 operations named in the + journal section of the operation catalog stay transport-described for wire + parity, but the seven typed facades expose only + `session.journal.subscribe` and do not add typed administration methods. + The CLI-only set is `session.journal.append`, + `session.journal.checkpoint.create`, `session.journal.checkpoint.list`, + `session.journal.hook.list`, `session.journal.hook.put`, + `session.journal.inspect`, `session.journal.list`, + `session.journal.producer.list`, `session.journal.producer.put`, + `session.journal.restore`, `session.journal.restore.preview`, + `session.journal.segment.list`, and `session.journal.segment.seal`. - The six sidebar plugin operations are local CLI/filesystem APIs. Transported SDK roots expose sidebar views, not plugin resource handles. diff --git a/cmux-tui/spec/cli.md b/cmux-tui/spec/cli.md index f7e819543f8..401b0e79273 100644 --- a/cmux-tui/spec/cli.md +++ b/cmux-tui/spec/cli.md @@ -64,6 +64,10 @@ remote-listener flags when the owning process also serves authenticated clients. Top-level remote commands and `remote-stop` remain compatibility aliases for one release cycle. +Startup restores journal-owned agent projections by default. Pass +`--no-restore` on a new session start to skip that replay for one invocation. +The option is not valid with `attach` or a machine provider. + ## Public grammar ```text @@ -153,6 +157,11 @@ the caller supplies `--idempotency-key`. The CLI sends one request and never retries a mutation. Mutations that support optimistic concurrency expose `--expected-revision`. +The noun-first journal restore route requires an explicit `--idempotency-key`. +This gives a retry from another process the same durable receipt. The raw +`cmux raw operation` transport route keeps its generic mutation behavior and +may generate a key when the caller does not provide one. + An explicit idempotency key contains 1 to 128 UTF-8 bytes, at least one Unicode scalar outside the Unicode `White_Space` property, and no Unicode `Cc` control scalar. Non-control whitespace is preserved when the key also @@ -208,6 +217,9 @@ session journal hook list session journal hook put --manifest-json --idempotency-key session journal checkpoint create --idempotency-key session journal checkpoint list +session journal list +session journal inspect [--checkpoint latest|] +session journal restore [--checkpoint latest|] --idempotency-key session journal restore preview [--checkpoint latest|] session journal segment list session journal segment seal --through --idempotency-key diff --git a/cmux-tui/spec/inventory.json b/cmux-tui/spec/inventory.json index 9b237aee782..efce2110f46 100644 --- a/cmux-tui/spec/inventory.json +++ b/cmux-tui/spec/inventory.json @@ -66,8 +66,11 @@ "session.journal.checkpoint.list", "session.journal.hook.list", "session.journal.hook.put", + "session.journal.inspect", + "session.journal.list", "session.journal.producer.list", "session.journal.producer.put", + "session.journal.restore", "session.journal.restore.preview", "session.journal.segment.list", "session.journal.segment.seal", diff --git a/cmux-tui/spec/resource-api-v2.json b/cmux-tui/spec/resource-api-v2.json index a69c85c3fad..9c624840ef4 100644 --- a/cmux-tui/spec/resource-api-v2.json +++ b/cmux-tui/spec/resource-api-v2.json @@ -101,8 +101,11 @@ "session.journal.checkpoint.list", "session.journal.hook.list", "session.journal.hook.put", + "session.journal.inspect", + "session.journal.list", "session.journal.producer.list", "session.journal.producer.put", + "session.journal.restore", "session.journal.restore.preview", "session.journal.segment.list", "session.journal.segment.seal", @@ -185,6 +188,8 @@ "session.get", "session.journal.checkpoint.list", "session.journal.hook.list", + "session.journal.inspect", + "session.journal.list", "session.journal.producer.list", "session.journal.restore.preview", "session.journal.segment.list", @@ -241,6 +246,7 @@ "session.journal.checkpoint.create", "session.journal.hook.put", "session.journal.producer.put", + "session.journal.restore", "session.journal.segment.seal", "session.open", "session.reload_config", @@ -350,6 +356,7 @@ "session.journal.checkpoint.create", "session.journal.hook.put", "session.journal.producer.put", + "session.journal.restore", "session.journal.segment.seal", "session.open", "session.reload_config", diff --git a/cmux-tui/spec/resource-api-v2.md b/cmux-tui/spec/resource-api-v2.md index fd074ff73a9..76b24d0c6ea 100644 --- a/cmux-tui/spec/resource-api-v2.md +++ b/cmux-tui/spec/resource-api-v2.md @@ -332,6 +332,13 @@ stream queues remain bounded independently. `session.journal.subscribe` is the append-only feed. Omitting a cursor tails from the captured head; `start:"beginning"` replays retained history first. +`session.journal.list` inventories checkpoints, sealed segments, the journal +head, and derived projection progress. `session.journal.inspect` combines that +progress with a selected checkpoint and its pure restore preview. The preview +never mutates the live session. `session.journal.restore` is the explicit +projection mutation and records a durable restore receipt after a fully +reducible preview; unsupported required records fail closed with repair +instructions. `follow:false` bounds replay to the head captured when the stream opens and ends it with reason `completed`; this is the primitive behind CLI `journal read`. @@ -341,7 +348,7 @@ never alter cursor order. Unix clients may read through `sensitive`; remote clients are capped at `metadata`, receive redacted authority and causal fields, and may run regex only on kind or subjects. A slow subscriber receives `stream_end` with reason `gap` and the last recoverable cursor, then reconnects -from that cursor. Producer, hook, checkpoint, restore-preview, and segment +from that cursor. Producer, hook, checkpoint, inspect, list, restore, restore-preview, and segment operations require a trusted local connection. Terminal and browser attachments have independent decimal-string sequences. @@ -398,8 +405,8 @@ defines the catalog format. Unknown parameter and result fields are rejected. | Class | Operations | | --- | --- | -| read | `agent.list`, `browser.get`, `browser.list`, `client.get`, `client.list`, `frontend_projection.get`, `machine.get`, `machine.list`, `notification.list`, `pairing_request.list`, `pane.get`, `pane.list`, `pane.neighbor.get`, `screen.get`, `screen.layout.export`, `screen.list`, `session.creation.resolve`, `session.get`, `session.journal.checkpoint.list`, `session.journal.hook.list`, `session.journal.producer.list`, `session.journal.restore.preview`, `session.journal.segment.list`, `session.list`, `session.ping`, `session.snapshot`, `sidebar_view.get`, `tab.get`, `tab.list`, `terminal.copy`, `terminal.get`, `terminal.history.read`, `terminal.list`, `terminal.process.get`, `terminal.screen.read`, `terminal.state.read`, `terminal.wait`, `terminal.wait_exit`, `workspace.get`, `workspace.list` | -| mutation | `agent.report`, `browser.activate`, `browser.back`, `browser.close`, `browser.forward`, `browser.input.key`, `browser.input.mouse`, `browser.input.text`, `browser.input.wheel`, `browser.navigate`, `browser.reload`, `frontend_projection.put`, `notification.create`, `pairing_request.resolve`, `pane.close`, `pane.create`, `pane.focus`, `pane.focus_direction`, `pane.rename`, `pane.run`, `pane.split`, `pane.split_ratio.set`, `pane.swap`, `pane.viewport_width.set`, `pane.zoom`, `screen.close`, `screen.create`, `screen.focus`, `screen.layout.undo`, `screen.rename`, `session.journal.append`, `session.journal.checkpoint.create`, `session.journal.hook.put`, `session.journal.producer.put`, `session.journal.segment.seal`, `session.open`, `session.reload_config`, `session.shutdown`, `session.terminal_defaults.update`, `session.window.title.clear`, `session.window.title.set`, `sidebar_view.ensure`, `sidebar_view.input`, `sidebar_view.reload`, `sidebar_view.resize`, `tab.close`, `tab.create_browser`, `tab.create_terminal`, `tab.focus`, `tab.move`, `tab.rename`, `terminal.close`, `terminal.history.clear`, `terminal.input.focus`, `terminal.input.keys`, `terminal.input.mouse`, `terminal.input.write`, `terminal.move`, `terminal.project`, `terminal.viewport.scroll`, `workspace.close`, `workspace.create`, `workspace.focus`, `workspace.layout.apply`, `workspace.move`, `workspace.rename`, `workspace.run` | +| read | `agent.list`, `browser.get`, `browser.list`, `client.get`, `client.list`, `frontend_projection.get`, `machine.get`, `machine.list`, `notification.list`, `pairing_request.list`, `pane.get`, `pane.list`, `pane.neighbor.get`, `screen.get`, `screen.layout.export`, `screen.list`, `session.creation.resolve`, `session.get`, `session.journal.checkpoint.list`, `session.journal.hook.list`, `session.journal.inspect`, `session.journal.list`, `session.journal.producer.list`, `session.journal.restore.preview`, `session.journal.segment.list`, `session.list`, `session.ping`, `session.snapshot`, `sidebar_view.get`, `tab.get`, `tab.list`, `terminal.copy`, `terminal.get`, `terminal.history.read`, `terminal.list`, `terminal.process.get`, `terminal.screen.read`, `terminal.state.read`, `terminal.wait`, `terminal.wait_exit`, `workspace.get`, `workspace.list` | +| mutation | `agent.report`, `browser.activate`, `browser.back`, `browser.close`, `browser.forward`, `browser.input.key`, `browser.input.mouse`, `browser.input.text`, `browser.input.wheel`, `browser.navigate`, `browser.reload`, `frontend_projection.put`, `notification.create`, `pairing_request.resolve`, `pane.close`, `pane.create`, `pane.focus`, `pane.focus_direction`, `pane.rename`, `pane.run`, `pane.split`, `pane.split_ratio.set`, `pane.swap`, `pane.viewport_width.set`, `pane.zoom`, `screen.close`, `screen.create`, `screen.focus`, `screen.layout.undo`, `screen.rename`, `session.journal.append`, `session.journal.checkpoint.create`, `session.journal.hook.put`, `session.journal.producer.put`, `session.journal.restore`, `session.journal.segment.seal`, `session.open`, `session.reload_config`, `session.shutdown`, `session.terminal_defaults.update`, `session.window.title.clear`, `session.window.title.set`, `sidebar_view.ensure`, `sidebar_view.input`, `sidebar_view.reload`, `sidebar_view.resize`, `tab.close`, `tab.create_browser`, `tab.create_terminal`, `tab.focus`, `tab.move`, `tab.rename`, `terminal.close`, `terminal.history.clear`, `terminal.input.focus`, `terminal.input.keys`, `terminal.input.mouse`, `terminal.input.write`, `terminal.move`, `terminal.project`, `terminal.viewport.scroll`, `workspace.close`, `workspace.create`, `workspace.focus`, `workspace.layout.apply`, `workspace.move`, `workspace.rename`, `workspace.run` | | stream_open | `browser.attach`, `session.events`, `session.journal.subscribe`, `sidebar_view.attach`, `terminal.attach` | | connection_control | `browser.viewer.release`, `browser.viewer.resize`, `client.cell_pixels.set`, `client.detach`, `client.metadata.update`, `client.sizing.release`, `client.sizing.set`, `request.cancel`, `stream.cancel`, `terminal.renderer_grant.create`, `terminal.viewer.release`, `terminal.viewer.resize` | | local | `sidebar_plugin.install`, `sidebar_plugin.list`, `sidebar_plugin.remove`, `sidebar_plugin.update`, `sidebar_plugin.use`, `sidebar_plugin.use_builtin` | diff --git a/cmux-tui/spec/resource-operations-v2.json b/cmux-tui/spec/resource-operations-v2.json index a4216b9f929..7ec9f4e6c68 100644 --- a/cmux-tui/spec/resource-operations-v2.json +++ b/cmux-tui/spec/resource-operations-v2.json @@ -745,6 +745,50 @@ }, "extra": false }, + "JournalProjectionRestoreStatus": { + "kind": "object", + "fields": { + "head_sequence": {"required": true, "type": {"kind": "primitive", "name": "decimal"}}, + "cursor_sequence": {"required": true, "type": {"kind": "nullable", "value": {"kind": "primitive", "name": "decimal"}}}, + "candidate_sequence": {"required": true, "type": {"kind": "nullable", "value": {"kind": "primitive", "name": "decimal"}}}, + "target_sequence": {"required": true, "type": {"kind": "nullable", "value": {"kind": "primitive", "name": "decimal"}}}, + "pending": {"required": true, "type": {"kind": "primitive", "name": "boolean"}} + }, + "extra": false + }, + "JournalListResult": { + "kind": "object", + "fields": { + "head_sequence": {"required": true, "type": {"kind": "primitive", "name": "decimal"}}, + "checkpoints": {"required": true, "type": {"kind": "array", "min_items": 0, "max_items": 4096, "items": {"kind": "ref", "name": "JournalCheckpointSummary"}}}, + "segments": {"required": true, "type": {"kind": "array", "min_items": 0, "max_items": 4096, "items": {"kind": "ref", "name": "JournalSegment"}}}, + "projection": {"required": true, "type": {"kind": "ref", "name": "JournalProjectionRestoreStatus"}} + }, + "extra": false + }, + "JournalInspectResult": { + "kind": "object", + "fields": { + "head_sequence": {"required": true, "type": {"kind": "primitive", "name": "decimal"}}, + "checkpoint": {"required": true, "type": {"kind": "nullable", "value": {"kind": "ref", "name": "JournalCheckpointSummary"}}}, + "preview": {"required": true, "type": {"kind": "nullable", "value": {"kind": "ref", "name": "JournalRestorePreview"}}}, + "projection": {"required": true, "type": {"kind": "ref", "name": "JournalProjectionRestoreStatus"}} + }, + "extra": false + }, + "JournalRestoreResult": { + "kind": "object", + "fields": { + "restored": {"required": true, "type": {"kind": "primitive", "name": "boolean"}}, + "checkpoint_id": {"required": true, "type": {"kind": "nullable", "value": {"kind": "primitive", "name": "string", "min_length": 1, "max_length": 128}}}, + "state_sha256": {"required": true, "type": {"kind": "nullable", "value": {"kind": "primitive", "name": "string", "min_length": 64, "max_length": 64}}}, + "projection": {"required": true, "type": {"kind": "ref", "name": "JournalProjectionRestoreStatus"}}, + "published_checkpoint": {"required": true, "type": {"kind": "primitive", "name": "boolean"}}, + "sequence": {"required": true, "type": {"kind": "primitive", "name": "decimal"}}, + "event_id": {"required": true, "type": {"kind": "primitive", "name": "string", "min_length": 1, "max_length": 128}} + }, + "extra": false + }, "JournalUnsupportedReplayRecord": { "kind": "object", "fields": { @@ -8998,6 +9042,34 @@ "validation.invalid" ] }, + "session.journal.inspect": { + "class": "read", + "idempotency": "forbidden", + "target": "session", + "ancestors": ["machine"], + "params": { + "selectors": {"machine": "required", "session": "required"}, + "fields": { + "checkpoint": {"required": false, "type": {"kind": "primitive", "name": "string", "min_length": 1, "max_length": 128}} + }, + "extra": false + }, + "result": {"kind": "ref", "name": "JournalInspectResult"}, + "errors": ["operation.failed", "operation.unsupported", "selector.ambiguous", "selector.invalid", "selector.not_found", "validation.invalid"] + }, + "session.journal.list": { + "class": "read", + "idempotency": "forbidden", + "target": "session", + "ancestors": ["machine"], + "params": { + "selectors": {"machine": "required", "session": "required"}, + "fields": {}, + "extra": false + }, + "result": {"kind": "ref", "name": "JournalListResult"}, + "errors": ["operation.failed", "operation.unsupported", "selector.ambiguous", "selector.invalid", "selector.not_found", "validation.invalid"] + }, "session.journal.producer.list": { "class": "read", "idempotency": "forbidden", @@ -9041,6 +9113,21 @@ "validation.invalid" ] }, + "session.journal.restore": { + "class": "mutation", + "idempotency": "required", + "target": "session", + "ancestors": ["machine"], + "params": { + "selectors": {"machine": "required", "session": "required"}, + "fields": { + "checkpoint": {"required": false, "type": {"kind": "primitive", "name": "string", "min_length": 1, "max_length": 128}} + }, + "extra": false + }, + "result": {"kind": "apply", "name": "MutationResult", "arguments": [{"kind": "ref", "name": "JournalRestoreResult"}]}, + "errors": ["idempotency.conflict", "operation.failed", "operation.unsupported", "selector.ambiguous", "selector.invalid", "selector.not_found", "validation.invalid"] + }, "session.journal.restore.preview": { "class": "read", "idempotency": "forbidden", diff --git a/cmux-tui/spec/resource-operations-v2.md b/cmux-tui/spec/resource-operations-v2.md index 8306d945bbd..d7a48f29346 100644 --- a/cmux-tui/spec/resource-operations-v2.md +++ b/cmux-tui/spec/resource-operations-v2.md @@ -6,14 +6,14 @@ selectors, fields, results, errors, constraints, or stream types. ## Transported operations -`cmux.protocol/2` transports 124 operations for exactly one local mux +`cmux.protocol/2` transports 127 operations for exactly one local mux session. Cross-machine aggregation and provider lifecycle require a later broker protocol. | Class | Count | Semantics | | --- | ---: | --- | -| `read` | 40 | Reads state and forbids an idempotency key | -| `mutation` | 67 | Requires an idempotency key and returns a mutation result | +| `read` | 42 | Reads state and forbids an idempotency key | +| `mutation` | 68 | Requires an idempotency key and returns a mutation result | | `stream_open` | 5 | Opens a connection-owned typed stream | | `connection_control` | 12 | Changes only connection-local state | @@ -38,7 +38,7 @@ correlation, and idempotency metadata. | `pane` | 14 | `pane.close`, `pane.create`, `pane.focus`, `pane.focus_direction`, `pane.get`, `pane.list`, `pane.neighbor.get`, `pane.rename`, `pane.run`, `pane.split`, `pane.split_ratio.set`, `pane.swap`, `pane.viewport_width.set`, `pane.zoom` | | `request` | 1 | `request.cancel` | | `screen` | 8 | `screen.close`, `screen.create`, `screen.focus`, `screen.get`, `screen.layout.export`, `screen.layout.undo`, `screen.list`, `screen.rename` | -| `session` | 23 | `session.creation.resolve`, `session.events`, `session.get`, `session.journal.append`, `session.journal.checkpoint.create`, `session.journal.checkpoint.list`, `session.journal.hook.list`, `session.journal.hook.put`, `session.journal.producer.list`, `session.journal.producer.put`, `session.journal.restore.preview`, `session.journal.segment.list`, `session.journal.segment.seal`, `session.journal.subscribe`, `session.list`, `session.open`, `session.ping`, `session.reload_config`, `session.shutdown`, `session.snapshot`, `session.terminal_defaults.update`, `session.window.title.clear`, `session.window.title.set` | +| `session` | 26 | `session.creation.resolve`, `session.events`, `session.get`, `session.journal.append`, `session.journal.checkpoint.create`, `session.journal.checkpoint.list`, `session.journal.hook.list`, `session.journal.hook.put`, `session.journal.inspect`, `session.journal.list`, `session.journal.producer.list`, `session.journal.producer.put`, `session.journal.restore`, `session.journal.restore.preview`, `session.journal.segment.list`, `session.journal.segment.seal`, `session.journal.subscribe`, `session.list`, `session.open`, `session.ping`, `session.reload_config`, `session.shutdown`, `session.snapshot`, `session.terminal_defaults.update`, `session.window.title.clear`, `session.window.title.set` | | `sidebar_view` | 6 | `sidebar_view.attach`, `sidebar_view.ensure`, `sidebar_view.get`, `sidebar_view.input`, `sidebar_view.reload`, `sidebar_view.resize` | | `stream` | 1 | `stream.cancel` | | `tab` | 8 | `tab.close`, `tab.create_browser`, `tab.create_terminal`, `tab.focus`, `tab.get`, `tab.list`, `tab.move`, `tab.rename` | diff --git a/cmux-tui/spec/session-journal.md b/cmux-tui/spec/session-journal.md index 90548e91706..09faf955d07 100644 --- a/cmux-tui/spec/session-journal.md +++ b/cmux-tui/spec/session-journal.md @@ -12,7 +12,11 @@ browser lifecycle; continuous terminal output and geometry; frontend focus, viewport, and geometry observations; frontend projections; explicit agent reports; and normalized native agent-hook observations. A pure restoration reducer can preview the state reconstructed from a checkpoint and its tail. -Verified root ownership leases and live application of a restored model remain +Persistent startup restores journal-owned agent projections by default. The +per-invocation `--no-restore` startup option skips that replay without +disabling journal storage or explicit journal commands. Explicit restore +applies a fully reducible agent projection and records a durable receipt. +Verified root ownership leases and full live resource/process adoption remain pending. ## Invariants @@ -567,9 +571,15 @@ Restoration starts from the newest compatible checkpoint, then applies every required record through the target sequence. The implemented v1 reducer is pure and deterministic. A checkpoint contains its source sequence, reducer version, public session projection, producer and hook manifests, and terminal -content references. `journal restore preview` returns the projected model, -digest, applied count, and every unsupported required record. It never mutates -the live session. +content references. `journal list` inventories the journal head, checkpoints, +sealed segments, and derived projection progress. `journal inspect` combines +that progress with a selected checkpoint and its pure restore preview. +`journal restore preview` returns the projected model, digest, applied count, +and every unsupported required record. It never mutates the live session. +`journal restore` is the explicit mutation counterpart. It applies the reduced +agent projection only after a fully reducible preview, records a durable +idempotent receipt, and leaves the append-only journal intact. Unsupported +required records fail closed with repair instructions. External effects are not repeated during replay. Their recorded outcomes materialize state. Live-process adoption separately verifies process identity @@ -596,21 +606,27 @@ writer instead of waiting without a limit. The transaction continues to own the registry and its atomic idempotency receipt until SQLite returns; a later retry therefore observes the committed receipt or performs the request once. -Live restoration will consume this inert complete model. Process adoption, -fresh process spawning, browser reconnect, and agent resume are explicit -post-replay actions with their own journal outcomes. A partially supported -record fails with a compatibility error or becomes an explicit degraded -projection. It is never silently discarded. +The reducer's complete model remains the source for future full-session +adoption. Current startup replay and explicit restore apply the journal-owned +agent projection only; they do not spawn processes, reconnect browsers, or +resume agents. Those actions remain explicit post-replay outcomes. A partially +supported record fails with a compatibility error or becomes an explicit +degraded projection. It is never silently discarded. -Create and inspect checkpoints, preview a reduction, then seal a covered -prefix: +List journal state, inspect and preview a checkpoint, explicitly restore a +fully reducible projection, then seal a covered prefix: ```bash cmux --session main --jsonl session current journal checkpoint create \ --idempotency-key checkpoint-1 cmux --session main --jsonl session current journal checkpoint list +cmux --session main --jsonl session current journal list +cmux --session main --jsonl session current journal inspect \ + --checkpoint latest cmux --session main --jsonl session current journal restore preview \ --checkpoint latest +cmux --session main --jsonl session current journal restore \ + --checkpoint latest --idempotency-key restore-1 cmux --session main --jsonl session current journal segment seal \ --through --idempotency-key segment-1 cmux --session main --jsonl session current journal segment list @@ -667,8 +683,9 @@ redaction markers are reserved for a later storage version. | Schema-validated producer manifests and ingress | Implemented in storage v1 | | Hook dispatcher and delivery projections | Implemented in storage v1 | | Checkpoint writer and restoration preview reducer | Implemented in storage v1 | +| Agent projection startup replay and explicit restore | Implemented in storage v1 | | Checkpoint-aligned immutable segments | Implemented in storage v1 | -| Live restoration application | Pending | +| Full live resource/process restoration | Pending | The in-memory `MuxEvent` broadcaster remains a lossy presentation mechanism. It may wake consumers after commit, but it is never a journal or restoration