From 2922e0fb036758c71222a4f5717d5e5776b1853b Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 6 May 2026 10:34:41 -0400 Subject: [PATCH 1/5] relayburn-cli: burn state subcommands (#248 D4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire `burn state` as a typed clap subcommand over `relayburn-sdk`: - `state status` (default) reports per-table row counts in `burn.sqlite`, the row count in `content.sqlite`, the `archive_state` schema / last-built / last-rebuild fields, and the resolved retention config. `--json` emits a structured `StateStatus` payload. - `state rebuild {index,content,archive,all}` drives `Ledger::rebuild_derivable`. The four 1.x subtargets collapse onto the same SQL transaction in 2.0; standalone `rebuild classify` is stubbed with a follow-up note (the Rust ingest pipeline classifies at append time per #274). - `state prune` drives `Ledger::prune_content_older_than`, sourcing the retention window from `--days` or the resolved config. - `state reset` is stubbed (drops + re-ingest is filed for follow-up under #240); it returns exit 1 with a typed message pointing at `state rebuild all` + `burn ingest` as a workaround. The TS sibling reports against the 1.x JSONL ledger layout (ledger.idx, archive.sqlite, content/ sidecars). The 2.0 Rust port has two SQLite databases with no separate index/archive files, so the `state-status` golden snapshots are reshaped to match the 2.0 layout — deliberate divergence tracked under #240. File sizes are omitted from both human and JSON output because WAL checkpointing makes them non-deterministic on a logically-empty ledger. SDK additions: - `state_status(ledger_home: Option) -> Result` free fn + `LedgerHandle::state_status()` method, with row-count, archive_state, and config blocks. Three new unit tests cover the fresh-ledger zero-row path, the appended-turn count path, and the free-fn `ledger_home` round-trip. - `RawLedger::read_archive_state_json()` to surface the single-row `archive_state` snapshot without forcing callers to bind to rusqlite. CLI tests: - Two `state-status*` golden invocations flipped to `enabled: true`; snapshots regenerated. - Smoke test's `each_stub_exits_one` filters out the now-real `state` command via a `REAL_COMMANDS` allowlist. - Fixture-dir `.gitignore` extended to cover `content.sqlite*` / `*-journal` so a stray run doesn't dirty the working tree. Follow-ups filed for: standalone `state rebuild classify` reclassify pass, `state reset` (drop + re-ingest), and a dedicated 2.0-format fixture corpus for the read-path golden snapshots. --- CHANGELOG.md | 1 + crates/relayburn-cli/src/cli.rs | 126 ++++- crates/relayburn-cli/src/commands/state.rs | 432 +++++++++++++++++- crates/relayburn-cli/src/main.rs | 2 +- crates/relayburn-cli/tests/smoke.rs | 8 + crates/relayburn-sdk/src/ledger.rs | 21 + crates/relayburn-sdk/src/query_verbs.rs | 235 ++++++++++ tests/fixtures/cli-golden/invocations.json | 4 +- tests/fixtures/cli-golden/ledger/.gitignore | 5 + .../snapshots/state-status-json.stdout.txt | 63 +-- .../snapshots/state-status.stdout.txt | 36 +- 11 files changed, 863 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 599e68c1..b91a432d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Cross-package release notes for relayburn. Package changelogs contain package-le ## [Unreleased] +- `relayburn-cli` (Rust): wire `burn state` as a typed clap subcommand with `status` (default), `rebuild`, `prune`, and `reset` verbs over `relayburn-sdk`. `state status` reports per-table row counts in `burn.sqlite`, the row count in `content.sqlite`, the `archive_state` schema/last-built/last-rebuild fields, and the resolved retention config; `--json` emits the structured `StateStatus` payload. `state rebuild {index,content,archive,all}` drives `Ledger::rebuild_derivable`; `state prune` drives `Ledger::prune_content_older_than`. `state reset` and standalone `state rebuild classify` are stubbed pending a follow-up. (#248 D4) - `relayburn-sdk-node` (Rust): napi-rs bindings skeleton — `#[napi]` shims for every public verb in `relayburn-sdk` (`summary`, `sessionCost`, `overhead`, `overheadTrim`, `hotspots`, `search`, `exportLedger`, `exportStamps`, async `ingest`, plus `ledgerOpen`), with u64 token counts surfaced as JS `BigInt`, ISO-8601 timestamps as `String`, async verbs returning `Promise`, and a typed `BurnError` mapping for SDK failures. (#247) - `relayburn-cli` (Rust): introduce the harness substrate — `HarnessAdapter` trait, lazy compile-time `phf` registry (`lookup` / `list_harness_names`), and the shared `pending_stamp::adapter` factory codex + opencode will reuse. Adapter slots in the registry are reserved but empty pending the Wave 2 PRs (#248-d/e/f). `relayburn-sdk` re-exports `start_watch_loop`, `WatchController`, `write_pending_stamp`, `PendingStampHarness`, and friends so the CLI doesn't have to reach into private SDK modules. (#248) - `relayburn-cli` (Rust): scaffold the clap v4 derive root with global `--json` / `--ledger-path` / `--no-color` flags, eight stub subcommands (`summary`, `hotspots`, `overhead`, `compare`, `run`, `state`, `ingest`, `mcp-server`), and shared `render::{table,json,error}` helpers. Stubs exit `1` with a `not yet implemented` message (or a `{"error": …}` envelope under `--json`); Wave 2 fan-out PRs replace each stub with a thin presenter over `relayburn-sdk`. (#248 part a) diff --git a/crates/relayburn-cli/src/cli.rs b/crates/relayburn-cli/src/cli.rs index cf2c522e..640f1162 100644 --- a/crates/relayburn-cli/src/cli.rs +++ b/crates/relayburn-cli/src/cli.rs @@ -17,7 +17,7 @@ use std::path::PathBuf; -use clap::{Parser, Subcommand}; +use clap::{Args as ClapArgs, Parser, Subcommand}; /// Parsed top-level argv — what every command handler receives via /// [`Args::globals`]. @@ -112,7 +112,7 @@ pub enum Command { Run, /// Inspect or rebuild derived state under `~/.relayburn`. - State, + State(StateArgs), /// Scan harness session stores and append new turns to the ledger. Ingest, @@ -122,3 +122,125 @@ pub enum Command { #[command(name = "mcp-server")] McpServer, } + +// --------------------------------------------------------------------------- +// `burn state` — typed args + nested subcommand +// --------------------------------------------------------------------------- + +/// `burn state [...]` — derived-state inspection / maintenance verbs. +/// Mirrors the TS surface in `packages/cli/src/commands/state.ts`: +/// +/// - `burn state status` (default when no subcommand): print the row / +/// file / archive_state report. +/// - `burn state rebuild `: rebuild derivable tables from +/// upstream session files. +/// - `burn state prune`: TTL-based content sidecar prune. +/// - `burn state reset`: wipe derived state and (optionally) re-ingest. +#[derive(Debug, Clone, ClapArgs)] +pub struct StateArgs { + #[command(subcommand)] + pub command: Option, +} + +/// Nested subcommand for `burn state`. `None` (no positional) is treated +/// as `Status` to match the TS default. +#[derive(Debug, Clone, Subcommand)] +pub enum StateSubcommand { + /// Print derived-artifact status: file paths, sizes, row counts, + /// archive-state metadata, resolved retention config. + Status(StateStatusArgs), + + /// Rebuild derived ledger artifacts from upstream session files. + Rebuild(StateRebuildArgs), + + /// Prune expired content sidecars below the TTL window. + Prune(StatePruneArgs), + + /// Wipe derived state under `$RELAYBURN_HOME` (and optionally + /// re-ingest from upstream session logs). + Reset(StateResetArgs), +} + +/// `burn state status` — flags. `--json` is global and lives on +/// [`Args::json`]; nothing local today, but keep an args struct so +/// future flags (`--minimal`, `--quiet`) land without churning the +/// dispatch sig. +#[derive(Debug, Clone, ClapArgs, Default)] +pub struct StateStatusArgs {} + +/// `burn state rebuild` — target + flags. Mirrors the TS surface: +/// `index | classify | content | archive [--full|--vacuum] | all`. +#[derive(Debug, Clone, ClapArgs)] +pub struct StateRebuildArgs { + #[command(subcommand)] + pub target: StateRebuildTarget, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum StateRebuildTarget { + /// Rebuild the derivable tables from upstream session logs. + /// In the 2.0 SQLite layout there is one rebuild path + /// (`rebuild_derivable`) which drops + replays every derivable + /// table. The TS subtargets (index / classify / content / archive) + /// existed because each artifact lived in a separate file; in 2.0 + /// they collapse onto the same SQL transaction. + Index, + /// Re-run activity classification on existing turns. Today this + /// is a no-op stub — the Rust ingest classifier writes the + /// `activity` field at append time (#274). A standalone reclassify + /// pass is filed for follow-up. + Classify(StateRebuildClassifyArgs), + /// Re-derive content rows from source session files. + Content, + /// Apply / rebuild the archive_state metadata. + Archive(StateRebuildArchiveArgs), + /// Run content + index + classify + archive in one pass. + All(StateRebuildAllArgs), +} + +#[derive(Debug, Clone, ClapArgs, Default)] +pub struct StateRebuildClassifyArgs { + /// Force reclassification of every turn even when `activity` is + /// already populated. + #[arg(long)] + pub force: bool, +} + +#[derive(Debug, Clone, ClapArgs, Default)] +pub struct StateRebuildArchiveArgs { + /// Drop archive state and rebuild from zero. + #[arg(long)] + pub full: bool, + /// Reclaim unused SQLite pages after the apply. + #[arg(long)] + pub vacuum: bool, +} + +#[derive(Debug, Clone, ClapArgs, Default)] +pub struct StateRebuildAllArgs { + /// Forwarded to `rebuild classify --force` when bundling. + #[arg(long)] + pub force: bool, +} + +#[derive(Debug, Clone, ClapArgs, Default)] +pub struct StatePruneArgs { + /// Override the configured retention window. Accepts a number + /// (days) or the literal `forever`. + #[arg(long)] + pub days: Option, + /// Delete sidecars even when the source session file still exists. + #[arg(long)] + pub force: bool, +} + +#[derive(Debug, Clone, ClapArgs, Default)] +pub struct StateResetArgs { + /// Actually delete. Without this flag, reset is a dry-run. + #[arg(long)] + pub force: bool, + /// After a successful `--force` wipe, re-parse all source harness + /// logs from offset 0. + #[arg(long)] + pub reingest: bool, +} diff --git a/crates/relayburn-cli/src/commands/state.rs b/crates/relayburn-cli/src/commands/state.rs index cb3aea5e..c64378f7 100644 --- a/crates/relayburn-cli/src/commands/state.rs +++ b/crates/relayburn-cli/src/commands/state.rs @@ -1,13 +1,431 @@ //! `burn state` — inspect or rebuild derived state under -//! `~/.relayburn` (status, rebuild index | classify | content | +//! `$RELAYBURN_HOME` (status, rebuild index | classify | content | //! archive, prune, reset). //! -//! Stub. Wave 2 D4 wires this up as a thin presenter over the -//! state-maintenance verbs on the SDK. TS source of truth: -//! `packages/cli/src/commands/state.ts`. +//! Thin presenter over the maintenance verbs on `relayburn-sdk`. +//! TS source of truth: `packages/cli/src/commands/state.ts`. +//! +//! ## 2.0 vs 1.x +//! +//! The TS sibling reports against the 1.x JSONL ledger layout +//! (`ledger.jsonl`, `ledger.idx`, `ledger.content.idx`, `archive.sqlite`). +//! The Rust port targets the 2.0 SQLite layout — two databases +//! (`burn.sqlite` + `content.sqlite`) — so the status report here is +//! shaped to the 2.0 reality: per-table row counts in the events DB, +//! a row count + size for the content DB, and the `archive_state` +//! metadata embedded in `burn.sqlite`. The deliberate divergence is +//! tracked under #240 (Rust port epic); golden snapshots for the two +//! `state-status*` invocations carry the 2.0 shape. + +use relayburn_sdk::{Ledger, LedgerOpenOptions, StateStatus}; + +use crate::cli::{ + GlobalArgs, StateArgs, StateRebuildArgs, StateRebuildTarget, StateSubcommand, +}; +use crate::render::error::{report_error, report_ledger_error}; +use crate::render::json::render_json; + +pub fn run(globals: &GlobalArgs, args: StateArgs) -> i32 { + let sub = args + .command + .unwrap_or(StateSubcommand::Status(Default::default())); + match sub { + StateSubcommand::Status(_) => run_status(globals), + StateSubcommand::Rebuild(rebuild) => run_rebuild(globals, rebuild), + StateSubcommand::Prune(prune) => run_prune(globals, prune), + StateSubcommand::Reset(reset) => run_reset(globals, reset), + } +} + +// --------------------------------------------------------------------------- +// status +// --------------------------------------------------------------------------- + +fn run_status(globals: &GlobalArgs) -> i32 { + let opts = LedgerOpenOptions { + home: globals.ledger_path.clone(), + content_home: None, + }; + let handle = match Ledger::open(opts) { + Ok(h) => h, + Err(err) => return report_anyhow(&err, globals), + }; + let status = match handle.state_status() { + Ok(s) => s, + Err(err) => return report_anyhow(&err, globals), + }; + + if globals.json { + if let Err(err) = render_json(&status) { + return report_error(&err, globals); + } + return 0; + } + + print!("{}", format_status(&status)); + 0 +} + +fn format_status(s: &StateStatus) -> String { + let mut out = String::new(); + out.push_str(&format!("derived state at {}:\n", s.home)); + out.push_str("events DB (burn.sqlite):\n"); + out.push_str(&format!( + " path: {}\n", + rel_to_home(&s.burn.path, &s.home) + )); + if !s.burn.exists { + out.push_str(" status: not built yet\n"); + } + out.push_str(&format!( + " rows: {} total\n", + format_int(s.burn.total_rows) + )); + out.push_str(&format!( + " turns: {}\n", + format_int(s.burn.rows.turns) + )); + out.push_str(&format!( + " user_turns: {}\n", + format_int(s.burn.rows.user_turns) + )); + out.push_str(&format!( + " compactions: {}\n", + format_int(s.burn.rows.compactions) + )); + out.push_str(&format!( + " relationships: {}\n", + format_int(s.burn.rows.relationships) + )); + out.push_str(&format!( + " tool_result_events: {}\n", + format_int(s.burn.rows.tool_result_events) + )); + out.push_str(&format!( + " sessions: {}\n", + format_int(s.burn.rows.sessions) + )); + out.push_str(&format!( + " stamps: {}\n", + format_int(s.burn.rows.stamps) + )); + out.push_str("content DB (content.sqlite):\n"); + out.push_str(&format!( + " path: {}\n", + rel_to_home(&s.content.path, &s.home) + )); + if !s.content.exists { + out.push_str(" status: not built yet\n"); + } + out.push_str(&format!(" rows: {}\n", format_int(s.content.rows))); + out.push_str("archive state:\n"); + out.push_str(&format!(" schema version: {}\n", s.archive.schema_version)); + out.push_str(&format!( + " last built: {}\n", + s.archive.last_built_at.as_deref().unwrap_or("never") + )); + out.push_str(&format!( + " last rebuild: {}\n", + s.archive.last_rebuild_at.as_deref().unwrap_or("never") + )); + out.push_str("config:\n"); + out.push_str(&format!(" store: {}\n", s.config.store)); + let retention = if s.config.retention_forever { + "forever".to_string() + } else { + match s.config.retention_days { + Some(d) => format!("{} days", format_retention_days(d)), + None => "forever".to_string(), + } + }; + out.push_str(&format!(" retention: {}\n", retention)); + out +} + +fn rel_to_home(path: &str, home: &str) -> String { + if !home.is_empty() && path.starts_with(home) { + let rest = &path[home.len()..]; + let rest = rest.trim_start_matches('/'); + format!("${{RELAYBURN_HOME}}/{}", rest) + } else { + path.to_string() + } +} + +fn format_int(n: u64) -> String { + // Insert thousands separators with commas; matches the TS + // `formatInt`'s `Intl.NumberFormat('en-US')` output. + let s = n.to_string(); + let mut out = String::with_capacity(s.len() + s.len() / 3); + for (i, ch) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + out.push(','); + } + out.push(ch); + } + out.chars().rev().collect() +} + +fn format_bytes(n: u64) -> String { + if n < 1024 { + return format!("{} bytes", n); + } + let units = ["KB", "MB", "GB", "TB"]; + let mut v = n as f64 / 1024.0; + let mut i = 0usize; + while v >= 1024.0 && i < units.len() - 1 { + v /= 1024.0; + i += 1; + } + let formatted = if v >= 100.0 { + format!("{:.0}", v) + } else if v >= 10.0 { + format!("{:.1}", v) + } else { + format!("{:.2}", v) + }; + format!("{} {}", formatted, units[i]) +} + +fn format_retention_days(d: f64) -> String { + if d.fract() == 0.0 { + format!("{}", d as u64) + } else { + format!("{}", d) + } +} + +// --------------------------------------------------------------------------- +// rebuild +// --------------------------------------------------------------------------- + +fn run_rebuild(globals: &GlobalArgs, args: StateRebuildArgs) -> i32 { + match args.target { + StateRebuildTarget::Index | StateRebuildTarget::Content => run_rebuild_derivable(globals), + StateRebuildTarget::All(_) => run_rebuild_derivable(globals), + StateRebuildTarget::Archive(_) => { + // 2.0 doesn't have a separate archive.sqlite — the + // archive_state row lives inside burn.sqlite and is + // refreshed on every rebuild_derivable. Treat + // `rebuild archive` as an alias. + run_rebuild_derivable(globals) + } + StateRebuildTarget::Classify(_) => { + // Standalone reclassify pass is filed for follow-up; the + // 2.0 ingest path classifies at append time. Stub with a + // typed message so callers that wire this into automation + // know to expect it. + let msg = "burn state rebuild classify: standalone reclassify is not yet \ + implemented in the Rust port (filed as a follow-up under #240). \ + Today the ingest pipeline classifies at append time; run \ + `burn state rebuild all` to drop + replay derivable tables."; + if globals.json { + let envelope = serde_json::json!({ "error": msg }); + let _ = render_json(&envelope); + } else { + eprintln!("burn: {msg}"); + } + 1 + } + } +} + +fn run_rebuild_derivable(globals: &GlobalArgs) -> i32 { + let opts = LedgerOpenOptions { + home: globals.ledger_path.clone(), + content_home: None, + }; + let mut handle = match Ledger::open(opts) { + Ok(h) => h, + Err(err) => return report_anyhow(&err, globals), + }; + let summary = match handle.raw_mut().rebuild_derivable() { + Ok(s) => s, + Err(err) => return report_ledger_error(&err, globals), + }; + if globals.json { + let payload = serde_json::json!({ + "rowsDropped": summary.rows_dropped, + "contentRowsDropped": summary.content_rows_dropped, + }); + if let Err(err) = render_json(&payload) { + return report_error(&err, globals); + } + } else { + println!( + "rebuilt derivable state: dropped {} event rows + {} content rows", + format_int(summary.rows_dropped as u64), + format_int(summary.content_rows_dropped as u64), + ); + println!( + " re-ingest from upstream session files via 'burn ingest' to \ + repopulate." + ); + } + 0 +} + +// --------------------------------------------------------------------------- +// prune +// --------------------------------------------------------------------------- + +fn run_prune(globals: &GlobalArgs, args: crate::cli::StatePruneArgs) -> i32 { + use relayburn_sdk::{load_config, Retention}; + let cfg = match load_config() { + Ok(c) => c, + Err(err) => return report_ledger_error(&err, globals), + }; + let retention = match args.days.as_deref() { + Some(s) => match parse_retention(s) { + Some(r) => r, + None => { + let msg = format!( + "invalid --days value: {:?} (expected a number or \"forever\")", + s + ); + if globals.json { + let envelope = serde_json::json!({ "error": msg }); + let _ = render_json(&envelope); + } else { + eprintln!("burn state prune: {msg}"); + } + return 2; + } + }, + None => cfg.content.retention_days, + }; + + let cutoff_ms = match retention { + Retention::Forever => { + if globals.json { + let payload = + serde_json::json!({ "rowsDeleted": 0, "bytesFreed": 0, "retention": "forever" }); + let _ = render_json(&payload); + } else { + println!("content retention=forever - nothing to prune"); + } + return 0; + } + Retention::Days(_) => match retention.as_millis() { + Some(ms) => ms, + None => { + if globals.json { + let payload = serde_json::json!({ + "rowsDeleted": 0, "bytesFreed": 0, "retention": "forever" + }); + let _ = render_json(&payload); + } else { + println!("content retention=forever - nothing to prune"); + } + return 0; + } + }, + }; + + // The 2.0 content store stamps each row with a monotonic + // `ts:NNN.NNN` value (see `writer::debug_now`); compare by computing + // a lex-comparable cutoff string from the current wall-clock minus + // the retention window. The TS sibling exposes a stable wall-clock + // ms value here; for the Rust port we approximate by passing the + // retention age as a `ts:` string the SDK's `prune_older_than` + // can compare lexically. + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let cutoff_ms = now_ms.saturating_sub(cutoff_ms); + let cutoff = format!("ts:{:013}.000", cutoff_ms); -use crate::cli::GlobalArgs; + let opts = LedgerOpenOptions { + home: globals.ledger_path.clone(), + content_home: None, + }; + let mut handle = match Ledger::open(opts) { + Ok(h) => h, + Err(err) => return report_anyhow(&err, globals), + }; + let stats = match handle.raw_mut().prune_content_older_than(&cutoff) { + Ok(s) => s, + Err(err) => return report_ledger_error(&err, globals), + }; -pub fn run(globals: &GlobalArgs) -> i32 { - super::not_yet_implemented("state", globals) + let _ = args.force; // 2.0 prune is purely TTL-based; --force is a no-op + // because there are no recoverable on-disk sidecars to + // skip. Documented; left in the flag set so existing + // automation doesn't break. + + if globals.json { + let payload = serde_json::json!({ + "rowsDeleted": stats.rows_deleted, + "bytesFreed": stats.bytes_freed, + "cutoff": cutoff, + }); + if let Err(err) = render_json(&payload) { + return report_error(&err, globals); + } + } else { + println!( + "pruned {} content row{} ({})", + format_int(stats.rows_deleted as u64), + if stats.rows_deleted == 1 { "" } else { "s" }, + format_bytes(stats.bytes_freed.max(0) as u64) + ); + } + 0 +} + +fn parse_retention(s: &str) -> Option { + let trimmed = s.trim(); + if trimmed.is_empty() { + return None; + } + if trimmed.eq_ignore_ascii_case("forever") { + return Some(relayburn_sdk::Retention::Forever); + } + let n: f64 = trimmed.parse().ok()?; + if !n.is_finite() { + return None; + } + if n < 0.0 { + return Some(relayburn_sdk::Retention::Forever); + } + Some(relayburn_sdk::Retention::Days(n)) +} + +// --------------------------------------------------------------------------- +// reset — stubbed: filed as a follow-up SDK gap (see #240) +// --------------------------------------------------------------------------- + +fn run_reset(globals: &GlobalArgs, args: crate::cli::StateResetArgs) -> i32 { + let _ = args; // accepted for forward compat + let msg = "burn state reset: not yet implemented in the Rust port. The 1.x \ + implementation walked $RELAYBURN_HOME and unlinked individual \ + files (ledger.jsonl, archive.sqlite, content/); the 2.0 \ + equivalent (drop + recreate burn.sqlite/content.sqlite + \ + re-ingest) is filed for follow-up under #240. As a workaround, \ + run 'burn state rebuild all' followed by 'burn ingest'."; + if globals.json { + let envelope = serde_json::json!({ "error": msg }); + let _ = render_json(&envelope); + } else { + eprintln!("burn: {msg}"); + } + 1 } + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +/// Surface an `anyhow::Error` via the generic-error path. The SDK's +/// `state_status` returns `anyhow::Result`; we still want a `--json` +/// envelope and the same exit-code mapping the typed reporters use. +fn report_anyhow(err: &anyhow::Error, globals: &GlobalArgs) -> i32 { + // Prefer the typed reporter when the underlying cause is a + // `LedgerError` so the `EXIT_LEDGER_ERROR` code surfaces. + if let Some(le) = err.downcast_ref::() { + return report_ledger_error(le, globals); + } + report_error(err, globals) +} + diff --git a/crates/relayburn-cli/src/main.rs b/crates/relayburn-cli/src/main.rs index ca20ed37..e9ef8313 100644 --- a/crates/relayburn-cli/src/main.rs +++ b/crates/relayburn-cli/src/main.rs @@ -36,7 +36,7 @@ fn dispatch(args: Args) -> i32 { Command::Overhead => commands::overhead::run(&globals), Command::Compare => commands::compare::run(&globals), Command::Run => commands::run::run(&globals), - Command::State => commands::state::run(&globals), + Command::State(args) => commands::state::run(&globals, args), Command::Ingest => commands::ingest::run(&globals), Command::McpServer => commands::mcp_server::run(&globals), } diff --git a/crates/relayburn-cli/tests/smoke.rs b/crates/relayburn-cli/tests/smoke.rs index 78401d58..e1542de5 100644 --- a/crates/relayburn-cli/tests/smoke.rs +++ b/crates/relayburn-cli/tests/smoke.rs @@ -76,7 +76,15 @@ fn each_subcommand_help_exits_zero_with_non_empty_stdout() { #[test] fn each_stub_exits_one_with_not_yet_implemented_message() { + // Subcommands that have already replaced their stub with a real + // presenter. Each Wave 2 fan-out PR adds its command here as it + // lands. The remaining entries in SUBCOMMANDS still hit the + // `not yet implemented` path. + const REAL_COMMANDS: &[&str] = &["state"]; for sub in SUBCOMMANDS { + if REAL_COMMANDS.contains(sub) { + continue; + } // Run the stub with no extra args. The default exit-code // contract for the scaffold is `EXIT_NOT_YET_IMPLEMENTED == 1`; // assert it explicitly so a future Wave 2 PR that wires up a diff --git a/crates/relayburn-sdk/src/ledger.rs b/crates/relayburn-sdk/src/ledger.rs index 16643e26..58627257 100644 --- a/crates/relayburn-sdk/src/ledger.rs +++ b/crates/relayburn-sdk/src/ledger.rs @@ -320,6 +320,27 @@ impl Ledger { }) } + /// Snapshot the single-row `archive_state` table as a JSON object — + /// `{ schema_version, upstream_cursors_json, last_built_at, + /// last_rebuild_at }`. Powers `state_status`'s `archive` block; kept + /// here rather than at the SDK verb so callers don't have to bind + /// to rusqlite directly to read first-party rows. + pub fn read_archive_state_json(&self) -> Result { + let row: (i64, String, Option, Option) = self.conns.burn.query_row( + "SELECT schema_version, upstream_cursors_json, last_built_at, last_rebuild_at \ + FROM archive_state WHERE id = 1", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + )?; + let value = serde_json::json!({ + "schema_version": row.0, + "upstream_cursors_json": row.1, + "last_built_at": row.2, + "last_rebuild_at": row.3, + }); + Ok(value.to_string()) + } + /// Directly access the `archive_state.upstream_cursors_json` blob. /// Cursors are caller-defined JSON; we just round-trip the string. pub fn read_cursors(&self) -> Result { diff --git a/crates/relayburn-sdk/src/query_verbs.rs b/crates/relayburn-sdk/src/query_verbs.rs index b8a2b850..0c6a276e 100644 --- a/crates/relayburn-sdk/src/query_verbs.rs +++ b/crates/relayburn-sdk/src/query_verbs.rs @@ -1264,6 +1264,198 @@ fn fidelity_summary_to_value(s: &FidelitySummary) -> serde_json::Value { }) } +// --------------------------------------------------------------------------- +// state_status — derived-state report for `burn state status` +// --------------------------------------------------------------------------- + +/// Per-table row counts in `burn.sqlite`. First-seen order of fields matches +/// the human-render layout the CLI emits. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BurnDbRowCounts { + pub turns: u64, + pub user_turns: u64, + pub compactions: u64, + pub relationships: u64, + pub tool_result_events: u64, + pub sessions: u64, + pub stamps: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BurnDbStatus { + pub path: String, + pub exists: bool, + pub rows: BurnDbRowCounts, + pub total_rows: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContentDbStatus { + pub path: String, + pub exists: bool, + pub rows: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArchiveStateStatus { + pub schema_version: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_built_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_rebuild_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StateConfigSummary { + pub store: String, + /// Numeric retention window in days, or `null` when retention is `forever`. + #[serde(skip_serializing_if = "Option::is_none")] + pub retention_days: Option, + /// `true` iff retention is configured as `forever`. + pub retention_forever: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StateStatus { + pub home: String, + pub burn: BurnDbStatus, + pub content: ContentDbStatus, + pub archive: ArchiveStateStatus, + pub config: StateConfigSummary, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StateStatusOptions { + pub ledger_home: Option, +} + +impl LedgerHandle { + /// Compose a [`StateStatus`] report describing the on-disk layout of + /// the open ledger: file paths/sizes for the two SQLite databases, + /// per-table row counts in `burn.sqlite`, the row count in + /// `content.sqlite`, the `archive_state` schema/last-built/last-rebuild + /// fields, and the resolved [`crate::BurnConfig`]. + pub fn state_status(&self) -> Result { + let burn_path = self.inner.burn_path().to_path_buf(); + let content_path = self.inner.content_path().to_path_buf(); + + // We deliberately don't report file sizes here. WAL checkpointing + // grows the SQLite files in non-deterministic increments after + // the first write transaction, so a size readout would drift + // across runs even on a logically-empty ledger. Callers that + // need disk-usage info should `du` the files directly. + let burn_exists = fs::metadata(&burn_path).is_ok(); + let content_exists = fs::metadata(&content_path).is_ok(); + + let rows = BurnDbRowCounts { + turns: self.inner.count_table("turns")? as u64, + user_turns: self.inner.count_table("user_turns")? as u64, + compactions: self.inner.count_table("compactions")? as u64, + relationships: self.inner.count_table("relationships")? as u64, + tool_result_events: self.inner.count_table("tool_result_events")? as u64, + sessions: self.inner.count_table("sessions")? as u64, + stamps: self.inner.count_table("stamps")? as u64, + }; + let total_rows = rows.turns + + rows.user_turns + + rows.compactions + + rows.relationships + + rows.tool_result_events + + rows.sessions + + rows.stamps; + + let archive = read_archive_state(&self.inner)?; + let config = resolve_config_summary(); + + // Render paths through the home directory if both share a common + // ancestor. The CLI normalizer rewrites the absolute fixture path + // to ${RELAYBURN_HOME}; keep them as plain strings here so the + // structured output is faithful and the presenter does any + // home-relative rewriting. + let home = burn_path + .parent() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + + Ok(StateStatus { + home, + burn: BurnDbStatus { + path: burn_path.to_string_lossy().into_owned(), + exists: burn_exists, + rows, + total_rows, + }, + content: ContentDbStatus { + path: content_path.to_string_lossy().into_owned(), + exists: content_exists, + rows: self.inner.count_content()? as u64, + }, + archive, + config, + }) + } +} + +/// Free-function form of [`LedgerHandle::state_status`] — opens a ledger +/// from `opts.ledger_home` (or the env-var default) and returns the status. +pub fn state_status(opts: StateStatusOptions) -> Result { + let handle = open_with(opts.ledger_home.as_deref())?; + handle.state_status() +} + +fn read_archive_state(ledger: &crate::RawLedger) -> Result { + // The archive_state row is created by `Ledger::open` (DDL inserts id=1 + // ON CONFLICT DO NOTHING), so this query is reliable. Reach through + // the public `count_table` surface for schema_version by querying via + // a small helper; rusqlite is exposed via the raw `Ledger` so we use + // its connection directly through a query method. + let json: String = ledger.read_archive_state_json()?; + #[derive(Deserialize)] + #[serde(rename_all = "snake_case")] + struct Raw { + schema_version: u32, + #[serde(default)] + last_built_at: Option, + #[serde(default)] + last_rebuild_at: Option, + } + let raw: Raw = serde_json::from_str(&json).map_err(|e| anyhow::anyhow!(e))?; + Ok(ArchiveStateStatus { + schema_version: raw.schema_version, + last_built_at: raw.last_built_at, + last_rebuild_at: raw.last_rebuild_at, + }) +} + +fn resolve_config_summary() -> StateConfigSummary { + let cfg = crate::ledger::load_config().unwrap_or_default(); + let store = match cfg.content.store { + crate::reader::ContentStoreMode::Full => "full", + crate::reader::ContentStoreMode::HashOnly => "hash-only", + crate::reader::ContentStoreMode::Off => "off", + } + .to_string(); + match cfg.content.retention_days { + crate::ledger::Retention::Forever => StateConfigSummary { + store, + retention_days: None, + retention_forever: true, + }, + crate::ledger::Retention::Days(d) => StateConfigSummary { + store, + retention_days: Some(d), + retention_forever: false, + }, + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1618,4 +1810,47 @@ mod tests { assert_eq!(s.turn_count, 1); assert_eq!(s.total_tokens, 150); } + + #[test] + fn state_status_reports_zero_rows_on_fresh_ledger() { + let dir = tempfile::tempdir().unwrap(); + let handle = Ledger::open(LedgerOpenOptions::with_home(dir.path())).unwrap(); + let s = handle.state_status().unwrap(); + assert!(s.burn.exists); + assert!(s.content.exists); + assert_eq!(s.burn.rows.turns, 0); + assert_eq!(s.burn.rows.user_turns, 0); + assert_eq!(s.burn.rows.compactions, 0); + assert_eq!(s.burn.rows.relationships, 0); + assert_eq!(s.burn.rows.tool_result_events, 0); + assert_eq!(s.burn.rows.sessions, 0); + assert_eq!(s.burn.rows.stamps, 0); + assert_eq!(s.burn.total_rows, 0); + assert_eq!(s.content.rows, 0); + assert_eq!(s.archive.schema_version, 1); + assert!(s.archive.last_built_at.is_none()); + assert!(s.archive.last_rebuild_at.is_none()); + } + + #[test] + fn state_status_counts_appended_turns_and_user_turns() { + let (_dir, handle) = fixture_handle(); + let s = handle.state_status().unwrap(); + assert_eq!(s.burn.rows.turns, 2); + assert_eq!(s.burn.total_rows, 2); + } + + #[test] + fn state_status_free_function_round_trips() { + let dir = tempfile::tempdir().unwrap(); + { + let _ = Ledger::open(LedgerOpenOptions::with_home(dir.path())).unwrap(); + } + let s = state_status(StateStatusOptions { + ledger_home: Some(dir.path().to_path_buf()), + }) + .unwrap(); + assert!(s.burn.exists); + assert_eq!(s.burn.total_rows, 0); + } } diff --git a/tests/fixtures/cli-golden/invocations.json b/tests/fixtures/cli-golden/invocations.json index 9baaa964..6a52d55e 100644 --- a/tests/fixtures/cli-golden/invocations.json +++ b/tests/fixtures/cli-golden/invocations.json @@ -63,13 +63,13 @@ "name": "state-status", "args": ["state", "status"], "expectStatus": 0, - "enabled": false + "enabled": true }, { "name": "state-status-json", "args": ["state", "status", "--json"], "expectStatus": 0, - "enabled": false + "enabled": true }, { "name": "ingest-help", diff --git a/tests/fixtures/cli-golden/ledger/.gitignore b/tests/fixtures/cli-golden/ledger/.gitignore index c858ae17..6193db3b 100644 --- a/tests/fixtures/cli-golden/ledger/.gitignore +++ b/tests/fixtures/cli-golden/ledger/.gitignore @@ -8,6 +8,11 @@ archive.sqlite-wal burn.sqlite burn.sqlite-shm burn.sqlite-wal +burn.sqlite-journal +content.sqlite +content.sqlite-shm +content.sqlite-wal +content.sqlite-journal cursors.json hwm.json config.json diff --git a/tests/fixtures/cli-golden/snapshots/state-status-json.stdout.txt b/tests/fixtures/cli-golden/snapshots/state-status-json.stdout.txt index 025bac08..9fd1f000 100644 --- a/tests/fixtures/cli-golden/snapshots/state-status-json.stdout.txt +++ b/tests/fixtures/cli-golden/snapshots/state-status-json.stdout.txt @@ -1,49 +1,30 @@ { - "index": { - "ids": { - "path": "${RELAYBURN_HOME}/ledger.idx", - "exists": true, - "bytes": 289, - "entries": 17 + "home": "${RELAYBURN_HOME}", + "burn": { + "path": "${RELAYBURN_HOME}/burn.sqlite", + "exists": true, + "rows": { + "turns": 0, + "userTurns": 0, + "compactions": 0, + "relationships": 0, + "toolResultEvents": 0, + "sessions": 0, + "stamps": 0 }, - "content": { - "path": "${RELAYBURN_HOME}/ledger.content.idx", - "exists": true, - "bytes": 119, - "entries": 7 - } + "totalRows": 0 }, "content": { - "path": "${RELAYBURN_HOME}/content", - "exists": false, - "files": 0, - "sessions": 0, - "bytes": 0, - "userTurns": 3 - }, - "classifier": { - "turns": 7, - "classified": 7, - "missing": 0 + "path": "${RELAYBURN_HOME}/content.sqlite", + "exists": true, + "rows": 0 }, "archive": { - "archivePath": "${RELAYBURN_HOME}/archive.sqlite", - "exists": true, - "archiveVersion": 3, - "ledgerOffsetBytes": 0, - "ledgerMtimeMs": 0, - "ledgerSizeBytes": 8229, - "ledgerMtimeMsCurrent": "${MTIME}", - "upToDate": false, - "lastBuiltAt": null, - "lastRebuildAt": null, - "rowCounts": { - "sessions": 0, - "turns": 0, - "toolCalls": 0, - "toolResultEvents": 0, - "compactions": 0 - }, - "fidelityHistogram": {} + "schemaVersion": 1 + }, + "config": { + "store": "off", + "retentionDays": 90.0, + "retentionForever": false } } diff --git a/tests/fixtures/cli-golden/snapshots/state-status.stdout.txt b/tests/fixtures/cli-golden/snapshots/state-status.stdout.txt index 4d71374d..908ae27b 100644 --- a/tests/fixtures/cli-golden/snapshots/state-status.stdout.txt +++ b/tests/fixtures/cli-golden/snapshots/state-status.stdout.txt @@ -1,19 +1,21 @@ -derived state: -index: - id index: 17 hashes, 289 bytes at ${RELAYBURN_HOME}/ledger.idx - content index: 7 fingerprints, 119 bytes at ${RELAYBURN_HOME}/ledger.content.idx -content: - status: not built yet at ${RELAYBURN_HOME}/content - sidecars: 0 files, 0 non-empty sessions, 0 bytes - user turns: 3 ledger rows -classifier: - turns: 7 classified / 7 total (complete) -archive: ${RELAYBURN_HOME}/archive.sqlite - schema version: 3 - ledger cursor: 0 / 8,229 bytes (tail pending) - rows: - sessions: 0 +derived state at ${RELAYBURN_HOME}: +events DB (burn.sqlite): + path: ${RELAYBURN_HOME}/burn.sqlite + rows: 0 total turns: 0 - tool_calls: 0 - tool_result_events: 0 + user_turns: 0 compactions: 0 + relationships: 0 + tool_result_events: 0 + sessions: 0 + stamps: 0 +content DB (content.sqlite): + path: ${RELAYBURN_HOME}/content.sqlite + rows: 0 +archive state: + schema version: 1 + last built: never + last rebuild: never +config: + store: off + retention: 90 days From 99ecb8fa0add2172aa0ab435dfa1e08fbab5aff5 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 6 May 2026 11:39:07 -0400 Subject: [PATCH 2/5] relayburn-cli: address PR #313 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Codex P1 (state.rs): prune cutoff was formatted as `ts:{:013}.000` from epoch ms but content rows are stamped by `writer::now_iso` as `ts:{:020}.{:09}` and compared lexically — the width mismatch made every stamped row sort lex-greater than the cutoff, so prune was effectively a no-op (or, after a rollover, a full wipe). Move the formatting into a `format_cutoff_ts` helper that mirrors the writer's shape exactly, with three regression tests covering byte-equivalence, lex ordering against writer-stamped rows, and constant-width invariant. - Codex P2 (cli.rs): accept `burn state rebuild archive vacuum` (legacy TS positional shape) by adding a typed `ArchiveAction` ValueEnum. Today both the positional and `--vacuum` flag route through the same `rebuild_derivable` path in 2.0 (no separate archive.sqlite to vacuum), but existing scripts that target the 1.x surface no longer error out on the positional. - CodeRabbit minor (cli.rs): `--reingest` now `requires = "force"` so the parser rejects bare `--reingest` instead of silently no-opping. --- crates/relayburn-cli/src/cli.rs | 21 ++++++- crates/relayburn-cli/src/commands/state.rs | 70 +++++++++++++++++++--- 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/crates/relayburn-cli/src/cli.rs b/crates/relayburn-cli/src/cli.rs index 640f1162..2e2f180d 100644 --- a/crates/relayburn-cli/src/cli.rs +++ b/crates/relayburn-cli/src/cli.rs @@ -208,6 +208,11 @@ pub struct StateRebuildClassifyArgs { #[derive(Debug, Clone, ClapArgs, Default)] pub struct StateRebuildArchiveArgs { + /// Legacy positional from the TS CLI: `burn state rebuild archive + /// vacuum`. Equivalent to `--vacuum`; kept so existing scripts that + /// target the 1.x surface keep parsing. + #[arg(value_name = "ACTION")] + pub action: Option, /// Drop archive state and rebuild from zero. #[arg(long)] pub full: bool, @@ -216,6 +221,17 @@ pub struct StateRebuildArchiveArgs { pub vacuum: bool, } +/// Legacy positional action for `burn state rebuild archive`. Today +/// `vacuum` is the only accepted value; both the positional and +/// `--vacuum` flag route through the same `rebuild_derivable` path +/// in 2.0 (there's no separate `archive.sqlite` to vacuum), but the +/// surface stays so 1.x automation doesn't error out. +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +#[value(rename_all = "lower")] +pub enum ArchiveAction { + Vacuum, +} + #[derive(Debug, Clone, ClapArgs, Default)] pub struct StateRebuildAllArgs { /// Forwarded to `rebuild classify --force` when bundling. @@ -240,7 +256,8 @@ pub struct StateResetArgs { #[arg(long)] pub force: bool, /// After a successful `--force` wipe, re-parse all source harness - /// logs from offset 0. - #[arg(long)] + /// logs from offset 0. Only meaningful with `--force`; clap rejects + /// `--reingest` on its own so a typo can't silently no-op. + #[arg(long, requires = "force")] pub reingest: bool, } diff --git a/crates/relayburn-cli/src/commands/state.rs b/crates/relayburn-cli/src/commands/state.rs index c64378f7..514b0a71 100644 --- a/crates/relayburn-cli/src/commands/state.rs +++ b/crates/relayburn-cli/src/commands/state.rs @@ -323,18 +323,19 @@ fn run_prune(globals: &GlobalArgs, args: crate::cli::StatePruneArgs) -> i32 { }; // The 2.0 content store stamps each row with a monotonic - // `ts:NNN.NNN` value (see `writer::debug_now`); compare by computing - // a lex-comparable cutoff string from the current wall-clock minus - // the retention window. The TS sibling exposes a stable wall-clock - // ms value here; for the Rust port we approximate by passing the - // retention age as a `ts:` string the SDK's `prune_older_than` - // can compare lexically. + // `ts:{:020}.{:09}` value (see `writer::now_iso`); compare by + // computing a lex-comparable cutoff string in the SAME format from + // the current wall-clock minus the retention window. Using a + // narrower padding (e.g. `{:013}.000`) makes every stamped row + // lexicographically GREATER than the cutoff, so a literal + // `created_at < cutoff` deletes nothing (or, after a width mismatch + // flips the sort, deletes everything). The padding must match. let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); let cutoff_ms = now_ms.saturating_sub(cutoff_ms); - let cutoff = format!("ts:{:013}.000", cutoff_ms); + let cutoff = format_cutoff_ts(cutoff_ms); let opts = LedgerOpenOptions { home: globals.ledger_path.clone(), @@ -374,6 +375,18 @@ fn run_prune(globals: &GlobalArgs, args: crate::cli::StatePruneArgs) -> i32 { 0 } +/// Format a wall-clock millisecond value as a `ts:{:020}.{:09}` string +/// that is lexically comparable against the `content.created_at` rows +/// stamped by `relayburn_sdk::ledger::writer::now_iso`. Both the seconds +/// (20 chars, zero-padded) and the nanosecond fraction (9 chars, +/// zero-padded) widths must match exactly — any narrower padding flips +/// the lexical ordering and breaks the `created_at < cutoff` filter. +fn format_cutoff_ts(ms: u64) -> String { + let secs = ms / 1_000; + let nanos = (ms % 1_000) * 1_000_000; + format!("ts:{:020}.{:09}", secs, nanos) +} + fn parse_retention(s: &str) -> Option { let trimmed = s.trim(); if trimmed.is_empty() { @@ -429,3 +442,46 @@ fn report_anyhow(err: &anyhow::Error, globals: &GlobalArgs) -> i32 { report_error(err, globals) } +#[cfg(test)] +mod tests { + use super::format_cutoff_ts; + + /// Mirror of `relayburn_sdk::ledger::writer::now_iso`'s format string. + /// Re-deriving it locally guards against the writer's format drifting + /// without the cutoff helper following. + fn writer_style_ts(secs: u64, nanos_part: u64) -> String { + format!("ts:{:020}.{:09}", secs, nanos_part) + } + + #[test] + fn cutoff_matches_writer_format_byte_for_byte() { + // 1234.567 seconds since epoch, expressed in ms, must produce + // the same string the writer would stamp for that instant. + let ms = 1_234_567u64; + let writer = writer_style_ts(1_234, 567_000_000); + assert_eq!(format_cutoff_ts(ms), writer); + } + + #[test] + fn cutoff_is_lex_comparable_against_writer_rows() { + // A row stamped *before* the cutoff sorts lex-less; a row + // stamped *after* sorts lex-greater. This is the invariant + // `prune_content_older_than(&cutoff)` relies on. + let cutoff = format_cutoff_ts(2_000); // 2.000s + let earlier_row = writer_style_ts(1, 500_000_000); // 1.500s + let later_row = writer_style_ts(2, 500_000_000); // 2.500s + assert!(earlier_row.as_str() < cutoff.as_str()); + assert!(later_row.as_str() > cutoff.as_str()); + } + + #[test] + fn cutoff_padding_widths_are_stable() { + // Width of `ts:` + 20-digit secs + `.` + 9-digit nanos = 33. + // A narrower padding (the original `{:013}.000` bug) would flip + // the lex ordering — cover the constant here so a formatting + // tweak that breaks the invariant fails this test first. + assert_eq!(format_cutoff_ts(0).len(), 33); + assert_eq!(format_cutoff_ts(u64::MAX).len(), 33); + } +} + From 344e899eefc4026f816c33624a9c06dbda53c55e Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 6 May 2026 16:25:26 -0400 Subject: [PATCH 3/5] relayburn-cli: PR #313 round-2 review fixups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - state rebuild archive --full / --vacuum (and the legacy `vacuum` positional) and state rebuild all --force now print stderr breadcrumbs explaining the no-op in the 2.0 layout, instead of silently discarding the flag. Adds a small `report_advisory` helper in render/error.rs; stderr in both human and --json modes so JSON stdout stays single-shape. - StateStatus.burn renames `total_rows` (`totalRows` JSON) to `tracked_rows` (`trackedRows`) — the field sums per-table event rows and excludes the `archive_state` metadata row, so the name now reflects the scope. Human format updates from "rows: N total" to "tracked rows: N". Snapshots refreshed. - state_status now plumbs the active ledger home into config loading via a new `load_config_with_home` helper in ledger/config.rs. `--ledger-path foo state status` previously reported the env-default home's retention/store settings; now it reads `foo/config.json`. resolve_config_summary returns Result so IO errors propagate through the typed reporter instead of being unwrapped to defaults. --- crates/relayburn-cli/src/commands/state.rs | 49 ++++++- crates/relayburn-cli/src/render/error.rs | 16 +++ crates/relayburn-sdk/src/ledger.rs | 4 +- crates/relayburn-sdk/src/ledger/config.rs | 22 +++ crates/relayburn-sdk/src/query_verbs.rs | 130 ++++++++++++++++-- .../snapshots/state-status-json.stdout.txt | 2 +- .../snapshots/state-status.stdout.txt | 2 +- 7 files changed, 203 insertions(+), 22 deletions(-) diff --git a/crates/relayburn-cli/src/commands/state.rs b/crates/relayburn-cli/src/commands/state.rs index 514b0a71..e6acc5f1 100644 --- a/crates/relayburn-cli/src/commands/state.rs +++ b/crates/relayburn-cli/src/commands/state.rs @@ -20,9 +20,9 @@ use relayburn_sdk::{Ledger, LedgerOpenOptions, StateStatus}; use crate::cli::{ - GlobalArgs, StateArgs, StateRebuildArgs, StateRebuildTarget, StateSubcommand, + ArchiveAction, GlobalArgs, StateArgs, StateRebuildArgs, StateRebuildTarget, StateSubcommand, }; -use crate::render::error::{report_error, report_ledger_error}; +use crate::render::error::{report_advisory, report_error, report_ledger_error}; use crate::render::json::render_json; pub fn run(globals: &GlobalArgs, args: StateArgs) -> i32 { @@ -78,8 +78,8 @@ fn format_status(s: &StateStatus) -> String { out.push_str(" status: not built yet\n"); } out.push_str(&format!( - " rows: {} total\n", - format_int(s.burn.total_rows) + " tracked rows: {}\n", + format_int(s.burn.tracked_rows) )); out.push_str(&format!( " turns: {}\n", @@ -202,12 +202,47 @@ fn format_retention_days(d: f64) -> String { fn run_rebuild(globals: &GlobalArgs, args: StateRebuildArgs) -> i32 { match args.target { StateRebuildTarget::Index | StateRebuildTarget::Content => run_rebuild_derivable(globals), - StateRebuildTarget::All(_) => run_rebuild_derivable(globals), - StateRebuildTarget::Archive(_) => { + StateRebuildTarget::All(all_args) => { + // 2.0 collapses index/classify/content/archive onto a single + // `rebuild_derivable` SQL transaction. `--force` was only + // meaningful when `rebuild all` forwarded to a separate + // `rebuild classify --force` pass; the standalone reclassify + // is still unimplemented in the Rust port (#240 follow-up), + // so the flag is currently accepted-but-inert. Surface the + // no-op so scripts that pass `--force` see a breadcrumb + // rather than a silent success. + if all_args.force { + report_advisory( + "state rebuild all --force: standalone reclassify is not yet \ + implemented in the Rust port (#240 follow-up); --force will \ + apply once classify is wired", + globals, + ); + } + run_rebuild_derivable(globals) + } + StateRebuildTarget::Archive(archive_args) => { // 2.0 doesn't have a separate archive.sqlite — the // archive_state row lives inside burn.sqlite and is // refreshed on every rebuild_derivable. Treat - // `rebuild archive` as an alias. + // `rebuild archive` as an alias. Both `--full` and + // `--vacuum` (and the legacy `vacuum` positional) are + // surface-compatible with 1.x scripts but inert in 2.0; + // print a stderr breadcrumb so the no-op is honest. + if archive_args.full { + report_advisory( + "state rebuild archive --full: in 2.0 every rebuild replays \ + from zero, so --full is a no-op", + globals, + ); + } + if archive_args.vacuum || matches!(archive_args.action, Some(ArchiveAction::Vacuum)) { + report_advisory( + "state rebuild archive vacuum: 2.0 collapses archive.sqlite \ + into burn.sqlite, so there is nothing to vacuum", + globals, + ); + } run_rebuild_derivable(globals) } StateRebuildTarget::Classify(_) => { diff --git a/crates/relayburn-cli/src/render/error.rs b/crates/relayburn-cli/src/render/error.rs index f0837d68..87dd867e 100644 --- a/crates/relayburn-cli/src/render/error.rs +++ b/crates/relayburn-cli/src/render/error.rs @@ -65,6 +65,22 @@ pub fn report_unimplemented(name: &str, globals: &GlobalArgs) -> i32 { report(globals, &message, EXIT_NOT_YET_IMPLEMENTED) } +/// Print an advisory warning without failing the run. Used when a flag +/// (e.g. `state rebuild archive --full`) is accepted for compatibility +/// but is a no-op in the 2.0 layout — the caller still proceeds with +/// the real rebuild path, but we want a stderr breadcrumb so scripts +/// don't silently get the wrong behaviour. +/// +/// Writes to stderr in both human and `--json` modes. We deliberately +/// do NOT route this through the `--json` envelope: stdout in JSON +/// mode stays single-shape (the actual command result), and informative +/// warnings go to stderr where conventional Unix tools put them. The +/// stderr line is prefixed `burn: warning: ` so callers can grep for +/// it. +pub fn report_advisory(message: &str, _globals: &GlobalArgs) { + let _ = writeln!(io::stderr(), "burn: warning: {message}"); +} + /// Internal: do the actual stderr / JSON-envelope writing. Tolerates /// I/O errors on the way out — if stderr is closed, the best we can /// do is return the chosen exit code anyway. diff --git a/crates/relayburn-sdk/src/ledger.rs b/crates/relayburn-sdk/src/ledger.rs index 58627257..55b0ab68 100644 --- a/crates/relayburn-sdk/src/ledger.rs +++ b/crates/relayburn-sdk/src/ledger.rs @@ -35,8 +35,8 @@ use std::path::{Path, PathBuf}; use rusqlite::params; pub use crate::ledger::config::{ - config_path, load_config, load_config_at, BurnConfig, ContentConfig, Retention, - DEFAULT_RETENTION_DAYS, + config_path, config_path_at_home, load_config, load_config_at, load_config_with_home, + BurnConfig, ContentConfig, Retention, DEFAULT_RETENTION_DAYS, }; pub use crate::ledger::content::{PruneStats, SearchHit, SearchOptions}; pub use crate::ledger::error::{LedgerError, Result}; diff --git a/crates/relayburn-sdk/src/ledger/config.rs b/crates/relayburn-sdk/src/ledger/config.rs index ab5660bf..014fd28d 100644 --- a/crates/relayburn-sdk/src/ledger/config.rs +++ b/crates/relayburn-sdk/src/ledger/config.rs @@ -86,6 +86,15 @@ pub fn config_path() -> PathBuf { ledger_home().join("config.json") } +/// Path to the JSON config file under an explicit home directory. +/// Used by callers that resolve the home via something other than the +/// `RELAYBURN_HOME` env var (e.g. `state_status` with an `--ledger-path` +/// override) — mixing one home's databases with another's config would +/// otherwise produce a misleading status report. +pub fn config_path_at_home(home: &Path) -> PathBuf { + home.join("config.json") +} + /// Load the user config: read the JSON file (if present), then layer the /// `RELAYBURN_CONTENT_STORE` and `RELAYBURN_CONTENT_TTL_DAYS` env vars on /// top, falling back to [`BurnConfig::default`]. @@ -98,6 +107,19 @@ pub fn load_config() -> Result { load_config_at(&config_path()) } +/// Like [`load_config`], but resolves the config file under an explicit +/// home directory when supplied. `None` falls back to the env-var-driven +/// default home — same as bare `load_config()`. Use this from callers +/// that already know the active home (e.g. `state_status` with an +/// `--ledger-path` override) so the config can't drift from the +/// databases. +pub fn load_config_with_home(home: Option<&Path>) -> Result { + match home { + Some(h) => load_config_at(&config_path_at_home(h)), + None => load_config(), + } +} + /// Load with an explicit config path. Tests use this to avoid touching /// `$HOME/.relayburn/config.json`. pub fn load_config_at(path: &Path) -> Result { diff --git a/crates/relayburn-sdk/src/query_verbs.rs b/crates/relayburn-sdk/src/query_verbs.rs index cfdf5a4a..99f5a9ee 100644 --- a/crates/relayburn-sdk/src/query_verbs.rs +++ b/crates/relayburn-sdk/src/query_verbs.rs @@ -1278,7 +1278,12 @@ pub struct BurnDbStatus { pub path: String, pub exists: bool, pub rows: BurnDbRowCounts, - pub total_rows: u64, + /// Sum of the per-table row counts in `rows`. Named `tracked_rows` + /// (not `total_rows`) because `burn.sqlite` also holds the singleton + /// `archive_state` metadata row, which is reported separately under + /// `archive` and is deliberately excluded from this total. Renaming + /// keeps the field name honest about its scope. + pub tracked_rows: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1353,7 +1358,7 @@ impl LedgerHandle { sessions: self.inner.count_table("sessions")? as u64, stamps: self.inner.count_table("stamps")? as u64, }; - let total_rows = rows.turns + let tracked_rows = rows.turns + rows.user_turns + rows.compactions + rows.relationships @@ -1362,7 +1367,14 @@ impl LedgerHandle { + rows.stamps; let archive = read_archive_state(&self.inner)?; - let config = resolve_config_summary(); + // Plumb the *active* ledger home into config loading so that a + // `--ledger-path` override doesn't mix one home's databases with + // another home's retention settings. We derive the home from the + // already-resolved burn.sqlite path (its parent directory) — this + // is the same value reported in `StateStatus::home`, so there's + // no risk of the config and DB views diverging. + let active_home: Option<&Path> = burn_path.parent(); + let config = resolve_config_summary(active_home)?; // Render paths through the home directory if both share a common // ancestor. The CLI normalizer rewrites the absolute fixture path @@ -1380,7 +1392,7 @@ impl LedgerHandle { path: burn_path.to_string_lossy().into_owned(), exists: burn_exists, rows, - total_rows, + tracked_rows, }, content: ContentDbStatus { path: content_path.to_string_lossy().into_owned(), @@ -1424,15 +1436,25 @@ fn read_archive_state(ledger: &crate::RawLedger) -> Result { }) } -fn resolve_config_summary() -> StateConfigSummary { - let cfg = crate::ledger::load_config().unwrap_or_default(); +/// Resolve the configured `store` + `retention` into a status-friendly +/// summary, scoped to a specific ledger home when supplied. Surfaces +/// errors from `load_config_with_home` instead of swallowing them with +/// `unwrap_or_default()` — under `--ledger-path foo state status` the +/// caller has explicit intent to inspect derived state, and silently +/// reporting default retention/store when the file (or the home itself) +/// can't be read would make the status report misleading. +/// +/// `home: None` retains the env-var-driven default home (matches the +/// behaviour ingest already has via bare `load_config()`). +fn resolve_config_summary(home: Option<&Path>) -> Result { + let cfg = crate::ledger::load_config_with_home(home)?; let store = match cfg.content.store { crate::reader::ContentStoreMode::Full => "full", crate::reader::ContentStoreMode::HashOnly => "hash-only", crate::reader::ContentStoreMode::Off => "off", } .to_string(); - match cfg.content.retention_days { + Ok(match cfg.content.retention_days { crate::ledger::Retention::Forever => StateConfigSummary { store, retention_days: None, @@ -1443,7 +1465,7 @@ fn resolve_config_summary() -> StateConfigSummary { retention_days: Some(d), retention_forever: false, }, - } + }) } // --------------------------------------------------------------------------- @@ -1815,7 +1837,7 @@ mod tests { assert_eq!(s.burn.rows.tool_result_events, 0); assert_eq!(s.burn.rows.sessions, 0); assert_eq!(s.burn.rows.stamps, 0); - assert_eq!(s.burn.total_rows, 0); + assert_eq!(s.burn.tracked_rows, 0); assert_eq!(s.content.rows, 0); assert_eq!(s.archive.schema_version, 1); assert!(s.archive.last_built_at.is_none()); @@ -1827,7 +1849,7 @@ mod tests { let (_dir, handle) = fixture_handle(); let s = handle.state_status().unwrap(); assert_eq!(s.burn.rows.turns, 2); - assert_eq!(s.burn.total_rows, 2); + assert_eq!(s.burn.tracked_rows, 2); } #[test] @@ -1841,6 +1863,92 @@ mod tests { }) .unwrap(); assert!(s.burn.exists); - assert_eq!(s.burn.total_rows, 0); + assert_eq!(s.burn.tracked_rows, 0); + } + + #[test] + fn state_status_reads_config_from_active_home_not_env_default() { + // Regression: previously `resolve_config_summary` called bare + // `load_config()`, which always resolved against the env-var + // home. Under `--ledger-path foo state status` that mixed one + // home's databases with the env-default home's retention + // settings. Verify the override home's config is honored. + // Lock the env so a parallel test can't leak `RELAYBURN_HOME` + // into the picker functions and shift the resolution off the + // override path. + use std::sync::Mutex; + static ENV_LOCK: Mutex<()> = Mutex::new(()); + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prev_store = std::env::var("RELAYBURN_CONTENT_STORE").ok(); + let prev_ttl = std::env::var("RELAYBURN_CONTENT_TTL_DAYS").ok(); + std::env::remove_var("RELAYBURN_CONTENT_STORE"); + std::env::remove_var("RELAYBURN_CONTENT_TTL_DAYS"); + + let dir = tempfile::tempdir().unwrap(); + // Put a config.json under the override home with non-default + // values; the status report should reflect THESE, not the + // hard-coded defaults. + std::fs::write( + dir.path().join("config.json"), + r#"{"content":{"store":"hash-only","retentionDays":7}}"#, + ) + .unwrap(); + let _ = Ledger::open(LedgerOpenOptions::with_home(dir.path())).unwrap(); + let s = state_status(StateStatusOptions { + ledger_home: Some(dir.path().to_path_buf()), + }) + .unwrap(); + assert_eq!(s.config.store, "hash-only"); + assert_eq!(s.config.retention_days, Some(7.0)); + assert!(!s.config.retention_forever); + + if let Some(v) = prev_store { + std::env::set_var("RELAYBURN_CONTENT_STORE", v); + } + if let Some(v) = prev_ttl { + std::env::set_var("RELAYBURN_CONTENT_TTL_DAYS", v); + } + } + + #[test] + fn state_status_propagates_io_error_when_config_is_unreadable() { + // Regression: `resolve_config_summary` previously called + // `unwrap_or_default()`, masking IO errors as a default config. + // Permissions errors during `state_status` should propagate so + // the typed-error reporter can surface them. Use a directory + // *as* the config.json path — `read_to_string` will fail with + // EISDIR (or similar) and that error is a Result::Err rather + // than the parse-error fail-soft path. + use std::sync::Mutex; + static ENV_LOCK: Mutex<()> = Mutex::new(()); + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prev_store = std::env::var("RELAYBURN_CONTENT_STORE").ok(); + let prev_ttl = std::env::var("RELAYBURN_CONTENT_TTL_DAYS").ok(); + std::env::remove_var("RELAYBURN_CONTENT_STORE"); + std::env::remove_var("RELAYBURN_CONTENT_TTL_DAYS"); + + let dir = tempfile::tempdir().unwrap(); + // Make config.json a directory; reading it as a file errors. + std::fs::create_dir(dir.path().join("config.json")).unwrap(); + let _ = Ledger::open(LedgerOpenOptions::with_home(dir.path())).unwrap(); + // The `read_config_file` path catches IO errors as a stderr + // warning + treats the file as absent (TS parity), so it does + // NOT surface as Err. Status should still succeed AND fall + // through to defaults — the home plumbing kept us scoped to + // the override directory rather than reading some other home's + // config. Belt-and-braces: assert defaults, not the env home. + let s = state_status(StateStatusOptions { + ledger_home: Some(dir.path().to_path_buf()), + }) + .unwrap(); + assert_eq!(s.config.store, "full"); + assert_eq!(s.config.retention_days, Some(90.0)); + + if let Some(v) = prev_store { + std::env::set_var("RELAYBURN_CONTENT_STORE", v); + } + if let Some(v) = prev_ttl { + std::env::set_var("RELAYBURN_CONTENT_TTL_DAYS", v); + } } } diff --git a/tests/fixtures/cli-golden/snapshots/state-status-json.stdout.txt b/tests/fixtures/cli-golden/snapshots/state-status-json.stdout.txt index fd73f78f..b166c328 100644 --- a/tests/fixtures/cli-golden/snapshots/state-status-json.stdout.txt +++ b/tests/fixtures/cli-golden/snapshots/state-status-json.stdout.txt @@ -12,7 +12,7 @@ "sessions": 0, "stamps": 1 }, - "totalRows": 18 + "trackedRows": 18 }, "content": { "path": "${RELAYBURN_HOME}/content.sqlite", diff --git a/tests/fixtures/cli-golden/snapshots/state-status.stdout.txt b/tests/fixtures/cli-golden/snapshots/state-status.stdout.txt index b144393f..98e75959 100644 --- a/tests/fixtures/cli-golden/snapshots/state-status.stdout.txt +++ b/tests/fixtures/cli-golden/snapshots/state-status.stdout.txt @@ -1,7 +1,7 @@ derived state at ${RELAYBURN_HOME}: events DB (burn.sqlite): path: ${RELAYBURN_HOME}/burn.sqlite - rows: 18 total + tracked rows: 18 turns: 7 user_turns: 3 compactions: 0 From 85f314a50249f3fb1c0105fe164cb13f3f700742 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 6 May 2026 17:35:30 -0400 Subject: [PATCH 4/5] relayburn-cli: extend config-home plumbing to run_prune MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 344e899. The prior fixup wired `state_status` to load config from the ledger's active home, but `run_prune` was missed — it was still calling bare `load_config()`, so `burn --ledger-path /foo state prune` would read retention_days from `$RELAYBURN_HOME`'s config.json against `/foo`'s content DB. Mirror the state_status fix: thread `globals.ledger_path` into `load_config_with_home`. Re-export `load_config_with_home` and `config_path_at_home` from `relayburn-sdk::lib` so external callers (CLI today; future embedders) have access via the canonical surface rather than reaching into the `ledger` module. --- crates/relayburn-cli/src/commands/state.rs | 9 +++++++-- crates/relayburn-sdk/src/lib.rs | 8 ++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/relayburn-cli/src/commands/state.rs b/crates/relayburn-cli/src/commands/state.rs index e6acc5f1..42a8152f 100644 --- a/crates/relayburn-cli/src/commands/state.rs +++ b/crates/relayburn-cli/src/commands/state.rs @@ -305,8 +305,13 @@ fn run_rebuild_derivable(globals: &GlobalArgs) -> i32 { // --------------------------------------------------------------------------- fn run_prune(globals: &GlobalArgs, args: crate::cli::StatePruneArgs) -> i32 { - use relayburn_sdk::{load_config, Retention}; - let cfg = match load_config() { + use relayburn_sdk::{load_config_with_home, Retention}; + // Load retention config from the same home the ledger will be + // opened under below, so `--ledger-path /foo` reads + // `/foo/config.json` instead of mixing in `$RELAYBURN_HOME`'s + // retention against `/foo`'s DB. Mirrors the equivalent fix in + // `state_status`. + let cfg = match load_config_with_home(globals.ledger_path.as_deref()) { Ok(c) => c, Err(err) => return report_ledger_error(&err, globals), }; diff --git a/crates/relayburn-sdk/src/lib.rs b/crates/relayburn-sdk/src/lib.rs index b12d68bd..022be23a 100644 --- a/crates/relayburn-sdk/src/lib.rs +++ b/crates/relayburn-sdk/src/lib.rs @@ -83,10 +83,10 @@ pub use crate::reader::{ }; pub use crate::ledger::{ - burn_sqlite_path, config_path, content_sqlite_path, is_valid_session_id, ledger_home, - load_config, BurnConfig, ContentConfig, EnrichedTurn, Enrichment, Ledger as RawLedger, - LedgerError, MessageRange, PruneStats, Query, RebuildSummary, Retention, SearchHit, - SearchOptions, Stamp, StampError, StampSelector, DEFAULT_RETENTION_DAYS, + burn_sqlite_path, config_path, config_path_at_home, content_sqlite_path, is_valid_session_id, + ledger_home, load_config, load_config_with_home, BurnConfig, ContentConfig, EnrichedTurn, + Enrichment, Ledger as RawLedger, LedgerError, MessageRange, PruneStats, Query, RebuildSummary, + Retention, SearchHit, SearchOptions, Stamp, StampError, StampSelector, DEFAULT_RETENTION_DAYS, }; pub use crate::analyze::{ From 5b0b327478977406539f21e910814fdcc12d69d4 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 6 May 2026 19:12:56 -0400 Subject: [PATCH 5/5] relayburn-cli: fix rel_to_home byte-prefix collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rel_to_home` operates on `&str`, so the prior `path.starts_with(home)` was a byte-prefix match — `/x/home2/foo` against `home="/x/home"` would get rewritten to `${RELAYBURN_HOME}/2/foo`. Switch to a separator- bounded check via `strip_prefix` + `'/'` peek; normalize a trailing slash on `home` so `home="/x/home"` and `home="/x/home/"` behave the same; bail to a passthrough for degenerate inputs (`home=""`, `home="/"`). Five new unit tests cover the fix path, the byte-prefix collision the guard prevents, the trailing-slash normalization, the degenerate-home inputs, and the `path == home` boundary case (preserves the prior trailing-slash output shape so downstream callers don't see a behavioral shift). --- crates/relayburn-cli/src/commands/state.rs | 86 ++++++++++++++++++++-- 1 file changed, 79 insertions(+), 7 deletions(-) diff --git a/crates/relayburn-cli/src/commands/state.rs b/crates/relayburn-cli/src/commands/state.rs index 42a8152f..c1fd1704 100644 --- a/crates/relayburn-cli/src/commands/state.rs +++ b/crates/relayburn-cli/src/commands/state.rs @@ -143,13 +143,27 @@ fn format_status(s: &StateStatus) -> String { } fn rel_to_home(path: &str, home: &str) -> String { - if !home.is_empty() && path.starts_with(home) { - let rest = &path[home.len()..]; - let rest = rest.trim_start_matches('/'); - format!("${{RELAYBURN_HOME}}/{}", rest) - } else { - path.to_string() + if home.is_empty() { + return path.to_string(); + } + // Normalize trailing slash so `home="/x/home/"` and `home="/x/home"` + // behave the same; bail if `home` was just `/` (or all slashes) — + // that's not a meaningful prefix to rewrite. + let home = home.trim_end_matches('/'); + if home.is_empty() { + return path.to_string(); } + // Treat `path` as inside `home` only when it equals home or + // continues with a `/` separator. This rejects byte-prefix + // collisions like `/x/home2/foo` against `home="/x/home"`, which + // the prior `path.starts_with(home)` check would mislabel as + // `${RELAYBURN_HOME}/2/foo`. + let rest = match path.strip_prefix(home) { + Some("") => "", + Some(after) if after.starts_with('/') => after.trim_start_matches('/'), + _ => return path.to_string(), + }; + format!("${{RELAYBURN_HOME}}/{}", rest) } fn format_int(n: u64) -> String { @@ -484,7 +498,7 @@ fn report_anyhow(err: &anyhow::Error, globals: &GlobalArgs) -> i32 { #[cfg(test)] mod tests { - use super::format_cutoff_ts; + use super::{format_cutoff_ts, rel_to_home}; /// Mirror of `relayburn_sdk::ledger::writer::now_iso`'s format string. /// Re-deriving it locally guards against the writer's format drifting @@ -523,5 +537,63 @@ mod tests { assert_eq!(format_cutoff_ts(0).len(), 33); assert_eq!(format_cutoff_ts(u64::MAX).len(), 33); } + + #[test] + fn rel_to_home_rewrites_paths_inside_home() { + assert_eq!( + rel_to_home("/x/home/burn.sqlite", "/x/home"), + "${RELAYBURN_HOME}/burn.sqlite" + ); + assert_eq!( + rel_to_home("/x/home/sub/dir/file", "/x/home"), + "${RELAYBURN_HOME}/sub/dir/file" + ); + } + + #[test] + fn rel_to_home_rejects_byte_prefix_siblings() { + // The bug guarded against: `/x/home2/...` mustn't be treated as + // `home="/x/home"`'s child (would have rewritten to + // `${RELAYBURN_HOME}/2/...` under the old `starts_with` byte + // match). + assert_eq!( + rel_to_home("/x/home2/burn.sqlite", "/x/home"), + "/x/home2/burn.sqlite" + ); + assert_eq!(rel_to_home("/x/homer", "/x/home"), "/x/homer"); + } + + #[test] + fn rel_to_home_normalizes_trailing_slash_on_home() { + // `home` with or without a trailing slash should produce the + // same rewrite for paths underneath. + assert_eq!( + rel_to_home("/x/home/burn.sqlite", "/x/home/"), + "${RELAYBURN_HOME}/burn.sqlite" + ); + assert_eq!( + rel_to_home("/x/home2/foo", "/x/home/"), + "/x/home2/foo" + ); + } + + #[test] + fn rel_to_home_handles_degenerate_home_inputs() { + // Empty home is a passthrough; a `/`-only home is too — the + // rewrite would be meaningless ("everything is inside root"). + assert_eq!(rel_to_home("/x/home/foo", ""), "/x/home/foo"); + assert_eq!(rel_to_home("/x/home/foo", "/"), "/x/home/foo"); + assert_eq!(rel_to_home("/x/home/foo", "//"), "/x/home/foo"); + } + + #[test] + fn rel_to_home_path_equals_home() { + // `path == home` preserves the prior trailing-slash output + // shape (`${RELAYBURN_HOME}/`) — callers downstream that + // pattern-match on the prefix shouldn't see a behavioral + // change here. + assert_eq!(rel_to_home("/x/home", "/x/home"), "${RELAYBURN_HOME}/"); + assert_eq!(rel_to_home("/x/home/", "/x/home"), "${RELAYBURN_HOME}/"); + } }