Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ Cross-package release notes for relayburn. Package changelogs contain package-le

## [Unreleased]

### Added

- `relayburn-cli` / `relayburn-sdk`: `burn state reset` is now a real
presenter over the new SDK `Ledger::reset()` verb. Without flags it
dry-runs (counts derivable rows, stamps, and content rows that would
drop, exits 0); `--force` actually wipes both DBs and blanks the
ingest cursors so a follow-up `burn ingest` walks every upstream file
from offset 0; `--force --reingest` runs that ingest sweep in the
same invocation. Replaces the prior "not yet implemented" stub.
(#341)

## [2.4.0] - 2026-05-08

### Changed
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions crates/relayburn-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ indexmap = { version = "2", features = ["serde"] }
assert_cmd = "2"
predicates = "3"

# `tempfile` gives smoke tests an isolated `RELAYBURN_HOME` so they don't
# touch the developer's real ledger when exercising `state` / `ingest`
# verbs end-to-end.
tempfile = "3"

# Async test harness for the `harnesses` module unit tests (lookup, factory
# round-trips). `rt-multi-thread` + `macros` lets `#[tokio::test]` resolve
# and gives spawned watch-loop ticks a runtime to land on.
Expand Down
155 changes: 142 additions & 13 deletions crates/relayburn-cli/src/commands/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
//! 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 relayburn_sdk::{
ingest_all, Ledger, LedgerHandle, LedgerOpenOptions, RawIngestOptions, ResetSummary,
StateStatus,
};

use crate::cli::{
ArchiveAction, GlobalArgs, StateArgs, StateRebuildArgs, StateRebuildTarget, StateSubcommand,
Expand Down Expand Up @@ -460,24 +463,150 @@ fn parse_retention(s: &str) -> Option<relayburn_sdk::Retention> {
}

// ---------------------------------------------------------------------------
// reset — stubbed: filed as a follow-up SDK gap (see #240)
// reset
// ---------------------------------------------------------------------------
//
// Wipes derived state under `$RELAYBURN_HOME`. The 1.x sibling unlinked
// individual files (`ledger.jsonl`, `archive.sqlite`, `content/`); the
// 2.0 layout collapses onto two SQLite databases, so the equivalent is
// to truncate every derivable + first-party table inside `burn.sqlite`
// and the `content` table inside `content.sqlite`, then blank the
// ingest cursors so the next `burn ingest` walks every upstream file
// from offset 0.
//
// Without `--force`, this is a dry-run: it opens the ledger, counts
// what would be dropped, prints the report, and exits 0. With
// `--force`, the SDK `reset()` actually performs the wipe. With
// `--force --reingest`, a follow-up `ingest_all` sweep runs on the
// same handle.

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'.";
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),
};

if !args.force {
let summary = match handle.raw().count_reset_targets() {
Ok(s) => s,
Err(err) => return report_ledger_error(&err, globals),
};
return print_reset_report(globals, &summary, /*executed=*/ false, None);
}

let summary = match handle.raw_mut().reset() {
Ok(s) => s,
Err(err) => return report_ledger_error(&err, globals),
};

let ingest_report = if args.reingest {
match run_reset_reingest(&mut handle, globals.ledger_path.clone()) {
Ok(r) => Some(r),
Err(err) => return report_error(&err, globals),
}
} else {
None
};

print_reset_report(globals, &summary, /*executed=*/ true, ingest_report.as_ref())
}

/// Drive a single `ingest_all` sweep on the open handle. Mirrors the
/// `run_ingest` helper in `commands/summary.rs`: the SDK verb is async,
/// so we spin a current-thread tokio runtime to drive it from this
/// otherwise-sync presenter.
///
/// `ledger_home` propagates the global `--ledger-path` override into
/// `RawIngestOptions::ledger_home` so sidecar ingest state (config and
/// pending-stamp manifests) resolves under the same home as the open
/// handle. Without this, `burn --ledger-path <custom> state reset
/// --force --reingest` would write turns into the custom DB while
/// reading config / pending stamps from `$RELAYBURN_HOME`.
fn run_reset_reingest(
handle: &mut LedgerHandle,
ledger_home: Option<std::path::PathBuf>,
) -> anyhow::Result<relayburn_sdk::IngestReport> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let opts = RawIngestOptions {
ledger_home,
..RawIngestOptions::default()
};
rt.block_on(ingest_all(handle.raw_mut(), &opts))
}

fn print_reset_report(
globals: &GlobalArgs,
summary: &ResetSummary,
executed: bool,
ingest_report: Option<&relayburn_sdk::IngestReport>,
) -> i32 {
if globals.json {
let envelope = serde_json::json!({ "error": msg });
let _ = render_json(&envelope);
let mut payload = serde_json::json!({
"executed": executed,
"rowsDropped": summary.rows_dropped,
"stampsDropped": summary.stamps_dropped,
"contentRowsDropped": summary.content_rows_dropped,
});
if let Some(report) = ingest_report {
payload["reingest"] = serde_json::json!({
"scannedSessions": report.scanned_sessions,
"ingestedSessions": report.ingested_sessions,
"appendedTurns": report.appended_turns,
"appliedPendingStamps": report.applied_pending_stamps,
});
}
if let Err(err) = render_json(&payload) {
return report_error(&err, globals);
}
return 0;
}

if executed {
println!(
"reset derived state: dropped {} event row{} + {} stamp{} + {} content row{}",
format_int(summary.rows_dropped as u64),
if summary.rows_dropped == 1 { "" } else { "s" },
format_int(summary.stamps_dropped as u64),
if summary.stamps_dropped == 1 { "" } else { "s" },
format_int(summary.content_rows_dropped as u64),
if summary.content_rows_dropped == 1 { "" } else { "s" },
);
match ingest_report {
Some(report) => {
println!(
" re-ingested {} session{} (+{} turn{}).",
format_int(report.ingested_sessions as u64),
if report.ingested_sessions == 1 { "" } else { "s" },
format_int(report.appended_turns as u64),
if report.appended_turns == 1 { "" } else { "s" },
);
}
None => {
println!(
" re-ingest from upstream session files via 'burn ingest' to \
repopulate (or re-run with --reingest)."
);
}
}
} else {
eprintln!("burn: {msg}");
println!(
"burn state reset (dry run): would drop {} event row{} + {} stamp{} + {} content row{}.",
format_int(summary.rows_dropped as u64),
if summary.rows_dropped == 1 { "" } else { "s" },
format_int(summary.stamps_dropped as u64),
if summary.stamps_dropped == 1 { "" } else { "s" },
format_int(summary.content_rows_dropped as u64),
if summary.content_rows_dropped == 1 { "" } else { "s" },
);
println!(" re-run with --force to actually wipe (add --reingest to repopulate).");
}
1
0
}

// ---------------------------------------------------------------------------
Expand Down
70 changes: 70 additions & 0 deletions crates/relayburn-cli/tests/smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,73 @@ fn unknown_subcommand_exits_non_zero() {
fn run_subcommand_is_not_registered() {
burn().args(["run", "--help"]).assert().failure();
}

/// `burn state reset` (no `--force`) is a dry-run: it must open the
/// ledger, count what would be dropped, print a "would drop ... " line,
/// and exit 0 *without* mutating either DB. Pin the contract here so a
/// future refactor can't silently turn the dry-run destructive.
#[test]
fn state_reset_dry_run_does_not_mutate() {
let home = tempfile::TempDir::new().expect("tmp RELAYBURN_HOME");

burn()
.args(["state", "reset"])
.env("RELAYBURN_HOME", home.path())
.env("HOME", home.path())
.env("NO_COLOR", "1")
.assert()
.success()
.stdout(predicate::str::contains("dry run"))
.stdout(predicate::str::contains("--force"));

// Both DB files should exist (Ledger::open creates them) and be
// sized like a freshly-bootstrapped empty layout.
assert!(
home.path().join("burn.sqlite").is_file(),
"burn.sqlite must exist after dry-run open"
);
assert!(
home.path().join("content.sqlite").is_file(),
"content.sqlite must exist after dry-run open"
);
}

/// `burn state reset --force` actually wipes; pair it with `--json` so
/// we can assert on the structured envelope without depending on the
/// human-readable format.
#[test]
fn state_reset_force_emits_executed_envelope() {
let home = tempfile::TempDir::new().expect("tmp RELAYBURN_HOME");

let output = burn()
.args(["--json", "state", "reset", "--force"])
.env("RELAYBURN_HOME", home.path())
.env("HOME", home.path())
.env("NO_COLOR", "1")
.assert()
.success()
.get_output()
.clone();

let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout");
let value: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("--json output is valid JSON");
assert_eq!(value["executed"], serde_json::Value::Bool(true));
assert_eq!(value["rowsDropped"], serde_json::Value::from(0));
assert_eq!(value["stampsDropped"], serde_json::Value::from(0));
assert_eq!(value["contentRowsDropped"], serde_json::Value::from(0));
assert!(
value.get("reingest").is_none(),
"no `reingest` key without --reingest"
);
}

/// `--reingest` requires `--force`. Clap should reject the lone flag at
/// parse time so a typo can't silently no-op.
#[test]
fn state_reset_reingest_requires_force() {
burn()
.args(["state", "reset", "--reingest"])
.assert()
.failure();
}
Loading
Loading