From 7abef7616aa8e909515bfb10efce8048bb8ea4f3 Mon Sep 17 00:00:00 2001 From: reacherwu Date: Thu, 17 Sep 2026 22:03:14 +0800 Subject: [PATCH 1/2] Prevent silent memory loss and expose validated recovery operations --- ARCHITECTURE_PLAN.md | 10 + P1_REGRESSION.log | 92 ++++ P1_REGRESSION.md | 17 + crates/continuum-cli/Cargo.toml | 1 + crates/continuum-cli/src/json.rs | 96 +++- crates/continuum-cli/src/main.rs | 673 ++++++++++++----------- crates/continuum-cli/src/mcp.rs | 89 +++ crates/continuum-cli/src/memory_api.rs | 139 +++++ crates/continuum-cli/src/memory_cli.rs | 57 ++ crates/continuum-cli/tests/errors_api.rs | 434 +++++++++++++++ crates/continuum-core/Cargo.toml | 1 + crates/continuum-core/src/lib.rs | 7 +- crates/continuum-core/src/lock.rs | 129 ++--- crates/continuum-core/src/persistence.rs | 616 ++++++++------------- crates/continuum-core/src/recovery.rs | 85 +++ crates/continuum-core/tests/storage.rs | 184 +++++++ 16 files changed, 1785 insertions(+), 845 deletions(-) create mode 100644 ARCHITECTURE_PLAN.md create mode 100644 P1_REGRESSION.log create mode 100644 P1_REGRESSION.md create mode 100644 crates/continuum-cli/src/mcp.rs create mode 100644 crates/continuum-cli/src/memory_api.rs create mode 100644 crates/continuum-cli/src/memory_cli.rs create mode 100644 crates/continuum-cli/tests/errors_api.rs create mode 100644 crates/continuum-core/src/recovery.rs create mode 100644 crates/continuum-core/tests/storage.rs diff --git a/ARCHITECTURE_PLAN.md b/ARCHITECTURE_PLAN.md new file mode 100644 index 0000000..aa56863 --- /dev/null +++ b/ARCHITECTURE_PLAN.md @@ -0,0 +1,10 @@ +# Reliability hardening plan +Baseline: upstream 4c9df39. Work only in this branch; do not install, modify Hermes configuration or touch user memory. +Keep Rust core + local CLI/MCP. No service, cloud, Docker or new AI provider is required for this native library repair. +Priority 1: propagate storage errors through CLI nonzero exit and MCP isError; never synthesize empty state on corruption. +Priority 2: OS advisory lock held throughout mutation, stable lock inode, no elapsed-age stealing; internal unlocked helpers. Rust std file locking preferred if toolchain supports it; declare MSRV. +Priority 3: durable atomic save, parent sync, temporary-file cleanup, bounded decoding/validation, explicit backup/check/restore with overwrite confirmation. Preserve legacy readable snapshots and document integrity limits. +Priority 4: runner stores observations rather than causal facts, correlates only identical command/project, avoids raw log/argument retention and does not auto-ingest fixes. +Priority 5: machine-readable memory CLI and verified native MCP, strict inputs and complete text output. +Priority 6: honest limitations and reproducible isolated load/recall measurements, no universal recall or memory-size claims. +Acceptance: real files and subprocesses, failure propagation, restart, lock contention/long holder/process exit, multi-process RMW, recover/invalid-backup, legacy roundtrip, Unicode/full text. No offensive reproduction or live-data tests. Independent defensive review, fix/re-review, measured stress evidence, branch PR (not direct main push). diff --git a/P1_REGRESSION.log b/P1_REGRESSION.log new file mode 100644 index 0000000..ac42b80 --- /dev/null +++ b/P1_REGRESSION.log @@ -0,0 +1,92 @@ +$ cargo test -p continuum-cli --test errors_api --locked --offline init_preserves_existing_memory_and_configuration -- --exact + Compiling continuum-core v0.1.0 (/Users/mymac/continuum-hardening/crates/continuum-core) + Compiling continuum-cli v0.1.0 (/Users/mymac/continuum-hardening/crates/continuum-cli) + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.42s + Running tests/errors_api.rs (target/debug/deps/errors_api-06085ea841f4ed0b) + +running 1 test +test init_preserves_existing_memory_and_configuration ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 14 filtered out; finished in 0.54s + + +exit=0 +$ cargo test -p continuum-cli --test errors_api --locked --offline json_options_preserve_literal_text_and_paths -- --exact + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.00s + Running tests/errors_api.rs (target/debug/deps/errors_api-06085ea841f4ed0b) + +running 1 test +test json_options_preserve_literal_text_and_paths ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 14 filtered out; finished in 0.58s + + +exit=0 +$ cargo test -p continuum-cli --test errors_api --locked --offline unicode_surrogates_follow_json_semantics -- --exact + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.00s + Running tests/errors_api.rs (target/debug/deps/errors_api-06085ea841f4ed0b) + +running 1 test +test unicode_surrogates_follow_json_semantics ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 14 filtered out; finished in 0.86s + + +exit=0 +$ cargo test -p continuum-cli --test errors_api --locked --offline storage_errors_are_nonzero_and_never_claim_success -- --exact + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.00s + Running tests/errors_api.rs (target/debug/deps/errors_api-06085ea841f4ed0b) + +running 1 test +test storage_errors_are_nonzero_and_never_claim_success ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 14 filtered out; finished in 0.46s + + +exit=0 +$ cargo test -p continuum-cli --test errors_api --locked --offline mcp_storage_failures_are_tool_errors_without_empty_fallback -- --exact + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.00s + Running tests/errors_api.rs (target/debug/deps/errors_api-06085ea841f4ed0b) + +running 1 test +test mcp_storage_failures_are_tool_errors_without_empty_fallback ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 14 filtered out; finished in 0.43s + + +exit=0 +$ cargo test -p continuum-cli --test errors_api --locked --offline recovery_check_backup_and_confirmed_restore -- --exact + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.00s + Running tests/errors_api.rs (target/debug/deps/errors_api-06085ea841f4ed0b) + +running 1 test +test recovery_check_backup_and_confirmed_restore ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 14 filtered out; finished in 0.55s + + +exit=0 +$ cargo test -p continuum-core --test storage --locked --offline + Compiling continuum-core v0.1.0 (/Users/mymac/continuum-hardening/crates/continuum-core) + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.13s + Running tests/storage.rs (target/debug/deps/storage-58850af08a49fd5f) + +running 13 tests +test child_worker ... ok +test incomplete_snapshot_is_not_reinitialized ... ok +test save_creates_parent_and_failed_mutation_preserves_snapshot ... ok +test save_rejects_inconsistent_state_without_overwrite ... ok +test failed_atomic_publication_cleans_own_temp ... ok +test legacy_empty_snapshot_roundtrips_byte_exact ... ok +test invalid_normal_state_is_rejected_before_save ... ok +test ordinary_truncation_and_trailing_data_fail_closed ... ok +test stable_lock_times_out_and_reacquires ... ok +test backup_check_restore_are_validated_and_no_clobber ... ok +test process_rmw_has_no_lost_updates ... ok +test thread_rmw_has_no_lost_updates ... ok +test long_holder_is_not_stolen_and_killed_holder_releases ... ok + +test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.18s + + +exit=0 diff --git a/P1_REGRESSION.md b/P1_REGRESSION.md new file mode 100644 index 0000000..9e8489c --- /dev/null +++ b/P1_REGRESSION.md @@ -0,0 +1,17 @@ +# P1 regression checkpoint + +No further code edits or broad lint/workspace runs at this checkpoint. + +Exact independent-review findings: +- [x] P1-1: init uses the transactional writer lock and preserves existing memory. Test: init_preserves_existing_memory_and_configuration. Repeated init is tested; simultaneous init specifically is not separately tested. +- [x] P1-2: output-option parsing preserves literal text/path arguments. Test: json_options_preserve_literal_text_and_paths. +- [x] P1-3: JSON decodes valid UTF-16 surrogate pairs. Test: unicode_surrogates_follow_json_semantics. + +Dependent reliability regressions: +- [x] CLI/MCP storage failures never claim success: storage_errors_are_nonzero_and_never_claim_success, mcp_storage_failures_are_tool_errors_without_empty_fallback. +- [x] Recovery CLI: recovery_check_backup_and_confirmed_restore. +- [x] Storage integration suite: stable OS lock, long holder/process exit, process/thread RMW, invalid-state preservation, atomic publication and backup/restore. + +Raw execution evidence: P1_REGRESSION.log. Each invocation must exit zero before committing. +Scope: CLI memory/MCP/error handling, JSON compatibility, core lock/persistence/recovery, minimum Rust version, and related tests. Runner changes are excluded from this checkpoint commit and remain uncommitted. No installation, Hermes configuration changes, or live-memory access. +This checkpoint is not a production-readiness certification or a performance benchmark. CTNM0001 has no checksum; old and new lock protocols must not run concurrently. diff --git a/crates/continuum-cli/Cargo.toml b/crates/continuum-cli/Cargo.toml index fb3145e..dc13f7a 100644 --- a/crates/continuum-cli/Cargo.toml +++ b/crates/continuum-cli/Cargo.toml @@ -2,6 +2,7 @@ name = "continuum-cli" version = "0.1.0" edition = "2024" +rust-version = "1.89" [dependencies] continuum-core = { path = "../continuum-core" } diff --git a/crates/continuum-cli/src/json.rs b/crates/continuum-cli/src/json.rs index 6b742db..e95e41f 100644 --- a/crates/continuum-cli/src/json.rs +++ b/crates/continuum-cli/src/json.rs @@ -93,9 +93,20 @@ impl JsonValue { pub fn to_json_string(&self) -> String { match self { JsonValue::Null => "null".to_string(), - JsonValue::Bool(b) => if *b { "true".to_string() } else { "false".to_string() }, + JsonValue::Bool(b) => { + if *b { + "true".to_string() + } else { + "false".to_string() + } + } JsonValue::Number(n) => { - if n.fract() == 0.0 && !n.is_infinite() && !n.is_nan() && *n >= (i64::MIN as f64) && *n <= (i64::MAX as f64) { + if n.fract() == 0.0 + && !n.is_infinite() + && !n.is_nan() + && *n >= (i64::MIN as f64) + && *n <= (i64::MAX as f64) + { format!("{}", *n as i64) } else { format!("{}", n) @@ -186,7 +197,10 @@ impl<'a> JsonParser<'a> { return Err(format!( "Unexpected trailing characters at pos {}: '{}'", parser.pos, - parser.chars[parser.pos..].iter().take(20).collect::() + parser.chars[parser.pos..] + .iter() + .take(20) + .collect::() )); } Ok(val) @@ -216,7 +230,9 @@ impl<'a> JsonParser<'a> { fn parse_value(&mut self) -> Result { self.skip_whitespace(); - let c = self.peek().ok_or_else(|| "Unexpected EOF while parsing JSON value".to_string())?; + let c = self + .peek() + .ok_or_else(|| "Unexpected EOF while parsing JSON value".to_string())?; match c { 'n' => self.parse_null(), 't' | 'f' => self.parse_bool(), @@ -224,7 +240,10 @@ impl<'a> JsonParser<'a> { '[' => self.parse_array(), '{' => self.parse_object(), '-' | '0'..='9' => self.parse_number(), - other => Err(format!("Unexpected character '{}' at pos {}", other, self.pos)), + other => Err(format!( + "Unexpected character '{}' at pos {}", + other, self.pos + )), } } @@ -261,6 +280,18 @@ impl<'a> JsonParser<'a> { } } + fn parse_hex_quad(&mut self) -> Result { + let mut code = 0; + for _ in 0..4 { + let digit = self + .next_char() + .and_then(|c| c.to_digit(16)) + .ok_or_else(|| "Expected four hexadecimal digits in Unicode escape".to_string())?; + code = (code << 4) | digit; + } + Ok(code) + } + fn parse_string(&mut self) -> Result { if self.next_char() != Some('"') { return Err(format!("Expected '\"' at pos {}", self.pos)); @@ -270,7 +301,9 @@ impl<'a> JsonParser<'a> { match c { '"' => return Ok(s), '\\' => { - let esc = self.next_char().ok_or_else(|| "Unexpected EOF after escape".to_string())?; + let esc = self + .next_char() + .ok_or_else(|| "Unexpected EOF after escape".to_string())?; match esc { '"' => s.push('"'), '\\' => s.push('\\'), @@ -281,12 +314,18 @@ impl<'a> JsonParser<'a> { 'r' => s.push('\r'), 't' => s.push('\t'), 'u' => { - let mut hex = String::with_capacity(4); - for _ in 0..4 { - hex.push(self.next_char().ok_or_else(|| "Unexpected EOF in \\u escape".to_string())?); + let mut code = self.parse_hex_quad()?; + if (0xd800..=0xdbff).contains(&code) { + if self.next_char() != Some('\\') || self.next_char() != Some('u') { + return Err("High surrogate requires a low surrogate escape" + .to_string()); + } + let low = self.parse_hex_quad()?; + if !(0xdc00..=0xdfff).contains(&low) { + return Err("Invalid low surrogate".to_string()); + } + code = 0x10000 + ((code - 0xd800) << 10) + (low - 0xdc00); } - let code = u32::from_str_radix(&hex, 16) - .map_err(|e| format!("Invalid hex escape \\u{}: {}", hex, e))?; let decoded = char::from_u32(code) .ok_or_else(|| format!("Invalid unicode code point: {:x}", code))?; s.push(decoded); @@ -325,8 +364,8 @@ impl<'a> JsonParser<'a> { } } } - if let Some(c) = self.peek() { - if c == 'e' || c == 'E' { + if let Some(c) = self.peek() + && (c == 'e' || c == 'E') { self.pos += 1; if self.peek() == Some('+') || self.peek() == Some('-') { self.pos += 1; @@ -339,9 +378,10 @@ impl<'a> JsonParser<'a> { } } } - } let raw: String = self.chars[start..self.pos].iter().collect(); - let num: f64 = raw.parse().map_err(|e| format!("Failed to parse number '{}': {}", raw, e))?; + let num: f64 = raw + .parse() + .map_err(|e| format!("Failed to parse number '{}': {}", raw, e))?; Ok(JsonValue::Number(num)) } @@ -435,7 +475,10 @@ mod tests { assert_eq!(parse_json("false").unwrap(), JsonValue::Bool(false)); assert_eq!(parse_json("42").unwrap(), JsonValue::Number(42.0)); assert_eq!(parse_json("-17.5").unwrap(), JsonValue::Number(-17.5)); - assert_eq!(parse_json("\"hello world\"").unwrap(), JsonValue::String("hello world".to_string())); + assert_eq!( + parse_json("\"hello world\"").unwrap(), + JsonValue::String("hello world".to_string()) + ); } #[test] @@ -463,9 +506,24 @@ mod tests { assert_eq!(v.get("jsonrpc").unwrap().as_str().unwrap(), "2.0"); assert_eq!(v.get("id").unwrap().to_raw_id_string(), "\"msg_01J8K9\""); assert_eq!(v.get("method").unwrap().as_str().unwrap(), "tools/call"); - assert_eq!(v.get_path(&["params", "name"]).unwrap().as_str().unwrap(), "continuum_recall"); - assert_eq!(v.get_path(&["params", "arguments", "query"]).unwrap().as_str().unwrap(), "database pool cap"); - assert_eq!(v.get_path(&["params", "arguments", "top_k"]).unwrap().as_u64().unwrap(), 5); + assert_eq!( + v.get_path(&["params", "name"]).unwrap().as_str().unwrap(), + "continuum_recall" + ); + assert_eq!( + v.get_path(&["params", "arguments", "query"]) + .unwrap() + .as_str() + .unwrap(), + "database pool cap" + ); + assert_eq!( + v.get_path(&["params", "arguments", "top_k"]) + .unwrap() + .as_u64() + .unwrap(), + 5 + ); } #[test] diff --git a/crates/continuum-cli/src/main.rs b/crates/continuum-cli/src/main.rs index 606ada0..b59426c 100644 --- a/crates/continuum-cli/src/main.rs +++ b/crates/continuum-cli/src/main.rs @@ -1,30 +1,40 @@ mod hook; mod json; +mod mcp; +mod memory_api; +mod memory_cli; mod runner; +use memory_api::{ApiError, Result}; +use continuum_core::{ContinuumConfig, ContinuumEngine, RealTextEmbedder, SemanticCausalBridge}; use std::time::Instant; -use continuum_core::{ - ContinuumConfig, ContinuumEngine, RealTextEmbedder, SemanticCausalBridge, -}; fn print_help() { println!("Continuum: Continuous Temporal Intelligence Engine (100% Native Rust)"); println!("Usage:"); println!(" continuum init [path] Initialize .continuum memory workspace"); println!(" continuum remember Save critical constraint or decision to memory"); - println!(" continuum recall [k] Retrieve causal memory in < 100 μs"); + println!( + " continuum recall [k] Retrieve causal memory in < 100 μs" + ); println!(" continuum run Run command with autonomous failure/fix causal learning"); println!(" continuum hook install [path] Install automatic Git post-commit memory hook"); - println!(" continuum hook uninstall [path] Remove Git post-commit memory hook"); + println!( + " continuum hook uninstall [path] Remove Git post-commit memory hook" + ); println!(" continuum mcp Launch Model Context Protocol (MCP) server for IDEs"); println!(" continuum upgrade View Pro tier subscription & token savings ROI"); println!(" continuum demo Run full native scenario demo"); println!(" continuum memory sync [snapshot] Ingest conversation transcript into bounded state"); println!(" continuum memory query [snapshot] [k] Query causal memory in < 100 μs native Rust"); - println!(" continuum memory ingest [snapshot] Ingest a single event into memory"); + println!( + " continuum memory ingest [snapshot] Ingest a single event into memory" + ); println!(" continuum memory inspect [snapshot] Inspect active memory state and slots"); println!(" continuum snapshot [filepath] Save state snapshot to disk"); - println!(" continuum restore [filepath] Load and inspect snapshot from disk"); + println!( + " continuum restore [filepath] Load and inspect snapshot from disk" + ); println!(" continuum benchmark Run engine throughput & latency benchmark"); println!(" continuum stats Display memory and engine invariants"); println!(" continuum help Display this help message"); @@ -77,11 +87,28 @@ fn run_demo_aiops() { let ingest_duration = t0.elapsed(); let throughput = 3000.0 / ingest_duration.as_secs_f64(); - println!("-> Ingested 3,000 events in {:?} ({:.0} events/sec)", ingest_duration, throughput); - println!("-> Active Memory Slots: {} / 750 invariant (Physical O(K) flat memory)", engine.total_slots()); - let in_hot = engine.hot_memory.records.iter().any(|r| r.event_id == root_id); - let in_cold = engine.cold_memory.records.iter().any(|r| r.event_id == root_id); - println!("-> Event {}: in_hot={}, in_cold={}", root_id, in_hot, in_cold); + println!( + "-> Ingested 3,000 events in {:?} ({:.0} events/sec)", + ingest_duration, throughput + ); + println!( + "-> Active Memory Slots: {} / 750 invariant (Physical O(K) flat memory)", + engine.total_slots() + ); + let in_hot = engine + .hot_memory + .records + .iter() + .any(|r| r.event_id == root_id); + let in_cold = engine + .cold_memory + .records + .iter() + .any(|r| r.event_id == root_id); + println!( + "-> Event {}: in_hot={}, in_cold={}", + root_id, in_hot, in_cold + ); println!("\n[2/3] Terminal Incident Occurs at t=3000:"); let symptom_query = "Cluster incident: 504 Gateway Timeout in checkout service caused by database connection pool exhausted"; @@ -95,9 +122,16 @@ fn run_demo_aiops() { let matches = engine.query(&v_bridged, 10); let query_lat = t_query.elapsed(); - println!("-> Retrospective Query Latency: {:?} (< 100 μs native execution!)", query_lat); + println!( + "-> Retrospective Query Latency: {:?} (< 100 μs native execution!)", + query_lat + ); let all_matches = engine.query(&v_bridged, 750); - if let Some((pos, m)) = all_matches.iter().enumerate().find(|(_, m)| m.event_id == root_id) { + if let Some((pos, m)) = all_matches + .iter() + .enumerate() + .find(|(_, m)| m.event_id == root_id) + { println!(" -> Target ID {} is at Rank #{}: Score={:.4} (sim={:.4}, state={:.4}, temp={:.4}, prov={:.4}) | Provenance: {}", root_id, pos + 1, m.revision_score, m.components.sim, m.components.state_compat, m.components.temporal_compat, m.components.provenance_compat, m.provenance); } @@ -108,10 +142,23 @@ fn run_demo_aiops() { if is_target { found_root = true; } - let tag = if is_target { "✅ [TRUE ROOT CAUSE]" } else { " [BACKGROUND/ALERT]" }; + let tag = if is_target { + "✅ [TRUE ROOT CAUSE]" + } else { + " [BACKGROUND/ALERT]" + }; let prov_snippet = safe_truncate(&m.provenance, 60); - println!(" #{:2} {} Event ID: {:4} | Score: {:.4} (sim={:.4}, state={:.4}, temp={:.4}) | {}", - i + 1, tag, m.event_id, m.revision_score, m.components.sim, m.components.state_compat, m.components.temporal_compat, prov_snippet); + println!( + " #{:2} {} Event ID: {:4} | Score: {:.4} (sim={:.4}, state={:.4}, temp={:.4}) | {}", + i + 1, + tag, + m.event_id, + m.revision_score, + m.components.sim, + m.components.state_compat, + m.components.temporal_compat, + prov_snippet + ); } if found_root { @@ -185,8 +232,15 @@ fn run_demo_persona() { } let ingest_dur = t0.elapsed(); - println!("-> Ingested 2,000 turns in {:?} ({:.0} turns/sec)", ingest_dur, 2000.0 / ingest_dur.as_secs_f64()); - println!("-> Active Memory Slots: {} / 500 bounded slots invariant", engine.total_slots()); + println!( + "-> Ingested 2,000 turns in {:?} ({:.0} turns/sec)", + ingest_dur, + 2000.0 / ingest_dur.as_secs_f64() + ); + println!( + "-> Active Memory Slots: {} / 500 bounded slots invariant", + engine.total_slots() + ); println!("\n[2/3] User Prompt at Turn 2000:"); let query_text = "User prompt: Book a surprise 5-course tasting dinner tonight at the new gourmet bistro in town, check dietary safety restrictions"; @@ -200,12 +254,27 @@ fn run_demo_persona() { println!("-> Query Latency: {:?} (< 100 μs execution!)", q_lat); for (i, m) in matches.iter().enumerate() { let is_target = m.event_id == root_id; - let tag = if is_target { "✅ [LIFE CONSTRAINT]" } else { " [RECENT CHATTER]" }; + let tag = if is_target { + "✅ [LIFE CONSTRAINT]" + } else { + " [RECENT CHATTER]" + }; let prov = safe_truncate(&m.provenance, 60); - println!(" #{} {} ID: {:4} | Causal Score: {:.4} | {}", i + 1, tag, m.event_id, m.revision_score, prov); + println!( + " #{} {} ID: {:4} | Causal Score: {:.4} | {}", + i + 1, + tag, + m.event_id, + m.revision_score, + prov + ); } - if matches.first().map(|m| m.event_id == root_id).unwrap_or(false) { + if matches + .first() + .map(|m| m.event_id == root_id) + .unwrap_or(false) + { println!("\n🎉 VERDICT: SUCCESS! Lethal allergy constraint preserved at Rank #1 across 2,000 turns in bounded 500 slots!"); } else { println!("\n❌ VERDICT: Failed to rank constraint at Rank #1."); @@ -262,9 +331,20 @@ fn run_demo_github() { println!("-> Query Latency: {:?}", lat); for (i, m) in matches.iter().enumerate() { let is_target = m.event_id == root_id; - let tag = if is_target { "✅ [ROOT CAUSE ACTION]" } else { " [ERROR MESSAGE]" }; + let tag = if is_target { + "✅ [ROOT CAUSE ACTION]" + } else { + " [ERROR MESSAGE]" + }; let prov = safe_truncate(&m.provenance, 60); - println!(" #{} {} ID: {:2} | Score: {:.4} | {}", i + 1, tag, m.event_id, m.revision_score, prov); + println!( + " #{} {} ID: {:2} | Score: {:.4} | {}", + i + 1, + tag, + m.event_id, + m.revision_score, + prov + ); } if matches.iter().any(|m| m.event_id == root_id) { @@ -299,33 +379,48 @@ fn run_demo_persistence() { let text = if t == critical_id { critical_text.to_string() } else { - format!("Event #{t}: Worker heartbeat status=healthy memory_used={}MB latency=12ms", 100 + (t % 50)) + format!( + "Event #{t}: Worker heartbeat status=healthy memory_used={}MB latency=12ms", + 100 + (t % 50) + ) }; let emb = embedder.embed(&text); engine.step(&emb, t as f64, &text); } - println!("-> Ingested 500 events. Active slots: {} / 150", engine.total_slots()); + println!( + "-> Ingested 500 events. Active slots: {} / 150", + engine.total_slots() + ); // Pre-shutdown query let query_text = "Compliance check: audit log retention and truncation policy"; let q_emb = embedder.embed(query_text); let pre_matches = engine.query(&q_emb, 150); let pre_crit_rank = pre_matches.iter().position(|m| m.event_id == critical_id); - println!("-> Pre-shutdown: Target event #{} is at Rank #{:?}", critical_id, pre_crit_rank.map(|r| r + 1)); + println!( + "-> Pre-shutdown: Target event #{} is at Rank #{:?}", + critical_id, + pre_crit_rank.map(|r| r + 1) + ); println!("\n[Phase 2/4] Simulating computer shutdown: Writing memory state to disk..."); let temp_dir = std::env::temp_dir(); let snapshot_file = temp_dir.join("continuum_reboot_test.state"); let t_save = Instant::now(); - engine.save_to_file(&snapshot_file).expect("Failed to persist snapshot"); + engine + .save_to_file(&snapshot_file) + .expect("Failed to persist snapshot"); let save_lat = t_save.elapsed(); let file_metadata = std::fs::metadata(&snapshot_file).expect("Snapshot file missing"); let file_kb = file_metadata.len() as f64 / 1024.0; println!("-> Snapshot written to disk in {:?}", save_lat); - println!("-> Snapshot file size on disk: {:.2} KB (< 300 KB ultra-compact!)", file_kb); + println!( + "-> Snapshot file size on disk: {:.2} KB (< 300 KB ultra-compact!)", + file_kb + ); println!("\n[Phase 3/4] SIMULATING COMPLETE POWER OFF / PROCESS REBOOT:"); drop(engine); // All RAM evaporated! @@ -333,21 +428,36 @@ fn run_demo_persistence() { println!("\n[Phase 4/4] Computer powers on: Restoring Continuum engine from disk..."); let t_load = Instant::now(); - let restored_engine = ContinuumEngine::load_from_file(&snapshot_file).expect("Failed to restore engine"); + let restored_engine = + ContinuumEngine::load_from_file(&snapshot_file).expect("Failed to restore engine"); let load_lat = t_load.elapsed(); let _ = std::fs::remove_file(&snapshot_file); println!("-> Engine restored from disk in {:?}", load_lat); - println!("-> Restored memory slots: {} / 150 (Step count: {})", restored_engine.total_slots(), restored_engine.step_count); + println!( + "-> Restored memory slots: {} / 150 (Step count: {})", + restored_engine.total_slots(), + restored_engine.step_count + ); // Post-reboot query let post_matches = restored_engine.query(&q_emb, 150); let post_crit_rank = post_matches.iter().position(|m| m.event_id == critical_id); - println!("-> Post-reboot: Target event #{} is at Rank #{:?}", critical_id, post_crit_rank.map(|r| r + 1)); + println!( + "-> Post-reboot: Target event #{} is at Rank #{:?}", + critical_id, + post_crit_rank.map(|r| r + 1) + ); println!("\nTop-3 Candidates After Reboot:"); for (i, m) in post_matches.iter().take(3).enumerate() { - println!(" #{} Event ID: {:3} | Score: {:.4} | {}", i + 1, m.event_id, m.revision_score, safe_truncate(&m.provenance, 60)); + println!( + " #{} Event ID: {:3} | Score: {:.4} | {}", + i + 1, + m.event_id, + m.revision_score, + safe_truncate(&m.provenance, 60) + ); } let mut exact_match = pre_matches.len() == post_matches.len(); @@ -384,7 +494,10 @@ fn run_benchmark() { println!("\nBenchmarking Stream Ingestion (10,000 events)..."); let t0 = Instant::now(); for t in 0..10_000 { - let text = format!("Log entry #{t}: server status 200 latency=15ms worker_id={}", t % 16); + let text = format!( + "Log entry #{t}: server status 200 latency=15ms worker_id={}", + t % 16 + ); let emb = embedder.embed(&text); engine.step(&emb, t as f64, &text); } @@ -393,8 +506,14 @@ fn run_benchmark() { println!(" Total Time: {:?}", total_time); println!(" Throughput: {:.0} events / second", throughput); - println!(" Latency / Event: {:.2} μs / event", (total_time.as_secs_f64() / 10_000.0) * 1e6); - println!(" Physical Memory: {} / 750 slots strictly bounded", engine.total_slots()); + println!( + " Latency / Event: {:.2} μs / event", + (total_time.as_secs_f64() / 10_000.0) * 1e6 + ); + println!( + " Physical Memory: {} / 750 slots strictly bounded", + engine.total_slots() + ); println!("\nBenchmarking Retrospective Query Latency (1,000 queries over 750 slots)..."); let query_emb = embedder.embed("Checkout incident 504 Gateway Timeout"); @@ -407,7 +526,10 @@ fn run_benchmark() { println!(" Total Query Time: {:?}", q_total); println!(" Mean Latency: {:.2} μs / query", q_lat); - println!(" Query QPS: {:.0} queries / second", 1_000.0 / q_total.as_secs_f64()); + println!( + " Query QPS: {:.0} queries / second", + 1_000.0 / q_total.as_secs_f64() + ); println!("\n[SUMMARY] Rust native engine runs at {:.0} events/sec ingestion and {:.1} μs query latency!", throughput, q_lat); } @@ -419,27 +541,22 @@ fn safe_truncate(s: &str, max_chars: usize) -> String { } } - fn default_snapshot_path() -> String { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); format!("{home}/.continuum/agent_memory.state") } -fn run_memory_sync(transcript_path: &str, snapshot_path: &str) { +fn run_memory_sync(transcript_path: &str, snapshot_path: &str) -> Result<()> { use std::fs::File; use std::io::{BufRead, BufReader}; - let file = match File::open(transcript_path) { - Ok(f) => f, - Err(e) => { - eprintln!("Error opening transcript file '{transcript_path}': {e}"); - return; - } - }; + memory_api::validate_path(snapshot_path)?; + let file = File::open(transcript_path)?; - if let Some(parent) = std::path::Path::new(snapshot_path).parent() { - let _ = std::fs::create_dir_all(parent); - } + if let Some(parent) = std::path::Path::new(snapshot_path).parent() + && !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } let dim = 32; let embedder = RealTextEmbedder::new(dim, 42); @@ -469,25 +586,25 @@ fn run_memory_sync(transcript_path: &str, snapshot_path: &str) { let mut count = 0; for line_res in reader.lines() { - let line = match line_res { - Ok(l) => l, - Err(_) => continue, - }; + let line = line_res?; if line.trim().is_empty() { continue; } - let v = match json::parse_json(&line) { - Ok(val) => val, - Err(_) => continue, - }; + let v = json::parse_json(&line) + .map_err(|e| ApiError::invalid(format!("invalid transcript JSON: {e}")))?; - let step_idx = v.get("step_index") + let step_idx = v + .get("step_index") .and_then(|x| x.as_u64()) .unwrap_or(count as u64); let step_type = v.get("type").and_then(|x| x.as_str()).unwrap_or("EVENT"); let content = v.get("content").and_then(|x| x.as_str()).unwrap_or(""); - let content_str = if content.is_empty() { safe_truncate(&line, 300) } else { content.to_string() }; + let content_str = if content.is_empty() { + safe_truncate(&line, 300) + } else { + content.to_string() + }; let prov = format!("[step_{step_idx}] [{step_type}] {content_str}"); let emb = embedder.embed(&prov); @@ -498,30 +615,30 @@ fn run_memory_sync(transcript_path: &str, snapshot_path: &str) { let dur = t0.elapsed(); let throughput = count as f64 / dur.as_secs_f64(); - match engine.save_to_file(snapshot_path) { - Ok(()) => { - let meta = std::fs::metadata(snapshot_path).ok(); - let kb = meta.map(|m| m.len() as f64 / 1024.0).unwrap_or(0.0); - println!("\n🎉 Successfully ingested {} conversation steps in pure Rust!", count); - println!(" Ingest Duration: {:?} ({:.0} steps/sec)", dur, throughput); - println!(" Active Memory: {} / 750 physical slots (O(K) invariant)", engine.total_slots()); - println!(" Hot Working RAM: {} slots", engine.hot_memory.len()); - println!(" Cold Manifold: {} slots", engine.cold_memory.len()); - println!(" Snapshot Size: {:.2} KB on disk (< 100 KB ultra-compact)", kb); - } - Err(e) => eprintln!("Failed to save snapshot to '{snapshot_path}': {e}"), - } + engine.save_to_file(snapshot_path)?; + let kb = std::fs::metadata(snapshot_path)?.len() as f64 / 1024.0; + println!( + "\n🎉 Successfully ingested {} conversation steps in pure Rust!", + count + ); + println!(" Ingest Duration: {:?} ({:.0} steps/sec)", dur, throughput); + println!( + " Active Memory: {} / 750 physical slots (O(K) invariant)", + engine.total_slots() + ); + println!(" Hot Working RAM: {} slots", engine.hot_memory.len()); + println!(" Cold Manifold: {} slots", engine.cold_memory.len()); + println!( + " Snapshot Size: {:.2} KB on disk (< 100 KB ultra-compact)", + kb + ); + Ok(()) } -fn run_memory_query(query: &str, snapshot_path: &str, top_k: usize) { +fn run_memory_query(query: &str, snapshot_path: &str, top_k: usize) -> Result<()> { + memory_api::validate_text(query)?; let t0 = Instant::now(); - let engine = match ContinuumEngine::load_from_file(snapshot_path) { - Ok(e) => e, - Err(e) => { - eprintln!("Error loading snapshot '{snapshot_path}': {e}"); - return; - } - }; + let engine = memory_api::load(snapshot_path)?; let load_time = t0.elapsed(); let dim = engine.config.embedding_dim; @@ -538,16 +655,30 @@ fn run_memory_query(query: &str, snapshot_path: &str, top_k: usize) { println!("============================================================================"); println!("Query: '{}'", query); println!("Snapshot: '{}'", snapshot_path); - println!("State Loaded: {:?} ({} active slots)", load_time, engine.total_slots()); - println!("Query Latency: {:?} (< 100 μs native microsecond execution!)", query_time); + println!( + "State Loaded: {:?} ({} active slots)", + load_time, + engine.total_slots() + ); + println!( + "Query Latency: {:?} (< 100 μs native microsecond execution!)", + query_time + ); if !exp.expanded_concepts.is_empty() { println!("Semantic Concepts: {:?}", exp.expanded_concepts); } println!("----------------------------------------------------------------------------"); for (i, m) in matches.iter().enumerate() { - println!("#{} [Score: {:.4} | sim={:.4}, state={:.4}, temp={:.4}] Event ID: {}", - i + 1, m.revision_score, m.components.sim, m.components.state_compat, m.components.temporal_compat, m.event_id); + println!( + "#{} [Score: {:.4} | sim={:.4}, state={:.4}, temp={:.4}] Event ID: {}", + i + 1, + m.revision_score, + m.components.sim, + m.components.state_compat, + m.components.temporal_compat, + m.event_id + ); let prov = m.provenance.replace('\n', " "); let prov_clean = prov.trim(); let prov_display: String = if prov_clean.chars().count() > 140 { @@ -557,50 +688,14 @@ fn run_memory_query(query: &str, snapshot_path: &str, top_k: usize) { }; println!(" {}\n", prov_display); } + Ok(()) } +// Compatibility bridge for the separately maintained hook module. fn run_memory_ingest(text: &str, snapshot_path: &str) { - let default_cfg = ContinuumConfig { - embedding_dim: 32, - state_dim: 32, - hot_capacity: 250, - cold_capacity: 500, - causal_exempt_threshold: Some(0.25), - sim_threshold: 0.65, - ..Default::default() - }; - - let res = continuum_core::mutate_engine_transactional( - snapshot_path, - Some(default_cfg), - |engine| { - let dim = engine.config.embedding_dim; - let embedder = RealTextEmbedder::new(dim, 42); - let emb = embedder.embed(text); - let step_id = engine.step_count as f64; - engine.step(&emb, step_id, text); - Ok(()) - }, - ); - - if let Err(e) = res { - eprintln!("Failed to ingest event into memory: {e}"); - } -} - -fn run_memory_inspect(snapshot_path: &str) { - match ContinuumEngine::load_from_file(snapshot_path) { - Ok(engine) => { - println!("Continuum Engine Snapshot: '{}'", snapshot_path); - println!(" Total Slots Used: {} / {}", engine.total_slots(), engine.config.hot_capacity + engine.config.cold_capacity); - println!(" Hot Memory: {} / {}", engine.hot_memory.len(), engine.config.hot_capacity); - println!(" Cold Memory: {} / {}", engine.cold_memory.len(), engine.config.cold_capacity); - println!(" Total Steps: {}", engine.step_count); - println!(" Embedding Dim: {}", engine.config.embedding_dim); - println!(" State Dim: {}", engine.config.state_dim); - println!(" Causal Exempt θ: {:?}", engine.config.causal_exempt_threshold); - } - Err(e) => eprintln!("Failed to load snapshot from '{snapshot_path}': {e}"), + if let Err(error) = memory_api::ingest(text, snapshot_path) { + eprintln!("{}: {}", error.code, error.message); + std::process::exit(error.exit_code()); } } @@ -617,30 +712,21 @@ fn find_active_state_file() -> String { default_snapshot_path() } -fn run_init(target_dir: &str) { +fn run_init(target_dir: &str) -> Result<()> { let base_path = std::path::Path::new(target_dir); let continuum_dir = base_path.join(".continuum"); - if let Err(e) = std::fs::create_dir_all(&continuum_dir) { - eprintln!("Failed to create directory '{:?}': {e}", continuum_dir); - return; - } + std::fs::create_dir_all(&continuum_dir)?; let state_file = continuum_dir.join("memory.state"); let cfg_file = continuum_dir.join("config.json"); - if !state_file.exists() { - let cfg = ContinuumConfig { - embedding_dim: 32, - state_dim: 32, - hot_capacity: 250, - cold_capacity: 500, - causal_exempt_threshold: Some(0.25), - sim_threshold: 0.65, - ..Default::default() - }; - let engine = ContinuumEngine::new(cfg); - let _ = engine.save_to_file(&state_file); - } + // Creation and loading share the writer lock: never replace a state created + // by another writer between an existence check and acquiring that lock. + continuum_core::mutate_engine_transactional( + &state_file, + Some(memory_api::default_config()), + |_| Ok(()), + )?; let config_content = r#"{ "version": "1.0", @@ -651,15 +737,19 @@ fn run_init(target_dir: &str) { "causal_decay_exemption": true, "mcp_enabled": true }"#; - let _ = std::fs::write(&cfg_file, config_content); + std::fs::write(&cfg_file, config_content)?; - println!("Initialized Continuum bounded memory repository in '{:?}'", continuum_dir); + println!( + "Initialized Continuum bounded memory repository in '{:?}'", + continuum_dir + ); println!(" State File: '{:?}'", state_file); println!(" Config File: '{:?}'", cfg_file); println!(" Memory Cap: 750 slots (O(K) constant memory invariant)"); println!("\nQuick Start:"); println!(" continuum remember \"Important architecture constraint...\""); println!(" continuum recall \"architecture constraint\""); + Ok(()) } fn run_upgrade() { @@ -685,142 +775,89 @@ fn run_upgrade() { println!("============================================================================"); } -fn send_mcp_msg(stdout: &mut std::io::Stdout, json_str: &str) { - use std::io::Write; - let single_line: String = json_str.chars().filter(|&c| c != '\n' && c != '\r').collect(); - let _ = writeln!(stdout, "{}", single_line); - let _ = stdout.flush(); +fn parse_output_options(mut args: Vec) -> (Vec, bool) { + let mut machine = false; + while args.get(1).map(String::as_str) == Some("--json") { + args.remove(1); + machine = true; + } + // Child command arguments belong entirely to the runner. + if matches!(args.get(1).map(String::as_str), Some("run" | "exec")) { + return (args, machine); + } + let boundary = args.iter().position(|arg| arg == "--"); + let end = boundary.unwrap_or(args.len()); + // Retain the established trailing option, but never consume required text + // or paths. Everything after `--` is literal, including `--json`. + let required_end = match args.get(1).map(String::as_str) { + Some("remember" | "record" | "recall" | "find") => 3, + Some("memory") => match args.get(2).map(String::as_str) { + Some("ingest" | "query" | "sync" | "check") => 4, + Some("backup" | "restore") => 5, + _ => 3, + }, + _ => 2, + }; + let trailing = + end > 2 && (boundary.is_some() || end > required_end) && args[end - 1] == "--json"; + if let Some(index) = boundary { + args.remove(index); + } + if trailing { + args.remove(end - 1); + machine = true; + } + (args, machine) } -fn run_mcp() { - use std::io::{self, BufRead}; - - let stdin = io::stdin(); - let mut stdout = io::stdout(); - - let state_path = find_active_state_file(); - - for line_res in stdin.lock().lines() { - let line = match line_res { - Ok(l) => l, - Err(_) => break, - }; - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - - let json = match json::parse_json(trimmed) { - Ok(v) => v, - Err(e) => { - eprintln!("MCP JSON-RPC parse error: {e}"); - continue; - } - }; - - let id_val = json.get("id").map(|v| v.to_raw_id_string()).unwrap_or_else(|| "null".to_string()); - let method = json.get("method").and_then(|v| v.as_str()).unwrap_or(""); - - if method == "initialize" { - let resp = format!( - r#"{{"jsonrpc":"2.0","id":{},"result":{{"protocolVersion":"2024-11-05","capabilities":{{"tools":{{}}}},"serverInfo":{{"name":"continuum","version":"0.1.0"}}}}}}"#, - id_val - ); - send_mcp_msg(&mut stdout, &resp); - } else if method == "notifications/initialized" { - // No response required - } else if method == "ping" { - let resp = format!( - r#"{{"jsonrpc":"2.0","id":{},"result":{{}}}}"#, - id_val - ); - send_mcp_msg(&mut stdout, &resp); - } else if method == "tools/list" { - let resp = format!( - r#"{{"jsonrpc":"2.0","id":{},"result":{{"tools":[{{"name":"continuum_remember","description":"Store a critical architecture constraint, engineering decision, or tool failure into bounded O(K) memory","inputSchema":{{"type":"object","properties":{{"text":{{"type":"string","description":"The constraint, decision, or event to remember"}}}},"required":["text"]}}}},{{"name":"continuum_recall","description":"Retrospectively retrieve relevant past constraints, actions, and root causes in < 1ms","inputSchema":{{"type":"object","properties":{{"query":{{"type":"string","description":"The symptom, search query, or question to recall"}},"top_k":{{"type":"integer","description":"Maximum candidates to return (default 3)"}}}},"required":["query"]}}}},{{"name":"continuum_stats","description":"Get current bounded memory usage, physical slot count, and token savings metrics","inputSchema":{{"type":"object","properties":{{}}}}}}]}}}}"#, - id_val - ); - send_mcp_msg(&mut stdout, &resp); - } else if method == "tools/call" { - let tool_name = json.get_path(&["params", "name"]).and_then(|v| v.as_str()).unwrap_or(""); - let result_text = if tool_name == "continuum_remember" { - let text_arg = json.get_path(&["params", "arguments", "text"]) - .and_then(|v| v.as_str()) - .unwrap_or("empty_event"); - run_memory_ingest(text_arg, &state_path); - format!("Stored constraint in Continuum memory: '{}' (Active slots saved in {})", text_arg, state_path) - } else if tool_name == "continuum_recall" { - let query_arg = json.get_path(&["params", "arguments", "query"]) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let k_arg = json.get_path(&["params", "arguments", "top_k"]) - .and_then(|v| v.as_u64()) - .unwrap_or(3) as usize; - - let engine = ContinuumEngine::load_from_file(&state_path).unwrap_or_else(|_| { - ContinuumEngine::new(ContinuumConfig::default()) - }); - let dim = engine.config.embedding_dim; - let embedder = RealTextEmbedder::new(dim, 42); - let bridge = SemanticCausalBridge::new(); - let (v_bridged, _) = bridge.project_query(query_arg, &embedder, 0.50); - let matches = engine.query(&v_bridged, k_arg); - - let mut out = format!("Retrieved {} causal memories (< 100 μs native Rust):\n", matches.len()); - for (i, m) in matches.iter().enumerate() { - let prov = safe_truncate(&m.provenance.replace('\n', " "), 120); - out.push_str(&format!("#{}: [Score: {:.4}] {}\n", i + 1, m.revision_score, prov)); - } - out - } else if tool_name == "continuum_stats" { - let engine = ContinuumEngine::load_from_file(&state_path).unwrap_or_else(|_| { - ContinuumEngine::new(ContinuumConfig::default()) - }); - format!("Continuum Memory Engine (100% Native Rust):\n- Active Slots: {} / 750 bounded invariant\n- Estimated Token Savings: 96.8%\n- Snapshot Path: {}", - engine.total_slots(), state_path) - } else { - format!("Unknown tool: '{}'", tool_name) - }; - - let escaped_text = result_text.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n").replace('\r', ""); - let resp = format!( - r#"{{"jsonrpc":"2.0","id":{},"result":{{"content":[{{"type":"text","text":"{}"}}]}}}}"#, - id_val, escaped_text - ); - send_mcp_msg(&mut stdout, &resp); +fn main() { + let (args, machine) = parse_output_options(std::env::args().collect()); + if let Err(error) = dispatch(&args, machine) { + if machine { + println!("{}", error.json()); } else { - let resp = format!( - r#"{{"jsonrpc":"2.0","id":{},"result":{{}}}}"#, - id_val - ); - send_mcp_msg(&mut stdout, &resp); + eprintln!("{}: {}", error.code, error.message); } + std::process::exit(error.exit_code()); } } -fn main() { - let args: Vec = std::env::args().collect(); +fn dispatch(args: &[String], machine: bool) -> Result<()> { if args.len() < 2 { print_help(); - return; + return Ok(()); } match args[1].as_str() { "init" => { + memory_cli::arity(args, 2, 3, "init [path]")?; let path = args.get(2).map(|s| s.as_str()).unwrap_or("."); - run_init(path); + run_init(path)?; } "remember" | "record" => { - let text = args.get(2).map(|s| s.as_str()).expect("Usage: continuum remember "); + memory_cli::arity(args, 3, 3, "remember ")?; + let text = &args[2]; let snap = find_active_state_file(); - run_memory_ingest(text, &snap); - println!("✅ Stored in Continuum memory manifold (Active state: '{}')", snap); + let result = memory_api::ingest(text, &snap)?; + if machine { + println!("{result}"); + } else { + println!( + "✅ Stored in Continuum memory manifold (Active state: '{}')", + snap + ); + } } "recall" | "find" => { - let query = args.get(2).map(|s| s.as_str()).expect("Usage: continuum recall [k]"); + memory_cli::arity(args, 3, 4, "recall [k]")?; + let query = &args[2]; let snap = find_active_state_file(); - let k = args.get(3).and_then(|s| s.parse::().ok()).unwrap_or(3); - run_memory_query(query, &snap, k); + let k = memory_api::top_k(args.get(3).map(String::as_str), 3)?; + if machine { + println!("{}", memory_api::query(query, &snap, k)?); + } else { + run_memory_query(query, &snap, k)?; + } } "run" | "exec" => { if args.len() < 3 { @@ -850,12 +887,15 @@ fn main() { _ => { println!("Usage:"); println!(" continuum hook install [dir] Install automatic Git post-commit memory hook"); - println!(" continuum hook uninstall [dir] Remove Git post-commit memory hook"); + println!( + " continuum hook uninstall [dir] Remove Git post-commit memory hook" + ); } } } "mcp" => { - run_mcp(); + memory_cli::arity(args, 2, 2, "mcp")?; + mcp::run(&find_active_state_file())?; } "upgrade" | "pro" => { run_upgrade(); @@ -872,68 +912,34 @@ fn main() { } } } - "memory" => { - let default_snap = default_snapshot_path(); - let sub = args.get(2).map(|s| s.as_str()).unwrap_or("help"); - match sub { - "sync" => { - let transcript = args.get(3).map(|s| s.as_str()).expect("Usage: continuum memory sync [snapshot_path]"); - let snap = args.get(4).map(|s| s.as_str()).unwrap_or(&default_snap); - run_memory_sync(transcript, snap); - } - "query" => { - let query_str = args.get(3).map(|s| s.as_str()).expect("Usage: continuum memory query [snapshot_path] [top_k]"); - let snap = args.get(4).map(|s| s.as_str()).unwrap_or(&default_snap); - let k = args.get(5).and_then(|s| s.parse::().ok()).unwrap_or(5); - run_memory_query(query_str, snap, k); - } - "ingest" => { - let text = args.get(3).map(|s| s.as_str()).expect("Usage: continuum memory ingest [snapshot_path]"); - let snap = args.get(4).map(|s| s.as_str()).unwrap_or(&default_snap); - run_memory_ingest(text, snap); - } - "inspect" | "stats" => { - let snap = args.get(3).map(|s| s.as_str()).unwrap_or(&default_snap); - run_memory_inspect(snap); - } - _ => { - println!("Usage:"); - println!(" continuum memory sync [snapshot_path]"); - println!(" continuum memory query [snapshot_path] [top_k]"); - println!(" continuum memory ingest [snapshot_path]"); - println!(" continuum memory inspect [snapshot_path]"); - } - } - } + "memory" => memory_cli::run(&args[2..], machine)?, "snapshot" => { + memory_cli::arity(args, 2, 3, "snapshot [path]")?; let path = args.get(2).map(|s| s.as_str()).unwrap_or("continuum.state"); let cfg = ContinuumConfig::default(); let engine = ContinuumEngine::new(cfg); - match engine.save_to_file(path) { - Ok(()) => println!("Successfully saved initial snapshot to '{path}'"), - Err(e) => eprintln!("Failed to save snapshot to '{path}': {e}"), - } + engine.save_to_file(path)?; + println!("Successfully saved initial snapshot to '{path}'"); } "restore" => { + memory_cli::arity(args, 2, 3, "restore [path]")?; let path = args.get(2).map(|s| s.as_str()).unwrap_or("continuum.state"); - match ContinuumEngine::load_from_file(path) { - Ok(eng) => { - println!("Successfully restored engine from '{path}'!"); - println!(" Total Slots: {}", eng.total_slots()); - println!(" Hot Slots: {}", eng.hot_memory.len()); - println!(" Cold Slots: {}", eng.cold_memory.len()); - println!(" Step Count: {}", eng.step_count); - } - Err(e) => eprintln!("Failed to load snapshot from '{path}': {e}"), - } + let eng = ContinuumEngine::load_from_file(path)?; + println!("Successfully restored engine from '{path}'!"); + println!(" Total Slots: {}", eng.total_slots()); + println!(" Hot Slots: {}", eng.hot_memory.len()); + println!(" Cold Slots: {}", eng.cold_memory.len()); + println!(" Step Count: {}", eng.step_count); } "benchmark" => run_benchmark(), "stats" => { - println!("Continuum Native Rust Core v0.1.0"); - println!("Memory Model: Physical O(K) Bounded Two-Tier Manifold (Hot + Cold)"); - println!("Complexity: O(1) Gated Linear Recurrence"); - println!("Query Speed: ~50 μs"); - println!("External Runtime Dependencies: 0 (Pure Rust Standard Library)"); + memory_cli::arity(args, 2, 2, "stats")?; + let path = find_active_state_file(); + if machine { + println!("{}", memory_api::inspect(&path)?); + } else { + memory_api::inspect_human(&path)?; + } } "version" | "--version" | "-v" => { println!("continuum 0.1.0 (native rust core)"); @@ -942,9 +948,12 @@ fn main() { print_help(); } cmd => { - eprintln!("Unknown command: '{cmd}'. Run 'continuum help' for usage."); + return Err(ApiError::invalid(format!( + "Unknown command: '{cmd}'. Run 'continuum help' for usage." + ))); } } + Ok(()) } #[cfg(test)] @@ -960,18 +969,36 @@ mod tests { ); // Strict stdio MCP mandate: must not contain any newlines - assert!(!resp.contains('\n'), "MCP response must NOT contain newlines!"); - assert!(!resp.contains('\r'), "MCP response must NOT contain carriage returns!"); + assert!( + !resp.contains('\n'), + "MCP response must NOT contain newlines!" + ); + assert!( + !resp.contains('\r'), + "MCP response must NOT contain carriage returns!" + ); // Must parse as valid JSON let parsed = json::parse_json(&resp).expect("Failed to parse MCP response as JSON"); assert_eq!(parsed.get("jsonrpc").unwrap().as_str().unwrap(), "2.0"); - assert_eq!(parsed.get("id").unwrap().to_raw_id_string(), "\"test_msg_001\""); + assert_eq!( + parsed.get("id").unwrap().to_raw_id_string(), + "\"test_msg_001\"" + ); - let tools = parsed.get_path(&["result", "tools"]).unwrap().as_array().unwrap(); + let tools = parsed + .get_path(&["result", "tools"]) + .unwrap() + .as_array() + .unwrap(); assert_eq!(tools.len(), 3); - let names: Vec<&str> = tools.iter().map(|t| t.get("name").unwrap().as_str().unwrap()).collect(); - assert_eq!(names, vec!["continuum_remember", "continuum_recall", "continuum_stats"]); + let names: Vec<&str> = tools + .iter() + .map(|t| t.get("name").unwrap().as_str().unwrap()) + .collect(); + assert_eq!( + names, + vec!["continuum_remember", "continuum_recall", "continuum_stats"] + ); } } - diff --git a/crates/continuum-cli/src/mcp.rs b/crates/continuum-cli/src/mcp.rs new file mode 100644 index 0000000..83490f5 --- /dev/null +++ b/crates/continuum-cli/src/mcp.rs @@ -0,0 +1,89 @@ +//! Native line-delimited stdio MCP. Tool failures are results with isError, not successes. +use crate::json::{self, JsonValue}; +use crate::memory_api::{self as api, ApiError, Result, quote}; +use std::io::{self, BufRead, Write}; + +const TOOLS: &str = r#"{"tools":[{"name":"continuum_remember","description":"Store an event in bounded local memory","inputSchema":{"type":"object","properties":{"text":{"type":"string","minLength":1}},"required":["text"],"additionalProperties":false}},{"name":"continuum_recall","description":"Retrieve ranked memory candidates, with complete stored provenance","inputSchema":{"type":"object","properties":{"query":{"type":"string","minLength":1},"top_k":{"type":"integer","minimum":1,"maximum":10000,"default":3}},"required":["query"],"additionalProperties":false}},{"name":"continuum_stats","description":"Inspect actual local memory usage and records","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}]}"#; + +fn rpc_error(id: &str, code: i32, message: &str) -> String { + format!("{{\"jsonrpc\":\"2.0\",\"id\":{id},\"error\":{{\"code\":{code},\"message\":{}}}}}", quote(message)) +} +fn required<'a>(args: &'a JsonValue, field: &str) -> Result<&'a str> { + args.get(field).and_then(JsonValue::as_str).ok_or_else(|| ApiError::invalid(format!("{field} must be a string"))) +} +fn tool_call(request: &JsonValue, path: &str) -> Result { + let params = request.get("params").ok_or_else(|| ApiError::invalid("params required"))?; + let name = required(params, "name")?; + let empty = JsonValue::Object(Vec::new()); + let args = params.get("arguments").unwrap_or(&empty); + let fields = args.as_object().ok_or_else(|| ApiError::invalid("arguments must be an object"))?; + let allowed: &[&str] = match name { + "continuum_remember" => &["text"], + "continuum_recall" => &["query", "top_k"], + "continuum_stats" => &[], + _ => return Err(ApiError::invalid("unknown tool")), + }; + if fields.iter().any(|(field, _)| !allowed.contains(&field.as_str())) { + return Err(ApiError::invalid("unknown tool argument")); + } + match name { + "continuum_remember" => api::ingest(required(args, "text")?, path), + "continuum_recall" => { + let k = match args.get("top_k") { + None => 3, + Some(JsonValue::Number(n)) if n.is_finite() && n.fract() == 0.0 && *n >= 1.0 && *n <= api::MAX_TOP_K as f64 => *n as usize, + _ => return Err(ApiError::invalid("top_k must be an integer in 1..=10000")), + }; + api::query(required(args, "query")?, path, k) + } + "continuum_stats" => api::inspect(path), + _ => unreachable!(), + } +} + +fn response(line: &str, path: &str) -> Option { + let request = match json::parse_json(line) { + Ok(value) => value, + Err(_) => return Some(rpc_error("null", -32700, "Parse error")), + }; + let id = request.get("id"); + let valid_id = match id { + None | Some(JsonValue::Null) | Some(JsonValue::String(_)) => true, + Some(JsonValue::Number(n)) => n.is_finite() && n.fract() == 0.0 && n.abs() <= 9_007_199_254_740_991.0, + _ => false, + }; + let method = request.get("method").and_then(JsonValue::as_str); + if request.get("jsonrpc").and_then(JsonValue::as_str) != Some("2.0") || method.is_none() || !valid_id { + return Some(rpc_error("null", -32600, "Invalid Request")); + } + // Notifications never receive replies or execute memory mutations. + let id = id?.to_json_string(); + let result = match method.unwrap() { + "initialize" => r#"{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"continuum","version":"0.1.0"}}"#.to_owned(), + "ping" => "{}".to_owned(), + "tools/list" => TOOLS.to_owned(), + "tools/call" => { + let (content, is_error) = match tool_call(&request, path) { + Ok(content) => (content, false), + Err(error) => (error.json(), true), + }; + format!("{{\"isError\":{is_error},\"content\":[{{\"type\":\"text\",\"text\":{}}}]}}", quote(&content)) + } + _ => return Some(rpc_error(&id, -32601, "Method not found")), + }; + Some(format!("{{\"jsonrpc\":\"2.0\",\"id\":{id},\"result\":{result}}}")) +} + +pub fn run(path: &str) -> Result<()> { + let stdin = io::stdin(); + let mut stdout = io::stdout().lock(); + for line in stdin.lock().lines() { + let line = line?; + if line.trim().is_empty() { continue; } + if let Some(message) = response(&line, path) { + writeln!(stdout, "{message}")?; + stdout.flush()?; + } + } + Ok(()) +} diff --git a/crates/continuum-cli/src/memory_api.rs b/crates/continuum-cli/src/memory_api.rs new file mode 100644 index 0000000..92dd7bd --- /dev/null +++ b/crates/continuum-cli/src/memory_api.rs @@ -0,0 +1,139 @@ +//! Shared CLI/MCP memory operations. Success is returned only after durable storage succeeds. +use crate::json::JsonValue; +use continuum_core::{ContinuumConfig, ContinuumEngine, RealTextEmbedder, SemanticCausalBridge}; +use std::io; + +pub const MAX_TEXT_BYTES: usize = 1024 * 1024; +pub const MAX_TOP_K: usize = 10_000; +pub type Result = std::result::Result; + +#[derive(Debug)] +pub struct ApiError { + pub code: &'static str, + pub message: String, +} +impl ApiError { + pub fn invalid(message: impl Into) -> Self { + Self { code: "INVALID_INPUT", message: message.into() } + } + pub fn json(&self) -> String { + format!("{{\"schema_version\":1,\"ok\":false,\"error\":{{\"code\":{},\"message\":{}}}}}", quote(self.code), quote(&self.message)) + } + pub fn exit_code(&self) -> i32 { if self.code == "INVALID_INPUT" { 2 } else { 1 } } +} +impl From for ApiError { + fn from(error: io::Error) -> Self { + let code = match error.kind() { + io::ErrorKind::NotFound => "NOT_FOUND", + io::ErrorKind::PermissionDenied => "PERMISSION_DENIED", + io::ErrorKind::InvalidData | io::ErrorKind::UnexpectedEof => "INVALID_STATE", + io::ErrorKind::InvalidInput => "INVALID_INPUT", + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock => "LOCK_TIMEOUT", + io::ErrorKind::AlreadyExists => "ALREADY_EXISTS", + _ => "IO_ERROR", + }; + Self { code, message: error.to_string() } + } +} +pub fn quote(text: &str) -> String { JsonValue::String(text.to_owned()).to_json_string() } +pub fn validate_text(text: &str) -> Result<()> { + if text.trim().is_empty() || text.len() > MAX_TEXT_BYTES { + return Err(ApiError::invalid("text/query must be nonempty and at most 1048576 UTF-8 bytes")); + } + Ok(()) +} +pub fn validate_path(path: &str) -> Result<()> { + if path.trim().is_empty() || path.contains('\0') { return Err(ApiError::invalid("snapshot path must be nonempty and contain no NUL")); } + Ok(()) +} +pub fn top_k(value: Option<&str>, default: usize) -> Result { + let k = match value { Some(value) => value.parse::().map_err(|_| ApiError::invalid("top_k must be an integer in 1..=10000"))?, None => default }; + if !(1..=MAX_TOP_K).contains(&k) { return Err(ApiError::invalid("top_k must be an integer in 1..=10000")); } + Ok(k) +} +pub fn default_config() -> ContinuumConfig { + ContinuumConfig { embedding_dim: 32, state_dim: 32, hot_capacity: 250, cold_capacity: 500, causal_exempt_threshold: Some(0.25), sim_threshold: 0.65, ..Default::default() } +} +pub fn load(path: &str) -> Result { + validate_path(path)?; + Ok(continuum_core::load_engine(path)?) +} + +pub fn ingest(text: &str, path: &str) -> Result { + validate_text(text)?; + validate_path(path)?; + let (id, steps, slots) = continuum_core::mutate_engine_transactional(path, Some(default_config()), |engine| { + if engine.step_count == u64::MAX { return Err(io::Error::new(io::ErrorKind::InvalidData, "event step counter exhausted")); } + let id = engine.step_count; + let embedder = RealTextEmbedder::new(engine.config.embedding_dim, 42); + engine.step(&embedder.embed(text), id as f64, text); + Ok((id, engine.step_count, engine.total_slots())) + })?; + // Decimal strings preserve u64 identity in clients whose JSON numbers are IEEE doubles. + Ok(format!("{{\"schema_version\":1,\"ok\":true,\"operation\":\"ingest\",\"snapshot\":{},\"event_id\":\"{id}\",\"step_id\":\"{id}\",\"step_count\":\"{steps}\",\"total_slots\":{slots},\"text\":{}}}", quote(path), quote(text))) +} + +pub fn query(query: &str, path: &str, k: usize) -> Result { + validate_text(query)?; + top_k(Some(&k.to_string()), k)?; + let engine = load(path)?; + let embedder = RealTextEmbedder::new(engine.config.embedding_dim, 42); + let (vector, _) = SemanticCausalBridge::new().project_query(query, &embedder, 0.50); + let matches = engine.query(&vector, k); + let rows: Vec = matches.iter().map(|m| format!("{{\"event_id\":\"{}\",\"step_id\":\"{}\",\"timestamp\":{},\"score\":{},\"text\":{},\"provenance\":{},\"components\":{{\"sim\":{},\"state_compat\":{},\"temporal_compat\":{},\"provenance_compat\":{}}}}}", m.event_id, m.event_id, m.timestamp, m.revision_score, quote(&m.provenance), quote(&m.provenance), m.components.sim, m.components.state_compat, m.components.temporal_compat, m.components.provenance_compat)).collect(); + Ok(format!("{{\"schema_version\":1,\"ok\":true,\"operation\":\"query\",\"snapshot\":{},\"query\":{},\"top_k\":{k},\"matches\":[{}]}}", quote(path), quote(query), rows.join(","))) +} + +pub fn inspect(path: &str) -> Result { + let engine = load(path)?; + let mut rows = Vec::new(); + for r in &engine.hot_memory.records { + rows.push((r.event_id, format!("{{\"event_id\":\"{}\",\"step_id\":\"{}\",\"tier\":\"hot\",\"timestamp\":{},\"text\":{}}}", r.event_id, r.event_id, r.timestamp, quote(&r.payload_ref)))); + } + for r in &engine.cold_memory.records { + rows.push((r.event_id, format!("{{\"event_id\":\"{}\",\"step_id\":\"{}\",\"tier\":\"cold\",\"timestamp\":{},\"text\":{}}}", r.event_id, r.event_id, r.timestamp, quote(&r.provenance_summary)))); + } + rows.sort_by_key(|r| r.0); + Ok(format!("{{\"schema_version\":1,\"ok\":true,\"operation\":\"inspect\",\"snapshot\":{},\"step_count\":\"{}\",\"total_slots\":{},\"hot_slots\":{},\"cold_slots\":{},\"hot_capacity\":{},\"cold_capacity\":{},\"embedding_dim\":{},\"state_dim\":{},\"records\":[{}]}}", quote(path), engine.step_count, engine.total_slots(), engine.hot_memory.len(), engine.cold_memory.len(), engine.config.hot_capacity, engine.config.cold_capacity, engine.config.embedding_dim, engine.config.state_dim, rows.into_iter().map(|r| r.1).collect::>().join(","))) +} + +// Recovery reports deliberately expose the core's integrity limits. Decimal +// step counts follow the existing CLI schema without losing u64 precision. +fn recovery_report(operation: &str, path: &str, info: continuum_core::SnapshotInfo) -> String { + format!("{{\"schema_version\":1,\"ok\":true,\"operation\":{},\"snapshot\":{},\"format\":{},\"bytes\":{},\"step_count\":\"{}\",\"hot_records\":{},\"cold_records\":{},\"checksum_verified\":{}}}", + quote(operation), quote(path), quote(info.format), info.bytes, info.step_count, + info.hot_records, info.cold_records, info.checksum_verified) +} + +pub fn check(path: &str) -> Result { + validate_path(path)?; + Ok(recovery_report("check", path, continuum_core::check_snapshot(path)?)) +} + +pub fn backup(path: &str, backup: &str) -> Result { + validate_path(path)?; + validate_path(backup)?; + Ok(recovery_report("backup", backup, continuum_core::backup_engine(path, backup)?)) +} + +pub fn restore(backup: &str, path: &str, confirmed: bool) -> Result { + validate_path(backup)?; + validate_path(path)?; + // Never infer overwrite permission from a preflight exists() check: the + // core enforces no-clobber under its lock even if another writer races us. + Ok(recovery_report("restore", path, continuum_core::restore_engine(backup, path, confirmed)?)) +} + +pub fn inspect_human(path: &str) -> Result<()> { + let engine = load(path)?; + // The local adapter parses these labels/spacing; do not change the human format. + println!("Continuum Engine Snapshot: '{}'", path); + println!(" Total Slots Used: {} / {}", engine.total_slots(), engine.config.hot_capacity + engine.config.cold_capacity); + println!(" Hot Memory: {} / {}", engine.hot_memory.len(), engine.config.hot_capacity); + println!(" Cold Memory: {} / {}", engine.cold_memory.len(), engine.config.cold_capacity); + println!(" Total Steps: {}", engine.step_count); + println!(" Embedding Dim: {}", engine.config.embedding_dim); + println!(" State Dim: {}", engine.config.state_dim); + println!(" Causal Exempt θ: {:?}", engine.config.causal_exempt_threshold); + Ok(()) +} diff --git a/crates/continuum-cli/src/memory_cli.rs b/crates/continuum-cli/src/memory_cli.rs new file mode 100644 index 0000000..59ff294 --- /dev/null +++ b/crates/continuum-cli/src/memory_cli.rs @@ -0,0 +1,57 @@ +//! Memory CLI parsing, deliberately separate from storage and MCP transport. +use crate::memory_api::{self as api, ApiError, Result}; + +pub fn arity(args: &[String], min: usize, max: usize, usage: &str) -> Result<()> { + if args.len() < min || args.len() > max { return Err(ApiError::invalid(usage)); } + Ok(()) +} +pub fn run(args: &[String], machine: bool) -> Result<()> { + let default = crate::default_snapshot_path(); + let sub = args.first().map(String::as_str).unwrap_or("help"); + match sub { + "ingest" => { + arity(args, 2, 3, "memory ingest [snapshot] [--json]")?; + let path = args.get(2).map(String::as_str).unwrap_or(&default); + let result = api::ingest(&args[1], path)?; + if machine { println!("{result}"); } + } + "query" => { + arity(args, 2, 4, "memory query [snapshot] [top_k] [--json]")?; + let path = args.get(2).map(String::as_str).unwrap_or(&default); + let k = api::top_k(args.get(3).map(String::as_str), 5)?; + if machine { println!("{}", api::query(&args[1], path, k)?); } + else { crate::run_memory_query(&args[1], path, k)?; } + } + "inspect" | "stats" => { + arity(args, 1, 2, "memory inspect [snapshot] [--json]")?; + let path = args.get(1).map(String::as_str).unwrap_or(&default); + if machine { println!("{}", api::inspect(path)?); } + else { api::inspect_human(path)?; } + } + "check" => { + arity(args, 2, 2, "memory check [--json]")?; + println!("{}", api::check(&args[1])?); + } + "backup" => { + arity(args, 3, 3, "memory backup [--json] (never overwrites)")?; + println!("{}", api::backup(&args[1], &args[2])?); + } + "restore" => { + arity(args, 3, 4, "memory restore [--confirm] [--json]")?; + let confirmed = match args.get(3).map(String::as_str) { + None => false, + Some("--confirm") => true, + Some(_) => return Err(ApiError::invalid("only --confirm permits replacing an existing state")), + }; + println!("{}", api::restore(&args[1], &args[2], confirmed)?); + } + "sync" => { + arity(args, 2, 3, "memory sync [snapshot]")?; + if machine { return Err(ApiError::invalid("--json supports ingest/query/inspect")); } + crate::run_memory_sync(&args[1], args.get(2).map(String::as_str).unwrap_or(&default))?; + } + "help" | "--help" | "-h" => crate::print_help(), + _ => return Err(ApiError::invalid("unknown memory command")), + } + Ok(()) +} diff --git a/crates/continuum-cli/tests/errors_api.rs b/crates/continuum-cli/tests/errors_api.rs new file mode 100644 index 0000000..84065e2 --- /dev/null +++ b/crates/continuum-cli/tests/errors_api.rs @@ -0,0 +1,434 @@ +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Output, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; + +#[path = "../src/json.rs"] +mod json; +use json::{parse_json, JsonValue}; + +static NEXT: AtomicU64 = AtomicU64::new(0); +struct Workspace(PathBuf); +impl Workspace { + fn new() -> Self { + let dir = std::env::temp_dir().join(format!( + "continuum-errors-api-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(dir.join("home")).unwrap(); + Self(dir) + } + fn command(&self) -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_continuum-cli")); + cmd.current_dir(&self.0).env("HOME", self.0.join("home")); + cmd + } + fn cli(&self, args: &[&str]) -> Output { + self.command().args(args).output().unwrap() + } + fn state(&self) -> PathBuf { + self.0.join(".continuum/memory.state") + } + fn corrupt(&self) { + fs::create_dir_all(self.state().parent().unwrap()).unwrap(); + fs::write(self.state(), b"incomplete snapshot").unwrap(); + } + fn mcp(&self, requests: &str) -> Vec { + let mut child = self + .command() + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(requests.as_bytes()) + .unwrap(); + let out = child.wait_with_output().unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout) + .unwrap() + .lines() + .map(|line| parse_json(line).unwrap()) + .collect() + } +} +impl Drop for Workspace { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +fn machine(out: &Output) -> JsonValue { + parse_json(std::str::from_utf8(&out.stdout).unwrap()).unwrap() +} + +#[test] +fn init_preserves_existing_memory_and_configuration() { + let w = Workspace::new(); + assert!(w.cli(&["init", "."]).status.success()); + assert!(w.cli(&["remember", "keep existing event"]).status.success()); + let before = fs::read(w.state()).unwrap(); + assert!(w.cli(&["init", "."]).status.success()); + assert_eq!(fs::read(w.state()).unwrap(), before); + let result = machine(&w.cli(&["--json", "stats"])); + assert_eq!( + result.get("step_count").and_then(JsonValue::as_str), + Some("1") + ); +} + +#[test] +fn json_options_preserve_literal_text_and_paths() { + let w = Workspace::new(); + assert!(w.cli(&["init", "."]).status.success()); + for args in [ + vec!["--json", "remember", "--json"], + vec!["remember", "--json", "--", "--json"], + vec!["--json", "remember", "--", "--json"], + vec!["memory", "ingest", "--json", "state", "--json"], + vec!["--json", "memory", "ingest", "--", "--json", "--json"], + ] { + let out = w.cli(&args); + assert!(out.status.success(), "{args:?}: {out:?}"); + assert_eq!( + machine(&out).get("text").and_then(JsonValue::as_str), + Some("--json") + ); + } + assert!(w.0.join("--json").exists()); + assert!(w.cli(&["memory", "ingest", "--json"]).status.success()); + let out = w.cli(&["--json", "memory", "query", "--", "--json", "--json", "5"]); + assert!(out.status.success()); + assert!(!machine(&out) + .get("matches") + .unwrap() + .as_array() + .unwrap() + .is_empty()); +} + +#[test] +fn unicode_surrogates_follow_json_semantics() { + for (encoded, decoded) in [ + (r#""\uD83E\uDD80""#, "🦀"), + (r#""\ud800\udc00""#, "\u{10000}"), + (r#""\uDBFF\uDFFF""#, "\u{10ffff}"), + (r#""\u4e2d\u6587 \ud83d\ude00""#, "中文 😀"), + ] { + assert_eq!(parse_json(encoded).unwrap().as_str(), Some(decoded)); + } + for encoded in [ + r#""\ud800""#, + r#""\udc00""#, + r#""\ud800\u0041""#, + r#""\ud800\ud800""#, + r#""\ud800x""#, + ] { + assert!(parse_json(encoded).is_err(), "{encoded}"); + } + let w = Workspace::new(); + let responses = w.mcp("{\"jsonrpc\":\"2.0\",\"id\":\"\\ud83e\\udd80\",\"method\":\"ping\"}\n"); + assert_eq!( + responses[0].get("id").and_then(JsonValue::as_str), + Some("🦀") + ); +} + +#[test] +fn recovery_check_backup_and_confirmed_restore() { + let w = Workspace::new(); + assert!(w + .cli(&["memory", "ingest", "original event", "state"]) + .status + .success()); + let original = fs::read(w.0.join("state")).unwrap(); + let out = w.cli(&["memory", "check", "state", "--json"]); + assert!(out.status.success()); + assert_eq!( + machine(&out) + .get("checksum_verified") + .and_then(JsonValue::as_bool), + Some(false) + ); + assert_eq!(fs::read(w.0.join("state")).unwrap(), original); + assert!(w + .cli(&["memory", "backup", "state", "backup", "--json"]) + .status + .success()); + assert_eq!(fs::read(w.0.join("backup")).unwrap(), original); + assert!(!w + .cli(&["memory", "backup", "state", "backup", "--json"]) + .status + .success()); + assert!(w + .cli(&["memory", "ingest", "later event", "state"]) + .status + .success()); + let changed = fs::read(w.0.join("state")).unwrap(); + assert!(!w + .cli(&["memory", "restore", "backup", "state", "--json"]) + .status + .success()); + assert_eq!(fs::read(w.0.join("state")).unwrap(), changed); + let out = w.cli(&[ + "memory", + "restore", + "backup", + "state", + "--confirm", + "--json", + ]); + assert!(out.status.success()); + assert_eq!( + machine(&out).get("operation").and_then(JsonValue::as_str), + Some("restore") + ); + assert_eq!(fs::read(w.0.join("state")).unwrap(), original); + assert!(w + .cli(&["memory", "restore", "backup", "new-state", "--json"]) + .status + .success()); + assert_eq!(fs::read(w.0.join("new-state")).unwrap(), original); +} + +#[test] +fn storage_errors_are_nonzero_and_never_claim_success() { + let w = Workspace::new(); + w.corrupt(); + for args in [ + vec!["remember", "new event"], + vec!["recall", "event"], + vec!["stats"], + vec!["memory", "inspect", ".continuum/memory.state"], + vec!["restore", ".continuum/memory.state"], + vec!["init", "."], + ] { + let out = w.cli(&args); + assert!( + !out.status.success(), + "{args:?}: {}", + String::from_utf8_lossy(&out.stdout) + ); + assert!(!String::from_utf8_lossy(&out.stdout).contains("Stored")); + } + assert_eq!(fs::read(w.state()).unwrap(), b"incomplete snapshot"); +} + +#[test] +fn write_failures_and_missing_transcript_are_nonzero() { + let w = Workspace::new(); + fs::write(w.0.join("not-directory"), "file").unwrap(); + for args in [ + vec!["memory", "ingest", "event", "not-directory/state"], + vec!["snapshot", "not-directory/state"], + vec!["init", "not-directory"], + vec!["memory", "sync", "missing.jsonl", "state"], + ] { + assert!(!w.cli(&args).status.success(), "{args:?}"); + } +} + +#[test] +fn machine_roundtrip_preserves_full_unicode_and_step_ids() { + let w = Workspace::new(); + let text = format!( + "中文记忆 🦀\n\t\"完整内容\" {}", + "long provenance 文本 ".repeat(35) + ); + let out = w.cli(&["memory", "ingest", &text, "state", "--json"]); + assert!(out.status.success()); + let result = machine(&out); + assert_eq!(result.get("ok").and_then(JsonValue::as_bool), Some(true)); + assert_eq!( + result.get("event_id").and_then(JsonValue::as_str), + Some("0") + ); + assert_eq!( + result.get("text").and_then(JsonValue::as_str), + Some(text.as_str()) + ); + let second = w.cli(&["memory", "ingest", "another event", "state", "--json"]); + assert_eq!( + machine(&second).get("event_id").and_then(JsonValue::as_str), + Some("1") + ); + let query = w.cli(&["memory", "query", &text, "state", "5", "--json"]); + let result = machine(&query); + let rows = result.get("matches").unwrap().as_array().unwrap(); + assert!(rows.iter().any( + |v| v.get("event_id").and_then(JsonValue::as_str) == Some("0") + && v.get("text").and_then(JsonValue::as_str) == Some(text.as_str()) + )); + let inspect = machine(&w.cli(&["memory", "inspect", "state", "--json"])); + assert_eq!( + inspect.get("step_count").and_then(JsonValue::as_str), + Some("2") + ); + assert!(inspect + .get("records") + .unwrap() + .as_array() + .unwrap() + .iter() + .any(|v| v.get("text").and_then(JsonValue::as_str) == Some(text.as_str()))); + let human = w.cli(&["memory", "inspect", "state"]); + let human = String::from_utf8(human.stdout).unwrap(); + assert!(human.contains(" Total Steps: 2")); + assert!(human.contains(" Hot Memory: ")); +} + +#[test] +fn invalid_cli_inputs_have_stable_machine_error_codes() { + let w = Workspace::new(); + for args in [ + vec!["memory", "ingest", " ", "state", "--json"], + vec!["memory", "query", "event", "state", "0", "--json"], + vec!["memory", "query", "event", "state", "1.2", "--json"], + vec!["memory", "query", "event", "state", "10001", "--json"], + vec!["--json", "memory", "ingest"], + vec!["memory", "inspect", "state", "extra", "--json"], + ] { + let out = w.cli(&args); + assert_eq!(out.status.code(), Some(2), "{args:?}"); + assert_eq!( + machine(&out) + .get_path(&["error", "code"]) + .and_then(JsonValue::as_str), + Some("INVALID_INPUT") + ); + } + assert!(!w.0.join("state").exists()); + let out = w.cli(&["memory", "inspect", "missing", "--json"]); + assert_eq!(out.status.code(), Some(1)); + assert_eq!( + machine(&out) + .get_path(&["error", "code"]) + .and_then(JsonValue::as_str), + Some("NOT_FOUND") + ); +} + +#[test] +fn mcp_storage_failures_are_tool_errors_without_empty_fallback() { + let w = Workspace::new(); + w.corrupt(); + let messages = w.mcp(concat!( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"continuum_remember\",\"arguments\":{\"text\":\"hello\"}}}\n", + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"continuum_recall\",\"arguments\":{\"query\":\"hello\"}}}\n", + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"continuum_stats\"}}\n" + )); + assert_eq!(messages.len(), 3); + for message in messages { + assert_eq!( + message + .get_path(&["result", "isError"]) + .and_then(JsonValue::as_bool), + Some(true) + ); + let content = message + .get_path(&["result", "content"]) + .unwrap() + .as_array() + .unwrap(); + assert!(content[0] + .get("text") + .unwrap() + .as_str() + .unwrap() + .contains("INVALID_STATE")); + } + assert_eq!(fs::read(w.state()).unwrap(), b"incomplete snapshot"); +} + +#[test] +fn mcp_validates_arguments_and_protocol_and_stays_usable() { + let w = Workspace::new(); + let requests = concat!( + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n", + "{\"jsonrpc\":\"2.0\",\"id\":\"init\",\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test\",\"version\":\"1\"}}}\n", + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}\n", + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"continuum_remember\",\"arguments\":{}}}\n", + "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"continuum_recall\",\"arguments\":{\"query\":\"hello\",\"top_k\":1.5}}}\n", + "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"unknown\"}\n", + "not json\n", + "{\"jsonrpc\":\"2.0\",\"id\":\"ping\\t\\n\",\"method\":\"ping\"}\n" + ); + let messages = w.mcp(requests); + assert_eq!(messages.len(), 7); + assert_eq!( + messages[0] + .get_path(&["result", "protocolVersion"]) + .and_then(JsonValue::as_str), + Some("2024-11-05") + ); + assert_eq!( + messages[1] + .get_path(&["result", "tools"]) + .unwrap() + .as_array() + .unwrap() + .len(), + 3 + ); + for index in [2, 3] { + assert_eq!( + messages[index] + .get_path(&["result", "isError"]) + .and_then(JsonValue::as_bool), + Some(true) + ); + } + assert_eq!( + messages[4] + .get_path(&["error", "code"]) + .and_then(JsonValue::as_i64), + Some(-32601) + ); + assert_eq!( + messages[5] + .get_path(&["error", "code"]) + .and_then(JsonValue::as_i64), + Some(-32700) + ); + assert_eq!( + messages[6].get("id").and_then(JsonValue::as_str), + Some("ping\t\n") + ); + assert!(!w.state().exists()); +} + +#[test] +fn transcript_read_or_parse_failure_does_not_replace_state() { + let w = Workspace::new(); + assert!(w + .cli(&["memory", "ingest", "keep me", "state"]) + .status + .success()); + let before = fs::read(w.0.join("state")).unwrap(); + fs::write( + w.0.join("transcript"), + b"{\"content\":\"valid event\"}\nnot json\n", + ) + .unwrap(); + assert!(!w + .cli(&["memory", "sync", "transcript", "state"]) + .status + .success()); + assert_eq!(fs::read(w.0.join("state")).unwrap(), before); + fs::write(w.0.join("transcript"), [0xff, 0xfe]).unwrap(); + assert!(!w + .cli(&["memory", "sync", "transcript", "state"]) + .status + .success()); + assert_eq!(fs::read(w.0.join("state")).unwrap(), before); +} diff --git a/crates/continuum-core/Cargo.toml b/crates/continuum-core/Cargo.toml index 706c72d..682e5a7 100644 --- a/crates/continuum-core/Cargo.toml +++ b/crates/continuum-core/Cargo.toml @@ -2,6 +2,7 @@ name = "continuum-core" version = "0.1.0" edition = "2024" +rust-version = "1.89" [lib] crate-type = ["rlib", "cdylib"] diff --git a/crates/continuum-core/src/lib.rs b/crates/continuum-core/src/lib.rs index ecc9a68..f7070a8 100644 --- a/crates/continuum-core/src/lib.rs +++ b/crates/continuum-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod lock; pub mod math; pub mod persistence; pub mod revision; +pub mod recovery; pub mod semantic_bridge; pub mod temporal; pub mod types; @@ -19,10 +20,8 @@ pub mod types; pub use embedder::RealTextEmbedder; pub use engine::ContinuumEngine; pub use lock::FileLockGuard; -pub use persistence::{ - load_engine, load_engine_unlocked, mutate_engine_transactional, save_engine, - save_engine_unlocked, -}; +pub use persistence::{load_engine, mutate_engine_transactional, save_engine}; +pub use recovery::{SnapshotInfo, backup_engine, check_snapshot, restore_engine}; pub use semantic_bridge::SemanticCausalBridge; pub use types::{CausalMatch, ContinuumConfig, ScoreComponents, StreamStepResult}; diff --git a/crates/continuum-core/src/lock.rs b/crates/continuum-core/src/lock.rs index a9b444d..d395265 100644 --- a/crates/continuum-core/src/lock.rs +++ b/crates/continuum-core/src/lock.rs @@ -1,119 +1,52 @@ -//! Cross-Process File Locking in pure Rust standard library. +//! OS advisory locks for cooperating local processes (Rust 1.89+). //! -//! Provides advisory locking with stale lock auto-expiration and timeout. -//! ZERO external crate dependencies. +//! The sidecar inode is permanent: never delete it or steal a lock based on age. +//! Stop ALL old create-new-protocol writers before upgrading or rolling back. +//! All participants must use the same path; hard-link aliases are not supported. -use std::fs::{File, OpenOptions}; -use std::io::{Read, Write}; +use std::fs::{File, OpenOptions, TryLockError}; +use std::io; use std::path::{Path, PathBuf}; -use std::thread::sleep; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant}; -/// RAII Guard for an acquired cross-process file lock. -/// Automatically releases the lock when dropped. +/// Exclusive OS lock released by closing its owned file, including on process exit. #[derive(Debug)] pub struct FileLockGuard { + _file: File, lock_path: PathBuf, } impl FileLockGuard { - /// Attempts to acquire an exclusive advisory file lock within `timeout`. - pub fn acquire(path: impl AsRef, timeout: Duration) -> std::io::Result { - let lock_path = PathBuf::from(format!("{}.lock", path.as_ref().display())); + /// Acquire within `timeout`. A zero timeout makes one nonblocking attempt. + /// The target's parent must already exist. Never remove the sidecar file. + pub fn acquire(path: impl AsRef, timeout: Duration) -> io::Result { + let mut name = path.as_ref().as_os_str().to_os_string(); + name.push(".lock"); + let lock_path = PathBuf::from(name); let start = Instant::now(); - let pid = std::process::id(); - + let file = OpenOptions::new().read(true).write(true).create(true) + .truncate(false).open(&lock_path)?; loop { - // Attempt atomic creation of lockfile - match OpenOptions::new().write(true).create_new(true).open(&lock_path) { - Ok(mut file) => { - let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); - let payload = format!("pid={}\ntime={}\n", pid, now); - let _ = file.write_all(payload.as_bytes()); - let _ = file.sync_all(); - return Ok(Self { lock_path }); - } - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { - // Check if existing lock is stale (> 5 seconds old) - if Self::is_lock_stale(&lock_path) { - let _ = std::fs::remove_file(&lock_path); - continue; - } - - if start.elapsed() >= timeout { - return Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - format!("Timed out acquiring lock on '{}'", lock_path.display()), - )); + match file.try_lock() { + Ok(()) => return Ok(Self { _file: file, lock_path }), + Err(TryLockError::WouldBlock) => { + let remaining = timeout.saturating_sub(start.elapsed()); + if remaining.is_zero() { + return Err(io::Error::new(io::ErrorKind::TimedOut, + format!("Timed out acquiring lock on '{}'", lock_path.display()))); } - - // Jittered backoff: 2ms to 20ms - let elapsed_ms = start.elapsed().as_millis(); - let backoff = Duration::from_millis(2 + (elapsed_ms % 18) as u64); - sleep(backoff); + std::thread::sleep(remaining.min(Duration::from_millis(10))); } - Err(e) => return Err(e), - } - } - } - - /// Checks if a lock file is stale (held by a dead process or > 5s old). - fn is_lock_stale(lock_path: &Path) -> bool { - if let Ok(mut f) = File::open(lock_path) { - let mut buf = String::new(); - if f.read_to_string(&mut buf).is_ok() { - for line in buf.lines() { - if let Some(rest) = line.strip_prefix("time=") { - if let Ok(ts) = rest.trim().parse::() { - let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); - if now.saturating_sub(ts) > 5 { - return true; // Stale lock older than 5 seconds - } - } + Err(TryLockError::Error(e)) if e.kind() == io::ErrorKind::Interrupted => { + if start.elapsed() >= timeout { + return Err(io::Error::new(io::ErrorKind::TimedOut, "Lock acquisition interrupted until timeout")); } } + Err(TryLockError::Error(e)) => return Err(e), } } - false - } - - /// Return the path of the active lockfile. - pub fn lock_path(&self) -> &Path { - &self.lock_path - } -} - -impl Drop for FileLockGuard { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.lock_path); } -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn test_lock_acquire_and_release() { - let temp_dir = std::env::temp_dir(); - let target = temp_dir.join(format!("continuum_lock_test_{}", std::process::id())); - let lock_file = PathBuf::from(format!("{}.lock", target.display())); - - { - let guard = FileLockGuard::acquire(&target, Duration::from_millis(500)).expect("Lock acquire failed"); - assert!(lock_file.exists()); - assert_eq!(guard.lock_path(), &lock_file); - - // Second acquire must fail / timeout while guard is held - let second = FileLockGuard::acquire(&target, Duration::from_millis(50)); - assert!(second.is_err()); - } - - // After guard is dropped, lockfile must be removed - assert!(!lock_file.exists()); - - // Now lock can be re-acquired immediately - let guard2 = FileLockGuard::acquire(&target, Duration::from_millis(500)); - assert!(guard2.is_ok()); - } + /// Path of the permanent sidecar, not evidence that a lock is held. + pub fn lock_path(&self) -> &Path { &self.lock_path } } diff --git a/crates/continuum-core/src/persistence.rs b/crates/continuum-core/src/persistence.rs index 0e8abcc..58a0b7f 100644 --- a/crates/continuum-core/src/persistence.rs +++ b/crates/continuum-core/src/persistence.rs @@ -1,427 +1,241 @@ -//! High-speed zero-dependency binary state persistence for ContinuumEngine. -//! -//! Enables zero-loss snapshotting of hot/cold memory manifolds, temporal state, -//! and causal index records to disk in < 1 millisecond. - -use std::fs::File; -use std::io::{self, BufReader, BufWriter, Error, ErrorKind, Read, Write}; -use std::path::Path; - -use crate::cold_memory::DiversifiedColdMemory; -use crate::engine::ContinuumEngine; -use crate::hot_memory::HotMemoryBank; -use crate::revision::RevisionEngine; -use crate::temporal::TemporalCore; -use crate::types::{ColdRecord, ContinuumConfig, HotRecord}; - -const MAGIC_HEADER: &[u8; 8] = b"CTNM0001"; - -pub fn save_engine_unlocked(engine: &ContinuumEngine, path: impl AsRef) -> io::Result<()> { - let target = path.as_ref(); - let parent = target.parent().unwrap_or_else(|| Path::new(".")); - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent)?; - } - - let tmp_path = parent.join(format!( - ".{}.tmp.{}.{}", - target.file_name().and_then(|n| n.to_str()).unwrap_or("state"), - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - - { - let file = File::create(&tmp_path)?; - let mut writer = BufWriter::new(file); - - // 1. Magic Header - writer.write_all(MAGIC_HEADER)?; - - // 2. Config - writer.write_all(&(engine.config.embedding_dim as u64).to_le_bytes())?; - writer.write_all(&(engine.config.state_dim as u64).to_le_bytes())?; - writer.write_all(&(engine.config.hot_capacity as u64).to_le_bytes())?; - writer.write_all(&(engine.config.cold_capacity as u64).to_le_bytes())?; - writer.write_all(&engine.config.sim_threshold.to_le_bytes())?; - - match engine.config.causal_exempt_threshold { - Some(th) => { - writer.write_all(&[1u8])?; - writer.write_all(&th.to_le_bytes())?; - } - None => { - writer.write_all(&[0u8])?; - } - } - - writer.write_all(&engine.config.temporal_decay_tau.to_le_bytes())?; - writer.write_all(&engine.config.w_sim.to_le_bytes())?; - writer.write_all(&engine.config.w_state_compat.to_le_bytes())?; - writer.write_all(&engine.config.w_temporal_compat.to_le_bytes())?; - writer.write_all(&engine.config.w_provenance_compat.to_le_bytes())?; - - // 3. Engine metadata - writer.write_all(&engine.step_count.to_le_bytes())?; - - // 4. Temporal Core state - let cur_state = engine.temporal_core.current_state(); - writer.write_all(&(cur_state.len() as u64).to_le_bytes())?; - for &val in cur_state { - writer.write_all(&val.to_le_bytes())?; - } - - // 5. Hot Memory Bank - let hot_records = &engine.hot_memory.records; - writer.write_all(&(hot_records.len() as u64).to_le_bytes())?; - for r in hot_records { - writer.write_all(&r.event_id.to_le_bytes())?; - writer.write_all(&r.timestamp.to_le_bytes())?; - - writer.write_all(&(r.embedding.len() as u64).to_le_bytes())?; - for &val in &r.embedding { - writer.write_all(&val.to_le_bytes())?; - } +//! Locked, bounded CTNM0001 snapshots. No checksum: structural validation cannot +//! detect every bit flip. Nonserialized runtime tuning is reset on load. +use std::fs::{File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; +use crate::{ContinuumConfig, ContinuumEngine, FileLockGuard}; +use crate::types::{HotRecord, ColdRecord}; + +const MAGIC: &[u8; 8] = b"CTNM0001"; +/// Maximum serialized snapshot size, including all text and vectors (64 MiB). +pub const MAX_SNAPSHOT_BYTES: usize = 64 * 1024 * 1024; +/// Maximum dimension of an embedding or temporal state. +pub const MAX_DIMENSION: usize = 4096; +/// Maximum capacity of either memory bank. +pub const MAX_CAPACITY: usize = 100_000; +/// Maximum UTF-8 bytes in one persisted payload/provenance string. +pub const MAX_TEXT_BYTES: usize = 1024 * 1024; +static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); +fn invalid(message: &str) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, message) } + +pub(crate) fn parent(path: &Path) -> &Path { + path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")) +} - writer.write_all(&(r.state_snapshot.len() as u64).to_le_bytes())?; - for &val in &r.state_snapshot { - writer.write_all(&val.to_le_bytes())?; +// RAII handles all ordinary error/unwind paths; SIGKILL can leave a harmless +// unreferenced temp. Never clean up another writer's temp by age. +struct TempPath(PathBuf); +impl Drop for TempPath { fn drop(&mut self) { let _ = std::fs::remove_file(&self.0); } } + +/// Publish only after flushing the complete temp file. Directory-sync failure +/// after publication is returned as an error: the new state may already be visible. +pub(crate) fn atomic_write( + target: &Path, create_only: bool, write: impl FnOnce(&mut File) -> io::Result<()>, +) -> io::Result<()> { + std::fs::create_dir_all(parent(target))?; + let dir = File::open(parent(target))?; + let (temp, mut file) = loop { + let mut name = std::ffi::OsString::from("."); + name.push(target.file_name().ok_or_else(|| invalid("Snapshot requires a filename"))?); + name.push(format!(".tmp.{}.{}", std::process::id(), NEXT_TEMP.fetch_add(1, Ordering::Relaxed))); + let path = parent(target).join(name); + let mut options = OpenOptions::new(); options.write(true).create_new(true); + #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } + match options.open(&path) { + Ok(file) => break (TempPath(path), file), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), } - - writer.write_all(&r.importance.to_le_bytes())?; - - let payload_bytes = r.payload_ref.as_bytes(); - writer.write_all(&(payload_bytes.len() as u64).to_le_bytes())?; - writer.write_all(payload_bytes)?; + }; + write(&mut file)?; + file.sync_all()?; + drop(file); + if create_only { + // Same-directory hard link is an atomic no-clobber publication. + std::fs::hard_link(&temp.0, target)?; + std::fs::remove_file(&temp.0)?; + } else { + std::fs::rename(&temp.0, target)?; } + dir.sync_all()?; + Ok(()) +} - // 6. Cold Memory Candidate Archive - let cold_records = &engine.cold_memory.records; - writer.write_all(&(cold_records.len() as u64).to_le_bytes())?; - for r in cold_records { - writer.write_all(&r.event_id.to_le_bytes())?; - writer.write_all(&r.timestamp.to_le_bytes())?; - - writer.write_all(&(r.compressed_embedding.len() as u64).to_le_bytes())?; - for &val in &r.compressed_embedding { - writer.write_all(&val.to_le_bytes())?; - } - - writer.write_all(&(r.state_fingerprint.len() as u64).to_le_bytes())?; - for &val in &r.state_fingerprint { - writer.write_all(&val.to_le_bytes())?; - } - - writer.write_all(&r.importance_at_eviction.to_le_bytes())?; - - let prov_bytes = r.provenance_summary.as_bytes(); - writer.write_all(&(prov_bytes.len() as u64).to_le_bytes())?; - writer.write_all(prov_bytes)?; +fn validate_config(c: &ContinuumConfig) -> io::Result<()> { + if !(1..=MAX_DIMENSION).contains(&c.embedding_dim) || !(1..=MAX_DIMENSION).contains(&c.state_dim) + || !(1..=MAX_CAPACITY).contains(&c.hot_capacity) || !(1..=MAX_CAPACITY).contains(&c.cold_capacity) { + return Err(invalid("Dimensions/capacities outside supported snapshot limits")); } - - writer.flush()?; - let f = writer.into_inner().map_err(|e| e.into_error())?; - f.sync_all()?; + if !(-1.0..=1.0).contains(&c.sim_threshold) + || c.causal_exempt_threshold.is_some_and(|v| !(-1.0..=1.0).contains(&v)) + || !c.temporal_decay_tau.is_finite() || c.temporal_decay_tau <= 0.0 + || [c.w_sim, c.w_state_compat, c.w_temporal_compat, c.w_provenance_compat].iter().any(|v| !v.is_finite() || *v < 0.0) { + return Err(invalid("Invalid finite thresholds, decay or weights")); } - - std::fs::rename(&tmp_path, target)?; Ok(()) } - -pub fn load_engine_unlocked(path: impl AsRef) -> io::Result { - let target = path.as_ref(); - let file = File::open(target)?; - let mut reader = BufReader::new(file); - - // 1. Magic Header - let mut magic = [0u8; 8]; - reader.read_exact(&mut magic)?; - if &magic != MAGIC_HEADER { - return Err(Error::new( - ErrorKind::InvalidData, - "Invalid Continuum checkpoint header", - )); - } - - // 2. Config - let mut buf8 = [0u8; 8]; - let mut buf4 = [0u8; 4]; - - reader.read_exact(&mut buf8)?; - let embedding_dim = u64::from_le_bytes(buf8) as usize; - - reader.read_exact(&mut buf8)?; - let state_dim = u64::from_le_bytes(buf8) as usize; - - reader.read_exact(&mut buf8)?; - let hot_capacity = u64::from_le_bytes(buf8) as usize; - - reader.read_exact(&mut buf8)?; - let cold_capacity = u64::from_le_bytes(buf8) as usize; - - reader.read_exact(&mut buf4)?; - let sim_threshold = f32::from_le_bytes(buf4); - - let mut flag = [0u8; 1]; - reader.read_exact(&mut flag)?; - let causal_exempt_threshold = if flag[0] == 1 { - reader.read_exact(&mut buf4)?; - Some(f32::from_le_bytes(buf4)) - } else { - None - }; - - reader.read_exact(&mut buf4)?; - let temporal_decay_tau = f32::from_le_bytes(buf4); - - reader.read_exact(&mut buf4)?; - let w_sim = f32::from_le_bytes(buf4); - - reader.read_exact(&mut buf4)?; - let w_state_compat = f32::from_le_bytes(buf4); - - reader.read_exact(&mut buf4)?; - let w_temporal_compat = f32::from_le_bytes(buf4); - - reader.read_exact(&mut buf4)?; - let w_provenance_compat = f32::from_le_bytes(buf4); - - let config = ContinuumConfig { - embedding_dim, - state_dim, - hot_capacity, - cold_capacity, - sim_threshold, - causal_exempt_threshold, - temporal_decay_tau, - w_sim, - w_state_compat, - w_temporal_compat, - w_provenance_compat, - }; - - // 3. Step count - reader.read_exact(&mut buf8)?; - let step_count = u64::from_le_bytes(buf8); - - // 4. Temporal Core - let mut temporal_core = TemporalCore::new(embedding_dim, state_dim); - reader.read_exact(&mut buf8)?; - let cur_state_len = u64::from_le_bytes(buf8) as usize; - let mut cur_state = Vec::with_capacity(cur_state_len); - for _ in 0..cur_state_len { - reader.read_exact(&mut buf4)?; - cur_state.push(f32::from_le_bytes(buf4)); +fn validate_vector(v: &[f32], dim: usize) -> io::Result<()> { + if v.len() != dim || v.iter().any(|x| !x.is_finite()) { return Err(invalid("Invalid vector dimensions or non-finite values")); } + Ok(()) +} +fn validate_engine(e: &ContinuumEngine) -> io::Result<()> { + validate_config(&e.config)?; + validate_vector(e.temporal_core.current_state(), e.config.state_dim)?; + if e.hot_memory.len() > e.config.hot_capacity || e.cold_memory.len() > e.config.cold_capacity { + return Err(invalid("Record count exceeds capacity")); } - temporal_core.state = cur_state; - - // 5. Hot Memory Bank - let mut hot_memory = HotMemoryBank::new(hot_capacity); - reader.read_exact(&mut buf8)?; - let num_hot = u64::from_le_bytes(buf8) as usize; - - for _ in 0..num_hot { - reader.read_exact(&mut buf8)?; - let event_id = u64::from_le_bytes(buf8); - - reader.read_exact(&mut buf8)?; - let timestamp = f64::from_le_bytes(buf8); - - reader.read_exact(&mut buf8)?; - let emb_len = u64::from_le_bytes(buf8) as usize; - let mut embedding = Vec::with_capacity(emb_len); - for _ in 0..emb_len { - reader.read_exact(&mut buf4)?; - embedding.push(f32::from_le_bytes(buf4)); - } - - reader.read_exact(&mut buf8)?; - let st_len = u64::from_le_bytes(buf8) as usize; - let mut state_snapshot = Vec::with_capacity(st_len); - for _ in 0..st_len { - reader.read_exact(&mut buf4)?; - state_snapshot.push(f32::from_le_bytes(buf4)); + // Fixed header is <= 101 bytes; each record adds 44 plus vectors/text. + let mut bytes = 101 + e.config.state_dim * 4; + let mut ids = std::collections::HashSet::new(); + let records = e.hot_memory.records.iter().map(|r| (r.event_id, r.timestamp, &r.embedding, &r.state_snapshot, r.importance, &r.payload_ref)) + .chain(e.cold_memory.records.iter().map(|r| (r.event_id, r.timestamp, &r.compressed_embedding, &r.state_fingerprint, r.importance_at_eviction, &r.provenance_summary))); + for (id, timestamp, embedding, state, importance, text) in records { + validate_vector(embedding, e.config.embedding_dim)?; + validate_vector(state, e.config.state_dim)?; + if id >= e.step_count || !ids.insert(id) || !timestamp.is_finite() || !importance.is_finite() || text.len() > MAX_TEXT_BYTES { + return Err(invalid("Invalid record metadata or text length")); } - - reader.read_exact(&mut buf4)?; - let importance = f32::from_le_bytes(buf4); - - reader.read_exact(&mut buf8)?; - let p_len = u64::from_le_bytes(buf8) as usize; - let mut p_bytes = vec![0u8; p_len]; - reader.read_exact(&mut p_bytes)?; - let payload_ref = String::from_utf8(p_bytes) - .map_err(|e| Error::new(ErrorKind::InvalidData, e))?; - - hot_memory.records.push(HotRecord { - event_id, - timestamp, - embedding, - state_snapshot, - importance, - payload_ref, - }); + bytes += 44 + embedding.len() * 4 + state.len() * 4 + text.len(); + if bytes > MAX_SNAPSHOT_BYTES { return Err(invalid("Snapshot exceeds byte limit")); } } + Ok(()) +} - // 6. Cold Memory Candidate Archive - let mut cold_memory = DiversifiedColdMemory::new(cold_capacity, sim_threshold); - reader.read_exact(&mut buf8)?; - let num_cold = u64::from_le_bytes(buf8) as usize; - - for _ in 0..num_cold { - reader.read_exact(&mut buf8)?; - let event_id = u64::from_le_bytes(buf8); - - reader.read_exact(&mut buf8)?; - let timestamp = f64::from_le_bytes(buf8); - - reader.read_exact(&mut buf8)?; - let emb_len = u64::from_le_bytes(buf8) as usize; - let mut compressed_embedding = Vec::with_capacity(emb_len); - for _ in 0..emb_len { - reader.read_exact(&mut buf4)?; - compressed_embedding.push(f32::from_le_bytes(buf4)); +fn write_u64(w: &mut impl Write, v: u64) -> io::Result<()> { w.write_all(&v.to_le_bytes()) } +fn write_f32(w: &mut impl Write, v: f32) -> io::Result<()> { w.write_all(&v.to_le_bytes()) } +fn write_vector(w: &mut impl Write, v: &[f32]) -> io::Result<()> { + write_u64(w, v.len() as u64)?; + for &value in v { write_f32(w, value)?; } Ok(()) +} +pub(crate) fn save_engine_unlocked(e: &ContinuumEngine, path: impl AsRef) -> io::Result<()> { + validate_engine(e)?; + atomic_write(path.as_ref(), false, |file| { + let mut w = io::BufWriter::new(file); + w.write_all(MAGIC)?; + let c = &e.config; + for v in [c.embedding_dim, c.state_dim, c.hot_capacity, c.cold_capacity] { write_u64(&mut w, v as u64)?; } + write_f32(&mut w, c.sim_threshold)?; + match c.causal_exempt_threshold { + Some(v) => { w.write_all(&[1])?; write_f32(&mut w, v)?; } + None => w.write_all(&[0])?, } - - reader.read_exact(&mut buf8)?; - let st_len = u64::from_le_bytes(buf8) as usize; - let mut state_fingerprint = Vec::with_capacity(st_len); - for _ in 0..st_len { - reader.read_exact(&mut buf4)?; - state_fingerprint.push(f32::from_le_bytes(buf4)); + for v in [c.temporal_decay_tau, c.w_sim, c.w_state_compat, c.w_temporal_compat, c.w_provenance_compat] { write_f32(&mut w, v)?; } + write_u64(&mut w, e.step_count)?; + write_vector(&mut w, e.temporal_core.current_state())?; + write_u64(&mut w, e.hot_memory.len() as u64)?; + for r in &e.hot_memory.records { + write_record(&mut w, r.event_id, r.timestamp, &r.embedding, &r.state_snapshot, r.importance, &r.payload_ref)?; } - - reader.read_exact(&mut buf4)?; - let importance_at_eviction = f32::from_le_bytes(buf4); - - reader.read_exact(&mut buf8)?; - let p_len = u64::from_le_bytes(buf8) as usize; - let mut p_bytes = vec![0u8; p_len]; - reader.read_exact(&mut p_bytes)?; - let provenance_summary = String::from_utf8(p_bytes) - .map_err(|e| Error::new(ErrorKind::InvalidData, e))?; - - cold_memory.records.push(ColdRecord { - event_id, - timestamp, - compressed_embedding, - state_fingerprint, - importance_at_eviction, - provenance_summary, - }); - } - - let revision_engine = RevisionEngine::new(config.clone()); - - Ok(ContinuumEngine { - config, - temporal_core, - hot_memory, - cold_memory, - revision_engine, - step_count, + write_u64(&mut w, e.cold_memory.len() as u64)?; + for r in &e.cold_memory.records { + write_record(&mut w, r.event_id, r.timestamp, &r.compressed_embedding, &r.state_fingerprint, r.importance_at_eviction, &r.provenance_summary)?; + } + w.flush() }) } +fn write_record(w: &mut impl Write, id: u64, timestamp: f64, embedding: &[f32], state: &[f32], importance: f32, text: &str) -> io::Result<()> { + write_u64(w, id)?; w.write_all(×tamp.to_le_bytes())?; + write_vector(w, embedding)?; write_vector(w, state)?; + write_f32(w, importance)?; write_u64(w, text.len() as u64)?; w.write_all(text.as_bytes()) +} -/// Thread/process-safe save with exclusive lock. +// Slice reader checks lengths against remaining bytes BEFORE allocating. +struct Decoder<'a>(&'a [u8]); +impl<'a> Decoder<'a> { + fn take(&mut self, n: usize) -> io::Result<&'a [u8]> { + if n > self.0.len() { return Err(invalid("Truncated snapshot")); } + let (value, rest) = self.0.split_at(n); self.0 = rest; Ok(value) + } + fn u64(&mut self) -> io::Result { Ok(u64::from_le_bytes(self.take(8)?.try_into().unwrap())) } + fn f32(&mut self) -> io::Result { Ok(f32::from_le_bytes(self.take(4)?.try_into().unwrap())) } + fn len(&mut self, max: usize) -> io::Result { + let n = usize::try_from(self.u64()?).map_err(|_| invalid("Length not representable"))?; + if n > max { return Err(invalid("Length exceeds supported limit")); } Ok(n) + } + fn vector(&mut self, dim: usize) -> io::Result> { + if self.len(dim)? != dim { return Err(invalid("Vector length differs from configured dimension")); } + let bytes = self.take(dim * 4)?; + let v: Vec = bytes.chunks_exact(4).map(|b| f32::from_le_bytes(b.try_into().unwrap())).collect(); + validate_vector(&v, dim)?; Ok(v) + } + fn record(&mut self, c: &ContinuumConfig) -> io::Result { + let event_id = self.u64()?; + let timestamp = f64::from_le_bytes(self.take(8)?.try_into().unwrap()); + let embedding = self.vector(c.embedding_dim)?; + let state_snapshot = self.vector(c.state_dim)?; + let importance = self.f32()?; + let n = self.len(MAX_TEXT_BYTES)?; + let payload_ref = std::str::from_utf8(self.take(n)?).map_err(|_| invalid("Invalid UTF-8 text"))?.to_owned(); + Ok(HotRecord { event_id, timestamp, embedding, state_snapshot, importance, payload_ref }) + } +} +pub(crate) fn read_snapshot_bytes(path: &Path) -> io::Result> { + let file = File::open(path)?; + if !file.metadata()?.is_file() { return Err(invalid("Snapshot must be a regular file")); } + if file.metadata()?.len() > MAX_SNAPSHOT_BYTES as u64 { return Err(invalid("Snapshot exceeds byte limit")); } + let mut bytes = Vec::new(); + file.take(MAX_SNAPSHOT_BYTES as u64 + 1).read_to_end(&mut bytes)?; + if bytes.len() > MAX_SNAPSHOT_BYTES { return Err(invalid("Snapshot exceeds byte limit")); } Ok(bytes) +} +pub(crate) fn decode(bytes: &[u8]) -> io::Result { + if bytes.len() > MAX_SNAPSHOT_BYTES { return Err(invalid("Snapshot exceeds byte limit")); } + let mut r = Decoder(bytes); + if r.take(8)? != MAGIC { return Err(invalid("Invalid Continuum checkpoint header")); } + let embedding_dim = r.len(MAX_DIMENSION)?; + let state_dim = r.len(MAX_DIMENSION)?; + let hot_capacity = r.len(MAX_CAPACITY)?; + let cold_capacity = r.len(MAX_CAPACITY)?; + let sim_threshold = r.f32()?; + let causal_exempt_threshold = match r.take(1)?[0] { 0 => None, 1 => Some(r.f32()?), _ => return Err(invalid("Invalid optional threshold flag")) }; + let c = ContinuumConfig { embedding_dim, state_dim, hot_capacity, cold_capacity, sim_threshold, causal_exempt_threshold, + temporal_decay_tau: r.f32()?, w_sim: r.f32()?, w_state_compat: r.f32()?, w_temporal_compat: r.f32()?, w_provenance_compat: r.f32()? }; + validate_config(&c)?; + let step_count = r.u64()?; + let state = r.vector(state_dim)?; + let mut e = ContinuumEngine::new(c); + e.step_count = step_count; e.temporal_core.state = state; + let count = r.len(hot_capacity)?; + for _ in 0..count { e.hot_memory.records.push(r.record(&e.config)?); } + let count = r.len(cold_capacity)?; + for _ in 0..count { + let h = r.record(&e.config)?; + e.cold_memory.records.push(ColdRecord { event_id: h.event_id, timestamp: h.timestamp, compressed_embedding: h.embedding, + state_fingerprint: h.state_snapshot, importance_at_eviction: h.importance, provenance_summary: h.payload_ref }); + } + if !r.0.is_empty() { return Err(invalid("Unexpected trailing snapshot bytes")); } + validate_engine(&e)?; Ok(e) +} +pub(crate) fn load_engine_unlocked(path: impl AsRef) -> io::Result { + decode(&read_snapshot_bytes(path.as_ref())?) +} +/// Save under an exclusive lock. For read-modify-write use the transactional API, +/// not separate load/save calls. Stops old-protocol writers before first use. pub fn save_engine(engine: &ContinuumEngine, path: impl AsRef) -> io::Result<()> { - let target = path.as_ref(); - let _lock = crate::lock::FileLockGuard::acquire(target, std::time::Duration::from_millis(2000))?; + let target = path.as_ref(); std::fs::create_dir_all(parent(target))?; + let _lock = FileLockGuard::acquire(target, Duration::from_secs(2))?; save_engine_unlocked(engine, target) } - -/// Thread/process-safe load with exclusive lock. +/// Load a validated snapshot under an exclusive advisory lock. pub fn load_engine(path: impl AsRef) -> io::Result { let target = path.as_ref(); - let _lock = crate::lock::FileLockGuard::acquire(target, std::time::Duration::from_millis(2000))?; + let _lock = FileLockGuard::acquire(target, Duration::from_secs(2))?; load_engine_unlocked(target) } - -/// Transactional Read-Modify-Write (RMW) mutation on ContinuumEngine state file. -/// Holds the cross-process lock across the ENTIRE load -> mutate -> atomic save lifecycle, -/// eliminating race conditions and lost updates under concurrent multi-agent access. -pub fn mutate_engine_transactional( - path: impl AsRef, - default_config: Option, - f: F, -) -> io::Result -where - F: FnOnce(&mut ContinuumEngine) -> io::Result, -{ - let target = path.as_ref(); - let parent = target.parent().unwrap_or_else(|| Path::new(".")); - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent)?; - } - - let _lock = crate::lock::FileLockGuard::acquire(target, std::time::Duration::from_millis(3000))?; - - let mut engine = if target.exists() { - load_engine_unlocked(target)? - } else { - ContinuumEngine::new(default_config.unwrap_or_default()) - }; - - let res = f(&mut engine)?; - - save_engine_unlocked(&engine, target)?; - - Ok(res) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_atomic_persistence_and_clean_state() { - let temp_dir = std::env::temp_dir(); - let test_path = temp_dir.join(format!("continuum_atomic_{}.state", std::process::id())); - - let mut engine = ContinuumEngine::new(ContinuumConfig::default()); - let emb = vec![0.1f32; 32]; - engine.step(&emb, 1.0, "Atomic persistence test constraint"); - - // Save atomically - save_engine(&engine, &test_path).expect("Failed atomic save"); - - // Lockfile should not linger - let lock_path = format!("{}.lock", test_path.display()); - assert!(!Path::new(&lock_path).exists(), "Lockfile was not cleaned up"); - - // Load back and verify bit-exact consistency - let loaded = load_engine(&test_path).expect("Failed load"); - assert_eq!(loaded.step_count, 1); - assert_eq!(loaded.hot_memory.len(), 1); - assert_eq!(loaded.hot_memory.records[0].payload_ref, "Atomic persistence test constraint"); - - let _ = std::fs::remove_file(&test_path); - } - - #[test] - fn test_mutate_engine_transactional_rmw() { - let temp_dir = std::env::temp_dir(); - let test_path = temp_dir.join(format!("continuum_rmw_{}.state", std::process::id())); - - // Perform 3 sequential transactional mutations - for i in 1..=3 { - let res = mutate_engine_transactional(&test_path, None, |eng| { - let emb = vec![0.05f32 * (i as f32); 32]; - eng.step(&emb, i as f64, &format!("Transactional Event #{i}")); - Ok(eng.step_count) - }); - assert_eq!(res.unwrap(), i as u64); +/// Hold the lock over the entire load/mutate/validate/atomic-save transaction. +/// Only NotFound initializes a new state. Closure errors do not commit. +/// Do not call a locking API for this path from inside the closure. +pub fn mutate_engine_transactional(path: impl AsRef, default_config: Option, f: F) -> io::Result +where F: FnOnce(&mut ContinuumEngine) -> io::Result { + let target = path.as_ref(); std::fs::create_dir_all(parent(target))?; + let _lock = FileLockGuard::acquire(target, Duration::from_secs(3))?; + let mut e = match load_engine_unlocked(target) { + Ok(e) => e, + Err(err) if err.kind() == io::ErrorKind::NotFound => { + let c = default_config.unwrap_or_default(); validate_config(&c)?; ContinuumEngine::new(c) } - - // Verify loaded state has all 3 events perfectly preserved - let loaded = load_engine(&test_path).expect("Failed to load state"); - assert_eq!(loaded.step_count, 3); - assert_eq!(loaded.hot_memory.len(), 3); - assert_eq!(loaded.hot_memory.records[2].payload_ref, "Transactional Event #3"); - - let _ = std::fs::remove_file(&test_path); - } + Err(err) => return Err(err), + }; + let result = f(&mut e)?; save_engine_unlocked(&e, target)?; Ok(result) } - diff --git a/crates/continuum-core/src/recovery.rs b/crates/continuum-core/src/recovery.rs new file mode 100644 index 0000000..519aa1b --- /dev/null +++ b/crates/continuum-core/src/recovery.rs @@ -0,0 +1,85 @@ +//! Explicit backup/check/restore; no automatic corruption fallback. +//! CLI callers must obtain explicit user confirmation before passing overwrite=true. +use std::{fs, io::{self, Write}, path::{Path, PathBuf}, time::Duration}; +use crate::{ContinuumEngine, FileLockGuard}; +use crate::persistence::{atomic_write, decode, parent, read_snapshot_bytes}; + +/// Structural check result, NOT cryptographic integrity verification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SnapshotInfo { + pub format: &'static str, + pub bytes: u64, + pub step_count: u64, + pub hot_records: usize, + pub cold_records: usize, + pub checksum_verified: bool, +} +fn info(e: &ContinuumEngine, bytes: usize) -> SnapshotInfo { + SnapshotInfo { format: "CTNM0001", bytes: bytes as u64, step_count: e.step_count, + hot_records: e.hot_memory.len(), cold_records: e.cold_memory.len(), checksum_verified: false } +} +/// Check a snapshot while holding its lock. Does not rewrite the snapshot. +pub fn check_snapshot(path: impl AsRef) -> io::Result { + let path = path.as_ref(); + let _lock = FileLockGuard::acquire(path, Duration::from_secs(2))?; + let bytes = read_snapshot_bytes(path)?; + Ok(info(&decode(&bytes)?, bytes.len())) +} +fn normalized(path: &Path) -> io::Result { + let name = path.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Snapshot requires a filename"))?; + Ok(fs::canonicalize(parent(path))?.join(name)) +} +fn copy_validated(source: &Path, target: &Path, overwrite: bool) -> io::Result { + // Recover destinations may not be symlinks: replacement should be unambiguous. + fs::create_dir_all(parent(target))?; + let source = normalized(source)?; + let target = normalized(target)?; + let mut source_lock = source.as_os_str().to_os_string(); source_lock.push(".lock"); + let mut target_lock = target.as_os_str().to_os_string(); target_lock.push(".lock"); + if source == target + || source.as_os_str() == target_lock.as_os_str() + || target.as_os_str() == source_lock.as_os_str() + { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "Source and destination (including lock sidecars) must differ")); + } + // Consistent order prevents A->B / B->A lock inversion. + let (first, second) = if source < target { (&source, &target) } else { (&target, &source) }; + let _first = FileLockGuard::acquire(first, Duration::from_secs(3))?; + let _second = FileLockGuard::acquire(second, Duration::from_secs(3))?; + for path in [&source, &target] { + match fs::symlink_metadata(path) { + Ok(m) if m.file_type().is_symlink() => return Err(io::Error::new(io::ErrorKind::InvalidInput, "Recovery paths must not be symlinks")), + Ok(_) => {}, + Err(e) if e.kind() == io::ErrorKind::NotFound => {}, + Err(e) => return Err(e), + } + } + #[cfg(unix)] { + use std::os::unix::fs::MetadataExt; + if let (Ok(a), Ok(b)) = (fs::metadata(&source), fs::metadata(&target)) + && a.dev() == b.dev() + && a.ino() == b.ino() + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Recovery paths refer to the same file", + )); + } + } + let bytes = read_snapshot_bytes(&source)?; + let report = info(&decode(&bytes)?, bytes.len()); + // Validation completes before any destination data is touched. Copy exact + // original bytes for rollback compatibility and never move/delete the backup. + atomic_write(&target, !overwrite, |file| file.write_all(&bytes))?; + Ok(report) +} +/// Create a validated exact-byte backup. Existing backup paths are never replaced. +pub fn backup_engine(source: impl AsRef, backup: impl AsRef) -> io::Result { + copy_validated(source.as_ref(), backup.as_ref(), false) +} +/// Validate backup, then atomically restore target without changing backup. +/// Existing targets require overwrite=true; CLI MUST require explicit confirmation. +/// With false, publication is atomic no-clobber even if another process creates target. +pub fn restore_engine(backup: impl AsRef, target: impl AsRef, overwrite: bool) -> io::Result { + copy_validated(backup.as_ref(), target.as_ref(), overwrite) +} diff --git a/crates/continuum-core/tests/storage.rs b/crates/continuum-core/tests/storage.rs new file mode 100644 index 0000000..868f913 --- /dev/null +++ b/crates/continuum-core/tests/storage.rs @@ -0,0 +1,184 @@ +use continuum_core::{ContinuumConfig, ContinuumEngine, FileLockGuard, load_engine, save_engine, mutate_engine_transactional}; +use std::{fs, path::PathBuf, time::{Duration, Instant}, sync::atomic::{AtomicU64, Ordering}}; +static NEXT: AtomicU64 = AtomicU64::new(0); +struct Scratch(PathBuf); +impl Scratch { + fn new() -> Self { + let p = std::env::temp_dir().join(format!("continuum-storage-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + fs::create_dir(&p).unwrap(); Self(p) + } + fn state(&self) -> PathBuf { self.0.join("memory.state") } +} +impl Drop for Scratch { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } +fn config() -> ContinuumConfig { ContinuumConfig { embedding_dim: 4, state_dim: 4, hot_capacity: 128, cold_capacity: 128, ..Default::default() } } +fn step(e: &mut ContinuumEngine) { e.step(&[1.0, 0.0, 0.0, 0.0], e.step_count as f64, "真实 Unicode 🦀 full text"); } + +#[test] +fn save_creates_parent_and_failed_mutation_preserves_snapshot() { + let dir = Scratch::new(); let p = dir.0.join("nested/memory.state"); + let mut e = ContinuumEngine::new(config()); step(&mut e); + save_engine(&e, &p).unwrap(); + let before = fs::read(&p).unwrap(); + let result = mutate_engine_transactional(&p, None, |e| -> std::io::Result<()> { step(e); Err(std::io::Error::other("cancel")) }); + assert!(result.is_err()); assert_eq!(fs::read(&p).unwrap(), before); + let loaded = load_engine(&p).unwrap(); assert_eq!(loaded.hot_memory.records[0].payload_ref, "真实 Unicode 🦀 full text"); + assert_eq!(&before[..8], b"CTNM0001"); +} + +#[test] +fn save_rejects_inconsistent_state_without_overwrite() { + let dir = Scratch::new(); let p = dir.state(); + let mut e = ContinuumEngine::new(config()); save_engine(&e, &p).unwrap(); + let before = fs::read(&p).unwrap(); e.temporal_core.state.pop(); + assert!(save_engine(&e, &p).is_err()); assert_eq!(fs::read(&p).unwrap(), before); +} + +#[test] +fn backup_check_restore_are_validated_and_no_clobber() { + use continuum_core::{backup_engine, check_snapshot, restore_engine}; + let dir = Scratch::new(); let p = dir.state(); let backup = dir.0.join("backup.state"); + let mut e = ContinuumEngine::new(config()); step(&mut e); save_engine(&e, &p).unwrap(); + let original = fs::read(&p).unwrap(); + let report = backup_engine(&p, &backup).unwrap(); + assert_eq!(report.step_count, 1); assert!(!report.checksum_verified); + assert_eq!(check_snapshot(&backup).unwrap(), report); + assert_eq!(fs::read(&backup).unwrap(), original); + assert_eq!(backup_engine(&p, &backup).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists); + step(&mut e); save_engine(&e, &p).unwrap(); let newer = fs::read(&p).unwrap(); + assert_eq!(restore_engine(&backup, &p, false).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(fs::read(&p).unwrap(), newer); + restore_engine(&backup, &p, true).unwrap(); + assert_eq!(fs::read(&p).unwrap(), original); assert_eq!(fs::read(&backup).unwrap(), original); + let other = dir.0.join("new.state"); restore_engine(&backup, &other, false).unwrap(); + assert_eq!(fs::read(&other).unwrap(), original); + fs::write(&backup, b"CTNM0001").unwrap(); + assert!(restore_engine(&backup, &p, true).is_err()); assert_eq!(fs::read(&p).unwrap(), original); + assert_eq!(fs::read(&backup).unwrap(), b"CTNM0001"); + assert!(restore_engine(&p, &p, true).is_err()); + assert!(restore_engine(&p, p.with_extension("state.lock"), true).is_err()); +} + +#[test] +fn failed_atomic_publication_cleans_own_temp() { + let dir = Scratch::new(); let p = dir.state(); fs::create_dir(&p).unwrap(); + fs::write(p.join("keep"), b"untouched").unwrap(); + assert!(save_engine(&ContinuumEngine::new(config()), &p).is_err()); + assert_eq!(fs::read(p.join("keep")).unwrap(), b"untouched"); + assert!(fs::read_dir(&dir.0).unwrap().all(|entry| !entry.unwrap().file_name().to_string_lossy().contains(".tmp."))); +} + +#[test] +fn ordinary_truncation_and_trailing_data_fail_closed() { + let dir = Scratch::new(); let p = dir.state(); let mut e = ContinuumEngine::new(config()); + step(&mut e); save_engine(&e, &p).unwrap(); let bytes = fs::read(&p).unwrap(); + fs::write(&p, &bytes[..bytes.len() - 1]).unwrap(); assert!(load_engine(&p).is_err()); + let mut extra = bytes.clone(); extra.push(0); fs::write(&p, extra).unwrap(); assert!(load_engine(&p).is_err()); + fs::write(&p, &bytes).unwrap(); let restored = load_engine(&p).unwrap(); + save_engine(&restored, &p).unwrap(); assert_eq!(fs::read(&p).unwrap(), bytes); +} + +#[test] +fn legacy_empty_snapshot_roundtrips_byte_exact() { + let dir = Scratch::new(); let p = dir.state(); + // Independently encode the original CTNM0001 empty layout, no new metadata. + let c = config(); let mut bytes = b"CTNM0001".to_vec(); + for v in [c.embedding_dim, c.state_dim, c.hot_capacity, c.cold_capacity] { bytes.extend_from_slice(&(v as u64).to_le_bytes()); } + bytes.extend_from_slice(&c.sim_threshold.to_le_bytes()); bytes.push(0); + for v in [c.temporal_decay_tau, c.w_sim, c.w_state_compat, c.w_temporal_compat, c.w_provenance_compat] { bytes.extend_from_slice(&v.to_le_bytes()); } + bytes.extend_from_slice(&0u64.to_le_bytes()); bytes.extend_from_slice(&4u64.to_le_bytes()); + bytes.extend_from_slice(&[0; 16]); bytes.extend_from_slice(&[0; 16]); + fs::write(&p, &bytes).unwrap(); let restored = load_engine(&p).unwrap(); + assert_eq!(restored.config.causal_exempt_threshold, None); + save_engine(&restored, &p).unwrap(); assert_eq!(fs::read(&p).unwrap(), bytes); +} + +#[test] +fn invalid_normal_state_is_rejected_before_save() { + use continuum_core::persistence::{MAX_DIMENSION, MAX_TEXT_BYTES}; + let dir = Scratch::new(); let p = dir.state(); + let mut e = ContinuumEngine::new(config()); step(&mut e); save_engine(&e, &p).unwrap(); + let original = fs::read(&p).unwrap(); + let mut inconsistent = e.clone(); inconsistent.config.embedding_dim = MAX_DIMENSION + 1; + assert!(save_engine(&inconsistent, &p).is_err()); + let mut inconsistent = e.clone(); inconsistent.config.temporal_decay_tau = 0.0; + assert!(save_engine(&inconsistent, &p).is_err()); + let mut inconsistent = e.clone(); inconsistent.hot_memory.records[0].embedding[0] = f32::NAN; + assert!(save_engine(&inconsistent, &p).is_err()); + let mut inconsistent = e.clone(); inconsistent.hot_memory.records.push(inconsistent.hot_memory.records[0].clone()); + assert!(save_engine(&inconsistent, &p).is_err()); + e.hot_memory.records[0].payload_ref = "x".repeat(MAX_TEXT_BYTES + 1); + assert!(save_engine(&e, &p).is_err()); assert_eq!(fs::read(&p).unwrap(), original); +} + +#[test] +fn incomplete_snapshot_is_not_reinitialized() { + let dir = Scratch::new(); let p = dir.state(); fs::write(&p, b"CTNM0001").unwrap(); + assert!(mutate_engine_transactional(&p, Some(config()), |_| Ok(())).is_err()); + assert_eq!(fs::read(&p).unwrap(), b"CTNM0001"); +} + +#[test] +fn thread_rmw_has_no_lost_updates() { + let dir = Scratch::new(); let p = dir.state(); + std::thread::scope(|scope| { + for _ in 0..4 { let p = &p; scope.spawn(move || { + for _ in 0..20 { mutate_engine_transactional(p, Some(config()), |e| { step(e); Ok(()) }).unwrap(); } + }); } + }); + assert_eq!(load_engine(&p).unwrap().step_count, 80); +} + +#[test] +fn stable_lock_times_out_and_reacquires() { + let dir = Scratch::new(); let p = dir.state(); + let guard = FileLockGuard::acquire(&p, Duration::ZERO).unwrap(); let sidecar = guard.lock_path().to_path_buf(); + #[cfg(unix)] let inode = { use std::os::unix::fs::MetadataExt; fs::metadata(&sidecar).unwrap().ino() }; + let start = Instant::now(); + assert_eq!(FileLockGuard::acquire(&p, Duration::from_millis(60)).unwrap_err().kind(), std::io::ErrorKind::TimedOut); + assert!(start.elapsed() >= Duration::from_millis(60)); + drop(guard); assert!(sidecar.exists()); + let _again = FileLockGuard::acquire(&p, Duration::ZERO).unwrap(); + #[cfg(unix)] { use std::os::unix::fs::MetadataExt; assert_eq!(fs::metadata(&sidecar).unwrap().ino(), inode); } +} + +// The integration-test executable doubles as an isolated cooperating child. +#[test] +fn child_worker() { + let Some(p) = std::env::var_os("CONTINUUM_STORAGE_CHILD") else { return; }; + let p = PathBuf::from(p); + if std::env::var_os("CONTINUUM_STORAGE_HOLD").is_some() { + let _guard = FileLockGuard::acquire(&p, Duration::from_secs(3)).unwrap(); + fs::write(p.with_extension("ready"), b"ready").unwrap(); + std::thread::sleep(Duration::from_secs(60)); + } else { + for _ in 0..20 { mutate_engine_transactional(&p, Some(config()), |e| { step(e); Ok(()) }).unwrap(); } + } +} +fn child(p: &std::path::Path, hold: bool) -> std::process::Child { + let mut c = std::process::Command::new(std::env::current_exe().unwrap()); + c.args(["--exact", "child_worker", "--nocapture"]).env("CONTINUUM_STORAGE_CHILD", p); + if hold { c.env("CONTINUUM_STORAGE_HOLD", "1"); } + c.stdout(std::process::Stdio::null()).spawn().unwrap() +} +struct ChildGuard(std::process::Child); +impl Drop for ChildGuard { fn drop(&mut self) { let _ = self.0.kill(); let _ = self.0.wait(); } } +#[test] +fn process_rmw_has_no_lost_updates() { + let dir = Scratch::new(); let p = dir.state(); + let mut children: Vec<_> = (0..4).map(|_| ChildGuard(child(&p, false))).collect(); + for c in &mut children { assert!(c.0.wait().unwrap().success()); } + assert_eq!(load_engine(&p).unwrap().step_count, 80); +} +#[test] +fn long_holder_is_not_stolen_and_killed_holder_releases() { + let dir = Scratch::new(); let p = dir.state(); let mut c = ChildGuard(child(&p, true)); + let start = Instant::now(); + while !p.with_extension("ready").exists() { + assert!(start.elapsed() < Duration::from_secs(5), "child not ready"); + assert!(c.0.try_wait().unwrap().is_none()); std::thread::sleep(Duration::from_millis(10)); + } + std::thread::sleep(Duration::from_millis(6100)); + assert_eq!(FileLockGuard::acquire(&p, Duration::from_millis(50)).unwrap_err().kind(), std::io::ErrorKind::TimedOut); + c.0.kill().unwrap(); c.0.wait().unwrap(); + let _guard = FileLockGuard::acquire(&p, Duration::from_millis(500)).unwrap(); +} From 9ce84e8319087d2ad3246188a2c60a769f458680 Mon Sep 17 00:00:00 2001 From: reacherwu Date: Thu, 17 Sep 2026 22:10:29 +0800 Subject: [PATCH 2/2] Record command failures as bounded observations without inferred causes --- crates/continuum-cli/src/runner.rs | 439 ++++++++++++-------- crates/continuum-cli/tests/runner_safety.rs | 203 +++++++++ 2 files changed, 464 insertions(+), 178 deletions(-) create mode 100644 crates/continuum-cli/tests/runner_safety.rs diff --git a/crates/continuum-cli/src/runner.rs b/crates/continuum-cli/src/runner.rs index ad19730..28b7c4f 100644 --- a/crates/continuum-cli/src/runner.rs +++ b/crates/continuum-cli/src/runner.rs @@ -1,219 +1,302 @@ -//! Zero-Friction Autonomous Command Runner for Continuum. -//! -//! Wraps build, test, and execution commands (e.g. `continuum run cargo test`). -//! Automatically tracks failure symptoms and pairs them with subsequent successful -//! fixes into causal anchor memories with zero human prompting. +//! Explicit, single-attempt command wrapper. Recovery is an observation, never a cause. +//! No output, environment, arguments or inferred fixes are ingested into memory. +//! At most one pending and one recovery observation (1 KiB each) are retained per +//! canonical project. They expire after 24 hours, pruned on the next opted-in run. -use std::io::{BufRead, BufReader}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::sync::mpsc::channel; -use std::thread; +use std::process::{Command, ExitStatus, Stdio}; use std::time::{SystemTime, UNIX_EPOCH}; -const INCIDENT_FILE: &str = ".continuum/pending_incident.json"; +const PENDING: &str = "pending_observation.json"; +const RECOVERY: &str = "recovery_observation.json"; +const MAX_BYTES: u64 = 1024; +const RETENTION_SECS: u64 = 24 * 60 * 60; -#[derive(Debug, Clone)] -pub struct PendingIncident { - pub command: String, - pub timestamp: u64, - pub symptom: String, - pub exit_code: i32, +#[derive(Clone)] +struct Observation { + project: String, + command: String, + failed_at: u64, + recovered_at: Option, + exit_code: i32, } -impl PendingIncident { - pub fn save(&self, path: impl AsRef) -> std::io::Result<()> { - let parent = path.as_ref().parent().unwrap_or_else(|| Path::new(".")); - let _ = std::fs::create_dir_all(parent); - - let clean_cmd = self.command.replace('\\', "\\\\").replace('"', "\\\""); - let clean_sym = self.symptom.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n"); - - let json = format!( - r#"{{"command":"{}","timestamp":{},"symptom":"{}","exit_code":{}}}"#, - clean_cmd, self.timestamp, clean_sym, self.exit_code - ); - std::fs::write(path, json) +impl Observation { + fn encode(&self) -> String { + let kind = if self.recovered_at.is_some() { "recovery_observation" } else { "failure_observation" }; + let recovered = self.recovered_at.map_or("null".into(), |n| n.to_string()); + format!("{{\"version\":1,\"kind\":\"{kind}\",\"project_digest\":\"{}\",\"command_digest\":\"{}\",\"failed_at\":{},\"recovered_at\":{recovered},\"exit_code\":{},\"verified_cause\":false}}\n", + self.project, self.command, self.failed_at, self.exit_code) } - pub fn load(path: impl AsRef) -> Option { - let content = std::fs::read_to_string(path).ok()?; - let json = crate::json::parse_json(&content).ok()?; - Some(Self { - command: json.get("command").and_then(|v| v.as_str()).unwrap_or("").to_string(), - timestamp: json.get("timestamp").and_then(|v| v.as_u64()).unwrap_or(0), - symptom: json.get("symptom").and_then(|v| v.as_str()).unwrap_or("").to_string(), - exit_code: json.get("exit_code").and_then(|v| v.as_i64()).unwrap_or(1) as i32, - }) + fn decode(text: &str) -> Option { + let json = crate::json::parse_json(text).ok()?; + let digest = |key| { + let s = json.get(key)?.as_str()?; + (s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())) + .then(|| s.to_string()) + }; + let observation = Self { + project: digest("project_digest")?, + command: digest("command_digest")?, + failed_at: json.get("failed_at")?.as_u64()?, + recovered_at: match json.get("recovered_at")? { + crate::json::JsonValue::Null => None, + value => Some(value.as_u64()?), + }, + exit_code: i32::try_from(json.get("exit_code")?.as_i64()?).ok()?, + }; + // Only our exact, versioned schema is accepted: no legacy text, unknown + // fields, fractional timestamps, duplicate keys or causal assertions. + (observation.exit_code != 0 && observation.encode() == text).then_some(observation) } -} -pub fn run_command(args: &[String], state_path: &str) -> i32 { - if args.is_empty() { - eprintln!("Usage: continuum run [args...]"); - return 1; + fn fresh(&self, now: u64) -> bool { + self.failed_at <= now && now - self.failed_at < RETENTION_SECS + && self.recovered_at.is_none_or(|t| t >= self.failed_at && t <= now) } +} - let cmd_name = &args[0]; - let cmd_args = &args[1..]; - let full_cmd_str = args.join(" "); +struct Session { + directory: PathBuf, + // Stable advisory-lock inode, kept locked across this single child execution. + // Concurrent opted-in commands still run, but skip observations rather than + // guessing execution order or waiting/retrying a command. + _lock: File, + project: String, + command: String, + prior: Option, +} - let mut child = match Command::new(cmd_name) - .args(cmd_args) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - { - Ok(c) => c, - Err(e) => { - eprintln!("Failed to execute '{}': {e}", cmd_name); - return 127; +impl Session { + fn begin(args: &[String]) -> io::Result { + let cwd = std::env::current_dir()?.canonicalize()?; + let root = cwd.ancestors().find(|p| p.join(".continuum").exists() || p.join(".git").exists()) + .unwrap_or(&cwd); + let directory = root.join(".continuum"); + fs::create_dir_all(&directory)?; + if fs::symlink_metadata(&directory)?.file_type().is_symlink() { + return Err(io::Error::other("observation directory must not be a symlink")); } - }; + let lock_path = directory.join("runner.lock"); + reject_nonregular(&lock_path)?; + let lock = private_options().read(true).write(true).create(true).open(lock_path)?; + lock.try_lock().map_err(io::Error::other)?; + let now = now()?; + // Do not parse or migrate legacy incidents containing raw user output. + remove_if_present(&directory.join("pending_incident.json"))?; + let prior = load_fresh(&directory.join(PENDING), now)?; + let _ = load_fresh(&directory.join(RECOVERY), now)?; + let mut project_hash = Sha256::new(); + project_hash.field(b"continuum-project-v1"); + project_hash.field(root.as_os_str().as_encoded_bytes()); + let mut command_hash = Sha256::new(); + command_hash.field(b"continuum-command-v1"); + // Same project does not imply the same execution context: keep cwd and + // argument boundaries. Environment is intentionally not collected. + command_hash.field(cwd.as_os_str().as_encoded_bytes()); + for arg in args { command_hash.field(arg.as_bytes()); } + Ok(Self { directory, _lock: lock, project: project_hash.finish(), command: command_hash.finish(), prior }) + } - let stdout_pipe = child.stdout.take().expect("Failed to capture stdout"); - let stderr_pipe = child.stderr.take().expect("Failed to capture stderr"); - - let (stdout_tx, stdout_rx) = channel(); - let (stderr_tx, stderr_rx) = channel(); - - // Spawn thread to stream stdout and retain trailing lines - let stdout_thread = thread::spawn(move || { - let reader = BufReader::new(stdout_pipe); - let mut lines = Vec::new(); - for line_res in reader.lines() { - if let Ok(line) = line_res { - println!("{}", line); - lines.push(line); - if lines.len() > 30 { - lines.remove(0); - } - } - } - let _ = stdout_tx.send(lines); - }); - - // Spawn thread to stream stderr and retain trailing lines - let stderr_thread = thread::spawn(move || { - let reader = BufReader::new(stderr_pipe); - let mut lines = Vec::new(); - for line_res in reader.lines() { - if let Ok(line) = line_res { - eprintln!("{}", line); - lines.push(line); - if lines.len() > 30 { - lines.remove(0); - } + fn record(self, exit_code: i32) -> io::Result<()> { + let now = now()?; + if exit_code != 0 { + let observation = Observation { + project: self.project, command: self.command, failed_at: now, + recovered_at: None, exit_code, + }; + save(&self.directory, PENDING, &observation)?; + } else if let Some(mut prior) = self.prior + && prior.fresh(now) && prior.recovered_at.is_none() + && prior.project == self.project && prior.command == self.command { + prior.recovered_at = Some(now); + save(&self.directory, RECOVERY, &prior)?; + remove_if_present(&self.directory.join(PENDING))?; + diagnostic("Continuum: recovery observed; cause unverified, no memory ingested."); } - } - let _ = stderr_tx.send(lines); - }); + Ok(()) + } +} - let status = child.wait().expect("Child process wasn't running"); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); +fn now() -> io::Result { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).map_err(io::Error::other) +} - let stdout_lines = stdout_rx.recv().unwrap_or_default(); - let stderr_lines = stderr_rx.recv().unwrap_or_default(); +fn private_options() -> OpenOptions { + let mut options = OpenOptions::new(); + #[cfg(unix)] { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options +} - let exit_code = status.code().unwrap_or(1); - let incident_path = PathBuf::from(INCIDENT_FILE); +fn reject_nonregular(path: &Path) -> io::Result<()> { + match fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_file() => Ok(()), + Ok(_) => Err(io::Error::other("expected regular observation file")), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } +} - if exit_code != 0 { - // Command failed: extract failure symptom - let symptom = extract_symptom(&stderr_lines, &stdout_lines); - let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); +fn remove_if_present(path: &Path) -> io::Result<()> { + match fs::remove_file(path) { + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + result => result, + } +} - let incident = PendingIncident { - command: full_cmd_str.clone(), - timestamp: now, - symptom: symptom.clone(), - exit_code, - }; +fn load_fresh(path: &Path, now: u64) -> io::Result> { + reject_nonregular(path)?; + let file = match File::open(path) { + Ok(file) => file, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e), + }; + // The byte cap applies before parsing and allocation, not just when saving. + let mut bytes = Vec::new(); + file.take(MAX_BYTES + 1).read_to_end(&mut bytes)?; + let observation = if bytes.len() as u64 <= MAX_BYTES { + std::str::from_utf8(&bytes).ok().and_then(Observation::decode).filter(|o| o.fresh(now)) + } else { None }; + if observation.is_none() { remove_if_present(path)?; } + Ok(observation) +} - if let Err(e) = incident.save(&incident_path) { - eprintln!("Warning: Failed to save pending incident: {e}"); - } else { - eprintln!("\n⚠️ Continuum: Incident captured in '{INCIDENT_FILE}'"); - eprintln!(" Symptom: '{}'", symptom); - eprintln!(" (Once fixed and re-run successfully, Continuum will auto-ingest the causal pair!)\n"); - } - } else { - // Command succeeded: check if resolving a prior failure - if incident_path.exists() { - if let Some(prior) = PendingIncident::load(&incident_path) { - let causal_pair = format!( - "FIX: Resolved error ('{}') from '{}' via successful run of '{}'", - prior.symptom, prior.command, full_cmd_str - ); - - crate::run_memory_ingest(&causal_pair, state_path); - let _ = std::fs::remove_file(&incident_path); - - eprintln!("\n🎉 Continuum: Autonomous Causal Learning Triggered!"); - eprintln!(" Prior Symptom: '{}'", prior.symptom); - eprintln!(" Resolving Cmd: '{}'", full_cmd_str); - eprintln!(" Causal Anchor: Ingested into 750 bounded memory manifold (< 75 KB RAM).\n"); - } - } - } +fn save(directory: &Path, name: &str, observation: &Observation) -> io::Result<()> { + let text = observation.encode(); + if text.len() as u64 > MAX_BYTES { return Err(io::Error::other("observation exceeds limit")); } + let temp = directory.join("runner-observation.tmp"); + // Only used while holding runner.lock. A prior interrupted save is discarded. + remove_if_present(&temp)?; + let result = (|| { + let mut file = private_options().write(true).create_new(true).open(&temp)?; + file.write_all(text.as_bytes())?; + file.sync_all()?; + fs::rename(&temp, directory.join(name))?; + #[cfg(unix)] File::open(directory)?.sync_all()?; + Ok(()) + })(); + if result.is_err() { let _ = remove_if_present(&temp); } + result +} - exit_code +fn diagnostic(message: &str) { + // Never echo OS errors, paths, command arguments, stored data or child output. + // A closed diagnostic pipe must not panic and replace the child exit status. + let _ = writeln!(io::stderr().lock(), "{message}"); } -fn extract_symptom(stderr: &[String], stdout: &[String]) -> String { - // Prefer stderr lines containing error markers - for line in stderr.iter().rev() { - let lower = line.to_lowercase(); - if lower.contains("error:") || lower.contains("fail") || lower.contains("panic") || lower.contains("fatal:") { - return line.trim().to_string(); - } +/// Execute exactly the requested program and arguments once, preserving its exit +/// code (Unix signal termination maps to the conventional 128 + signal). The +/// state-path parameter stays API-compatible but is never opened or ingested. +pub fn run_command(args: &[String], _state_path: &str) -> i32 { + if args.is_empty() { + diagnostic("Usage: continuum run [args...]"); + return 1; } - // Check stdout lines for test failures or assertion errors - for line in stdout.iter().rev() { - let lower = line.to_lowercase(); - if lower.contains("failed") || lower.contains("assertionerror") || lower.contains("panic") || lower.contains("error:") { - return line.trim().to_string(); + let session = Session::begin(args); + if session.is_err() { diagnostic("Continuum: observations unavailable; requested command will still run."); } + // Inherited streams preserve bytes, stdin and terminal behavior without any + // line buffers, capture threads or output retained in the runner's memory. + let status = Command::new(&args[0]).args(&args[1..]) + .stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit()).status(); + let code = match status { + Ok(status) => exit_code(status), + Err(e) => { + diagnostic("Continuum: requested command could not be executed."); + return if e.kind() == io::ErrorKind::NotFound { 127 } else { 126 }; } + }; + if let Ok(session) = session + && session.record(code).is_err() { diagnostic("Continuum: observation not saved; child exit status preserved."); } + code +} + +fn exit_code(status: ExitStatus) -> i32 { + if let Some(code) = status.code() { return code; } + #[cfg(unix)] { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { return 128 + signal; } } - // Fallback to last line of stderr or stdout - if let Some(last) = stderr.last() { - if !last.trim().is_empty() { - return last.trim().to_string(); + 1 +} + +// SHA-256 identities avoid raw argument/path retention, not secrecy: low-entropy +// commands can still be guessed offline. Digests do not prove cause, identical +// executable contents, or identical environment. Streaming avoids copying args. +struct Sha256 { state: [u32; 8], block: [u8; 64], used: usize, bytes: u64 } +impl Sha256 { + fn new() -> Self { + Self { state: [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19], block: [0; 64], used: 0, bytes: 0 } + } + fn field(&mut self, bytes: &[u8]) { + self.update(&(bytes.len() as u64).to_be_bytes()); + self.update(bytes); + } + fn update(&mut self, mut bytes: &[u8]) { + self.bytes = self.bytes.wrapping_add(bytes.len() as u64); + while !bytes.is_empty() { + let n = (64 - self.used).min(bytes.len()); + self.block[self.used..self.used + n].copy_from_slice(&bytes[..n]); + self.used += n; + bytes = &bytes[n..]; + if self.used == 64 { self.compress(); self.used = 0; } } } - if let Some(last) = stdout.last() { - if !last.trim().is_empty() { - return last.trim().to_string(); + fn finish(mut self) -> String { + let bits = self.bytes.wrapping_mul(8); + self.update(&[0x80]); + while self.used != 56 { self.update(&[0]); } + self.update(&bits.to_be_bytes()); + self.state.iter().map(|word| format!("{word:08x}")).collect() + } + fn compress(&mut self) { + const K: [u32; 64] = [ + 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, + 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, + 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, + 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, + 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, + 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, + 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, + 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2, + ]; + let mut w = [0u32; 64]; + for (i, chunk) in self.block.chunks_exact(4).enumerate() { + w[i] = u32::from_be_bytes(chunk.try_into().unwrap()); + } + for i in 16..64 { + let s0 = w[i-15].rotate_right(7) ^ w[i-15].rotate_right(18) ^ (w[i-15] >> 3); + let s1 = w[i-2].rotate_right(17) ^ w[i-2].rotate_right(19) ^ (w[i-2] >> 10); + w[i] = w[i-16].wrapping_add(s0).wrapping_add(w[i-7]).wrapping_add(s1); } + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.state; + for i in 0..64 { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let choice = (e & f) ^ (!e & g); + let t1 = h.wrapping_add(s1).wrapping_add(choice).wrapping_add(K[i]).wrapping_add(w[i]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let t2 = s0.wrapping_add((a & b) ^ (a & c) ^ (b & c)); + h = g; g = f; f = e; e = d.wrapping_add(t1); d = c; c = b; b = a; a = t1.wrapping_add(t2); + } + for (state, value) in self.state.iter_mut().zip([a,b,c,d,e,f,g,h]) { *state = state.wrapping_add(value); } } - "Unknown command failure".to_string() } #[cfg(test)] mod tests { use super::*; - #[test] - fn test_pending_incident_roundtrip() { - let temp_dir = std::env::temp_dir(); - let path = temp_dir.join(format!("incident_test_{}.json", std::process::id())); - - let incident = PendingIncident { - command: "cargo test --test auth".to_string(), - timestamp: 1789500000, - symptom: "error[E0432]: unresolved import `crate::auth`".to_string(), - exit_code: 101, - }; - - incident.save(&path).expect("Failed save"); - let loaded = PendingIncident::load(&path).expect("Failed load"); - - assert_eq!(loaded.command, incident.command); - assert_eq!(loaded.timestamp, incident.timestamp); - assert_eq!(loaded.symptom, incident.symptom); - assert_eq!(loaded.exit_code, incident.exit_code); - - let _ = std::fs::remove_file(&path); + fn sha256_standard_vectors() { + assert_eq!(Sha256::new().finish(), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + let mut hash = Sha256::new(); hash.update(b"abc"); + assert_eq!(hash.finish(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + let mut hash = Sha256::new(); hash.update(&vec![b'a'; 1_000_000]); + assert_eq!(hash.finish(), "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"); } } diff --git a/crates/continuum-cli/tests/runner_safety.rs b/crates/continuum-cli/tests/runner_safety.rs new file mode 100644 index 0000000..52ed72a --- /dev/null +++ b/crates/continuum-cli/tests/runner_safety.rs @@ -0,0 +1,203 @@ +//! Real subprocess checks; all state and synthetic output stay in isolated temp directories. +#![cfg(unix)] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const PENDING: &str = ".continuum/pending_observation.json"; +const RECOVERY: &str = ".continuum/recovery_observation.json"; +static NEXT: AtomicU64 = AtomicU64::new(0); + +struct Workspace(PathBuf); +impl Workspace { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "continuum-runner-safety-{}-{}", + std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + fs::create_dir(path.join("home")).unwrap(); + Self(path) + } + fn run(&self, cwd: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_continuum-cli")) + .arg("run").args(args).current_dir(cwd) + .env_clear().env("HOME", self.0.join("home")) + .env("PATH", "/usr/bin:/bin") + .env("SYNTHETIC_RUNNER_VALUE", "synthetic-environment-value") + .output().unwrap() + } + fn shell(&self, script: &str) -> Output { + self.run(&self.0, &["/bin/sh", "-c", script]) + } + fn read(&self, name: &str) -> String { + fs::read_to_string(self.0.join(name)).unwrap() + } + fn assert_no_memory(&self) { + assert!(!self.0.join(".continuum/memory.state").exists()); + assert!(!self.0.join("home/.continuum/agent_memory.state").exists()); + } +} +impl Drop for Workspace { + fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } +} + +#[test] +fn unrelated_success_never_becomes_a_fix() { + let w = Workspace::new(); + assert_eq!(w.shell("exit 23").status.code(), Some(23)); + assert!(w.shell("exit 0").status.success()); + w.assert_no_memory(); + assert!(w.0.join(PENDING).exists()); + assert!(!w.0.join(RECOVERY).exists()); +} + +#[test] +fn identical_command_recovery_is_only_an_observation() { + let w = Workspace::new(); + let command = "test -f ready"; + assert_eq!(w.shell(command).status.code(), Some(1)); + fs::write(w.0.join("ready"), b"").unwrap(); + assert!(w.shell(command).status.success()); + let recovery = w.read(RECOVERY); + assert!(recovery.contains("\"kind\":\"recovery_observation\"")); + assert!(recovery.contains("\"verified_cause\":false")); + assert!(!w.0.join(PENDING).exists()); + w.assert_no_memory(); +} + +#[test] +fn output_is_byte_exact_and_not_retained_or_repeated_in_diagnostics() { + let w = Workspace::new(); + let output = w.shell("printf 'synthetic-output\\377'; printf 'synthetic-stderr' >&2; exit 7"); + assert_eq!(output.status.code(), Some(7)); + assert_eq!(output.stdout, b"synthetic-output\xff"); + assert_eq!(String::from_utf8_lossy(&output.stderr).matches("synthetic-stderr").count(), 1); + let incident = w.read(PENDING); + for raw in ["synthetic-output", "synthetic-stderr", "synthetic-environment-value", "printf", "exit 7"] { + assert!(!incident.contains(raw)); + } + assert!(incident.len() <= 1024); + w.assert_no_memory(); +} + +#[test] +fn executes_exact_arguments_once_without_shell_reinterpretation() { + let w = Workspace::new(); + let output = w.run(&w.0, &["/usr/bin/printf", "%s|%s", "one two", "$SYNTHETIC_RUNNER_VALUE"]); + assert!(output.status.success()); + assert_eq!(output.stdout, b"one two|$SYNTHETIC_RUNNER_VALUE"); + let failed = w.shell("printf x >> attempts; exit 19"); + assert_eq!(failed.status.code(), Some(19)); + assert_eq!(fs::read(w.0.join("attempts")).unwrap(), b"x"); +} + +#[test] +fn copied_observation_cannot_correlate_across_projects() { + let a = Workspace::new(); + let b = Workspace::new(); + let command = "test -f ready"; + assert!(!a.shell(command).status.success()); + fs::create_dir(b.0.join(".continuum")).unwrap(); + fs::copy(a.0.join(PENDING), b.0.join(PENDING)).unwrap(); + fs::write(b.0.join("ready"), b"").unwrap(); + assert!(b.shell(command).status.success()); + assert!(!b.0.join(RECOVERY).exists()); + b.assert_no_memory(); +} + +#[test] +fn canonical_project_alias_correlates_but_different_working_directory_does_not() { + let w = Workspace::new(); + let command = "test -f ready"; + assert!(!w.shell(command).status.success()); + let subdir = w.0.join("subdir"); + fs::create_dir(&subdir).unwrap(); + fs::write(subdir.join("ready"), b"").unwrap(); + assert!(w.run(&subdir, &["/bin/sh", "-c", command]).status.success()); + assert!(!w.0.join(RECOVERY).exists()); + let alias = w.0.join("alias"); + std::os::unix::fs::symlink(&w.0, &alias).unwrap(); + fs::write(w.0.join("ready"), b"").unwrap(); + assert!(w.run(&alias, &["/bin/sh", "-c", command]).status.success()); + assert!(w.0.join(RECOVERY).exists()); +} + +#[test] +fn observation_storage_failure_does_not_override_child_status() { + let w = Workspace::new(); + fs::write(w.0.join(".continuum"), b"not a directory").unwrap(); + assert_eq!(w.shell("exit 31").status.code(), Some(31)); + assert!(w.shell("exit 0").status.success()); + w.assert_no_memory(); +} + +#[test] +fn argument_boundaries_are_part_of_identity() { + let w = Workspace::new(); + let command = "test -f ready"; + assert!(!w.run(&w.0, &["/bin/sh", "-c", command, "one two"]).status.success()); + fs::write(w.0.join("ready"), b"").unwrap(); + assert!(w.run(&w.0, &["/bin/sh", "-c", command, "one", "two"]).status.success()); + assert!(!w.0.join(RECOVERY).exists()); + assert!(w.run(&w.0, &["/bin/sh", "-c", command, "one two"]).status.success()); + assert!(w.0.join(RECOVERY).exists()); +} + +#[test] +fn expired_and_oversize_observations_are_discarded_without_recovery() { + let w = Workspace::new(); + let command = "test -f ready"; + assert!(!w.shell(command).status.success()); + let mut pending = w.read(PENDING); + let start = pending.find("\"failed_at\":").unwrap() + "\"failed_at\":".len(); + let end = start + pending[start..].find(',').unwrap(); + pending.replace_range(start..end, "1"); + fs::write(w.0.join(PENDING), pending).unwrap(); + fs::write(w.0.join("ready"), b"").unwrap(); + assert!(w.shell(command).status.success()); + assert!(!w.0.join(PENDING).exists()); + assert!(!w.0.join(RECOVERY).exists()); + fs::write(w.0.join(PENDING), vec![b' '; 2048]).unwrap(); + assert!(w.shell(command).status.success()); + assert!(!w.0.join(PENDING).exists()); + assert!(!w.0.join(RECOVERY).exists()); + w.assert_no_memory(); +} + +#[test] +fn legacy_raw_incident_is_discarded_not_migrated() { + let w = Workspace::new(); + fs::create_dir(w.0.join(".continuum")).unwrap(); + let legacy = w.0.join(".continuum/pending_incident.json"); + fs::write(&legacy, b"synthetic old observation").unwrap(); + assert!(w.shell("exit 0").status.success()); + assert!(!legacy.exists()); + assert!(!w.0.join(RECOVERY).exists()); + w.assert_no_memory(); +} + +#[test] +fn missing_program_returns_127_without_echoing_its_argument() { + let w = Workspace::new(); + let output = w.run(&w.0, &["/synthetic-nonexistent-program", "synthetic-argument"]); + assert_eq!(output.status.code(), Some(127)); + assert!(!String::from_utf8_lossy(&output.stderr).contains("synthetic-argument")); + assert!(!w.0.join(PENDING).exists()); + w.assert_no_memory(); +} + +#[test] +fn many_failures_have_bounded_retained_size() { + let w = Workspace::new(); + for i in 0..40 { + assert_eq!(w.shell(&format!("exit 3 # synthetic-{i}")).status.code(), Some(3)); + } + let files: Vec<_> = fs::read_dir(w.0.join(".continuum")).unwrap().map(Result::unwrap).collect(); + assert!(files.len() <= 4); + assert!(files.iter().map(|f| f.metadata().unwrap().len()).sum::() <= 2048); + assert!(w.read(PENDING).contains("\"kind\":\"failure_observation\"")); + w.assert_no_memory(); +}