diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f08e9a1..a43203d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 63efdea6..f138ddd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -828,6 +828,7 @@ dependencies = [ "relayburn-sdk", "serde", "serde_json", + "tempfile", "thiserror", "tokio", ] diff --git a/crates/relayburn-cli/Cargo.toml b/crates/relayburn-cli/Cargo.toml index a810ef96..99073915 100644 --- a/crates/relayburn-cli/Cargo.toml +++ b/crates/relayburn-cli/Cargo.toml @@ -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. diff --git a/crates/relayburn-cli/src/commands/state.rs b/crates/relayburn-cli/src/commands/state.rs index e0765b70..950f2cc9 100644 --- a/crates/relayburn-cli/src/commands/state.rs +++ b/crates/relayburn-cli/src/commands/state.rs @@ -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, @@ -460,24 +463,150 @@ fn parse_retention(s: &str) -> Option { } // --------------------------------------------------------------------------- -// 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 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, +) -> anyhow::Result { + 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 } // --------------------------------------------------------------------------- diff --git a/crates/relayburn-cli/tests/smoke.rs b/crates/relayburn-cli/tests/smoke.rs index 63787808..8048b355 100644 --- a/crates/relayburn-cli/tests/smoke.rs +++ b/crates/relayburn-cli/tests/smoke.rs @@ -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(); +} diff --git a/crates/relayburn-sdk/src/ledger.rs b/crates/relayburn-sdk/src/ledger.rs index d2cb50ce..fac33dad 100644 --- a/crates/relayburn-sdk/src/ledger.rs +++ b/crates/relayburn-sdk/src/ledger.rs @@ -372,6 +372,96 @@ impl Ledger { self.conns.content.execute_batch("VACUUM")?; Ok(()) } + + // --- state reset -------------------------------------------------- + + /// Count what a `reset()` would delete without mutating either DB. + /// Powers the dry-run path of `burn state reset` (no `--force`). + /// + /// SQL errors propagate via `Result`. A swallowed `unwrap_or(0)` here + /// would silently report a healthy zero-count dry-run on a corrupt + /// ledger and mislead operators into treating reset as a safe no-op. + pub fn count_reset_targets(&self) -> Result { + let mut rows_dropped = 0i64; + for table in DERIVABLE_TABLES { + let count: i64 = self.conns.burn.query_row( + &format!("SELECT COUNT(*) FROM {table}"), + [], + |r| r.get(0), + )?; + rows_dropped += count; + } + let stamps_dropped: i64 = self + .conns + .burn + .query_row("SELECT COUNT(*) FROM stamps", [], |r| r.get(0))?; + let content_rows_dropped = content::count_content(&self.conns.content)?; + Ok(ResetSummary { + rows_dropped: rows_dropped as usize, + stamps_dropped: stamps_dropped as usize, + content_rows_dropped: content_rows_dropped as usize, + }) + } + + /// Wipe **all** derived ledger state, including first-party stamps + /// and ingest cursors. Stronger than [`Self::rebuild_derivable`], + /// which preserves stamps and cursors so re-ingest is incremental. + /// + /// After `reset()` runs, both DBs are byte-equivalent to a fresh + /// `Ledger::open` against an empty `$RELAYBURN_HOME`: every + /// derivable table is empty, `stamps` is empty, `content.sqlite` + /// is empty (FTS index included), and `archive_state` is reset to + /// the bootstrap row (`schema_version` preserved, cursors blanked, + /// `last_built_at` / `last_rebuild_at` cleared). + /// + /// Re-ingest is the caller's responsibility; the CLI offers + /// `burn state reset --force --reingest` as a convenience that + /// drives `burn ingest` afterwards. + pub fn reset(&mut self) -> Result { + // Snapshot counts BEFORE we mutate so the returned summary + // describes what the call deleted, not what's left. + let summary = self.count_reset_targets()?; + + // Wipe derivable + stamps in a single transaction so an early + // failure can't leave the events DB half-emptied. + let tx = self.conns.burn.transaction()?; + for table in DERIVABLE_TABLES { + tx.execute(&format!("DELETE FROM {table}"), [])?; + } + tx.execute("DELETE FROM stamps", [])?; + // Reset archive_state to the bootstrap shape: keep the row + // (the CHECK constraint pins id=1) and the schema_version, but + // blank the cursors + build timestamps so the next ingest walks + // every upstream file from offset 0. + tx.execute( + "UPDATE archive_state \ + SET upstream_cursors_json = '{}', \ + last_built_at = NULL, \ + last_rebuild_at = NULL \ + WHERE id = 1", + [], + )?; + tx.commit()?; + + // Wipe content + the FTS index using the same drop-trigger / + // bulk-delete / rebuild dance as `rebuild_derivable`, so the + // FTS sync triggers don't pay tokenization cost per row. + self.conns.content.execute_batch( + "DROP TRIGGER IF EXISTS content_fts_ad; + DROP TRIGGER IF EXISTS content_fts_au; + DELETE FROM content; + CREATE TRIGGER content_fts_ad AFTER DELETE ON content BEGIN + INSERT INTO content_fts(content_fts, rowid, body) VALUES('delete', old.rowid, old.body); + END; + CREATE TRIGGER content_fts_au AFTER UPDATE ON content BEGIN + INSERT INTO content_fts(content_fts, rowid, body) VALUES('delete', old.rowid, old.body); + INSERT INTO content_fts(rowid, body) VALUES (new.rowid, new.body); + END; + INSERT INTO content_fts(content_fts) VALUES('rebuild');", + )?; + + Ok(summary) + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -380,6 +470,18 @@ pub struct RebuildSummary { pub content_rows_dropped: usize, } +/// Counts returned by [`Ledger::reset`] (and by the dry-run sibling +/// [`Ledger::count_reset_targets`]). `rows_dropped` covers the +/// derivable events tables; `stamps_dropped` is split out because +/// stamps are first-party data and the CLI surfaces them separately +/// so callers can see what they're about to lose. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResetSummary { + pub rows_dropped: usize, + pub stamps_dropped: usize, + pub content_rows_dropped: usize, +} + /// Convenience: layout describing where a `Ledger` will land. Callers /// that want test isolation construct one with `under()` and pass the /// paths to [`Ledger::open`]. diff --git a/crates/relayburn-sdk/src/ledger/tests.rs b/crates/relayburn-sdk/src/ledger/tests.rs index 8aad91a0..0d2b22df 100644 --- a/crates/relayburn-sdk/src/ledger/tests.rs +++ b/crates/relayburn-sdk/src/ledger/tests.rs @@ -257,6 +257,140 @@ fn cursors_survive_state_rebuild() { assert_eq!(cursors, r#"{"claude-code": "2025-01-01T00:00:00Z"}"#); } +#[test] +fn reset_wipes_derivable_stamps_content_and_cursors() { + // `reset()` is the harder-hitting sibling of `rebuild_derivable`: + // unlike rebuild, it MUST drop stamps and blank ingest cursors so + // a follow-up `burn ingest` walks every upstream file from offset + // 0. This test pins all three behaviours. + let tmp = TempDir::new().unwrap(); + let mut l = open_in(&tmp); + + l.write_cursors(r#"{"claude-code": "2025-01-01T00:00:00Z"}"#).unwrap(); + + let mut enrichment = BTreeMap::new(); + enrichment.insert("role".into(), "fix-bug".into()); + let stamp = Stamp::new( + "2025-01-01T00:00:00Z", + StampSelector { + session_id: Some("s1".into()), + ..Default::default() + }, + enrichment, + ) + .unwrap(); + l.append_stamp(&stamp).unwrap(); + + l.append_turns(&[make_turn("s1", "m1", "2025-01-01T00:00:00Z", 10)]) + .unwrap(); + l.append_content(&[make_content("s1", "m1", "out of memory error")]) + .unwrap(); + + assert_eq!(l.count_table("turns").unwrap(), 1); + assert_eq!(l.count_table("stamps").unwrap(), 1); + assert_eq!(l.count_content().unwrap(), 1); + + let summary = l.reset().unwrap(); + assert_eq!(summary.rows_dropped, 1, "1 derivable row dropped (turns)"); + assert_eq!(summary.stamps_dropped, 1); + assert_eq!(summary.content_rows_dropped, 1); + + assert_eq!(l.count_table("turns").unwrap(), 0); + assert_eq!(l.count_table("stamps").unwrap(), 0); + assert_eq!(l.count_content().unwrap(), 0); + assert_eq!( + l.read_cursors().unwrap(), + "{}", + "reset blanks ingest cursors" + ); + + // FTS must also be empty so post-reset search returns nothing. + let post = l.search_content(SearchOptions::new("memory")).unwrap(); + assert!(post.is_empty(), "FTS5 should be empty after reset"); +} + +#[test] +fn count_reset_targets_does_not_mutate() { + // The dry-run path of `burn state reset` calls + // `count_reset_targets()` and prints the would-drop counts. The + // call MUST be read-only — a stray DELETE here would silently turn + // every dry-run into a destructive op. + let tmp = TempDir::new().unwrap(); + let mut l = open_in(&tmp); + + let mut enrichment = BTreeMap::new(); + enrichment.insert("role".into(), "fix-bug".into()); + let stamp = Stamp::new( + "2025-01-01T00:00:00Z", + StampSelector { + session_id: Some("s1".into()), + ..Default::default() + }, + enrichment, + ) + .unwrap(); + l.append_stamp(&stamp).unwrap(); + l.append_turns(&[make_turn("s1", "m1", "2025-01-01T00:00:00Z", 10)]) + .unwrap(); + l.append_content(&[make_content("s1", "m1", "hello")]).unwrap(); + + let preview = l.count_reset_targets().unwrap(); + assert_eq!(preview.rows_dropped, 1); + assert_eq!(preview.stamps_dropped, 1); + assert_eq!(preview.content_rows_dropped, 1); + + // Nothing changed on disk. + assert_eq!(l.count_table("turns").unwrap(), 1); + assert_eq!(l.count_table("stamps").unwrap(), 1); + assert_eq!(l.count_content().unwrap(), 1); + + // A second call returns the same numbers — idempotent dry-run. + let preview2 = l.count_reset_targets().unwrap(); + assert_eq!(preview, preview2); +} + +#[test] +fn count_reset_targets_propagates_sql_errors() { + // Regression: before #341 review, this method swallowed + // `query_row` failures via `unwrap_or(0)` and reported a clean + // zero-count summary on a corrupt ledger. Drop the `turns` table + // out from under the open connection (a stand-in for any + // schema/corruption fault that would normally surface a + // `LedgerError::Sqlite`) and confirm the call now errors instead + // of silently returning `Ok(ResetSummary::default())`. + let tmp = TempDir::new().unwrap(); + let l = open_in(&tmp); + l.conns.burn.execute("DROP TABLE turns", []).unwrap(); + + let result = l.count_reset_targets(); + assert!( + result.is_err(), + "expected SQL failure to propagate, got {:?}", + result + ); +} + +#[test] +fn reset_is_idempotent_on_empty_ledger() { + // Running reset on a fresh ledger should be a no-op: zero counts, + // both DBs still openable, archive_state row still present (the + // CHECK constraint pins id=1, so "delete + reinsert" would have + // tripped the constraint). + let tmp = TempDir::new().unwrap(); + let mut l = open_in(&tmp); + + let summary = l.reset().unwrap(); + assert_eq!(summary, ResetSummary::default()); + + // archive_state row survives — read_cursors() reads `id = 1` and + // would error on a missing row. + assert_eq!(l.read_cursors().unwrap(), "{}"); + + // A second reset still works (re-checks transaction path). + let again = l.reset().unwrap(); + assert_eq!(again, ResetSummary::default()); +} + #[test] fn rebuild_clears_content_and_fts_index() { // Acceptance: `burn state rebuild` regenerates the entire diff --git a/crates/relayburn-sdk/src/lib.rs b/crates/relayburn-sdk/src/lib.rs index 30f750e8..c9724c8c 100644 --- a/crates/relayburn-sdk/src/lib.rs +++ b/crates/relayburn-sdk/src/lib.rs @@ -80,7 +80,8 @@ pub use crate::ledger::{ 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, + ResetSummary, Retention, SearchHit, SearchOptions, Stamp, StampError, StampSelector, + DEFAULT_RETENTION_DAYS, }; pub use crate::analyze::{ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a91aa6a..a7826b5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,17 +24,17 @@ importers: packages/relayburn: optionalDependencies: '@relayburn/cli-darwin-arm64': - specifier: 2.3.0 - version: 2.3.0 + specifier: 2.4.0 + version: 2.4.0 '@relayburn/cli-darwin-x64': - specifier: 2.3.0 - version: 2.3.0 + specifier: 2.4.0 + version: 2.4.0 '@relayburn/cli-linux-arm64-gnu': - specifier: 2.3.0 - version: 2.3.0 + specifier: 2.4.0 + version: 2.4.0 '@relayburn/cli-linux-x64-gnu': - specifier: 2.3.0 - version: 2.3.0 + specifier: 2.4.0 + version: 2.4.0 packages/relayburn/npm/darwin-arm64: {} @@ -54,17 +54,17 @@ importers: version: 0.25.12 optionalDependencies: '@relayburn/sdk-darwin-arm64': - specifier: 2.3.0 - version: 2.3.0 + specifier: 2.4.0 + version: 2.4.0 '@relayburn/sdk-darwin-x64': - specifier: 2.3.0 - version: 2.3.0 + specifier: 2.4.0 + version: 2.4.0 '@relayburn/sdk-linux-arm64-gnu': - specifier: 2.3.0 - version: 2.3.0 + specifier: 2.4.0 + version: 2.4.0 '@relayburn/sdk-linux-x64-gnu': - specifier: 2.3.0 - version: 2.3.0 + specifier: 2.4.0 + version: 2.4.0 packages/sdk-node/npm/darwin-arm64: {} @@ -237,54 +237,54 @@ packages: engines: {node: '>= 10'} hasBin: true - '@relayburn/cli-darwin-arm64@2.3.0': - resolution: {integrity: sha512-r2CknL1aPjA970Sg0k7doZRwFZVUyXafyRlO/Ip6DxMzFfHI4VgYZK/KOxRLfWQxj+qCT3pxzJOD/rEsW3kQCw==} + '@relayburn/cli-darwin-arm64@2.4.0': + resolution: {integrity: sha512-MbvkSlaQ4FtwexKH56zJpISaXwiH2F9S2G2o1wx4n2TpYcJP8FKLbwfpz0sv4OX06JyLIj9uzvxLxZm6b+TFCw==} engines: {node: '>=22'} cpu: [arm64] os: [darwin] hasBin: true - '@relayburn/cli-darwin-x64@2.3.0': - resolution: {integrity: sha512-o6wbTEEnAmYLwkPfHeacG7jIDkuGUM2s4hCjQMCQVUu7aH9i71HkMvswHwSUXbRJ/joCpcSodkYphMfOxqSzHw==} + '@relayburn/cli-darwin-x64@2.4.0': + resolution: {integrity: sha512-gXsCrn4oWMyYULwyv4DYqaE21uHs/KBPe/1DrMfBXxhmilYFZciXSRVPm3t3grupWoijeyPklyBCkvXWP7zNkQ==} engines: {node: '>=22'} cpu: [x64] os: [darwin] hasBin: true - '@relayburn/cli-linux-arm64-gnu@2.3.0': - resolution: {integrity: sha512-+3vEM8gsmhkozLmYm7Y6A6jlvG9xzbDLMH52RC76Z56EqQbQbimLbwGStYCyN71TZroNHuRYZ6U2bGjsqY4ypQ==} + '@relayburn/cli-linux-arm64-gnu@2.4.0': + resolution: {integrity: sha512-bahzWErFd2skzEO62tAoOyRbDB76fjZyTjVMpVWu24WKKZPS1G3Ek4VIr3/bXDN+MXAK0MonXMXMpr/2asM45A==} engines: {node: '>=22'} cpu: [arm64] os: [linux] hasBin: true - '@relayburn/cli-linux-x64-gnu@2.3.0': - resolution: {integrity: sha512-eZSqJpV5ihYWFDfD6LKdR8v5Mgl4RhEAKiHBieodGDzIq6T0N7zdFA7xKURgG+H0dE7fmFG6ALpo6kY7CHtDTA==} + '@relayburn/cli-linux-x64-gnu@2.4.0': + resolution: {integrity: sha512-lEXxAwM739VkFir/Nno0qFC85AwSnCjTliU/3Ar/lnzgqxTopAv+j4tkW1UKOMNXWyH4ATZlT3XQLE7haGoGFg==} engines: {node: '>=22'} cpu: [x64] os: [linux] hasBin: true - '@relayburn/sdk-darwin-arm64@2.3.0': - resolution: {integrity: sha512-O0vYF/RrXghOBXgA4oBJ6+ZdtBq37RJV0iGvgDuqipBalREZXthUU9eQDcdlPDqUknBLSEUWy2VucUvWI7dozA==} + '@relayburn/sdk-darwin-arm64@2.4.0': + resolution: {integrity: sha512-+PIfY583utAqoXuu5Ig2fuQWXFP6yp9F5ACLp7+78lH7r99lI8Iqao3Y6nXCo1wFI4kejfS5FdRpTKx0COGH2A==} engines: {node: '>=22'} cpu: [arm64] os: [darwin] - '@relayburn/sdk-darwin-x64@2.3.0': - resolution: {integrity: sha512-hdqY71XqC3dfdYhZ83Xs9CFwTbyLz5w7lhF7jOaEeR8k3pQLPLGaEp0iflqU2pCYE+QIH8emkUiZxrwCQPCUIA==} + '@relayburn/sdk-darwin-x64@2.4.0': + resolution: {integrity: sha512-uQ7yOhzM8U16q+t+drgw9L9ahiencU+b+7x77a+lYNkb/lBJsW4w6g6hPQhSN7oxNcVplNBAIvAv86HuEDzAMA==} engines: {node: '>=22'} cpu: [x64] os: [darwin] - '@relayburn/sdk-linux-arm64-gnu@2.3.0': - resolution: {integrity: sha512-Jsl1p/NBlXGVMkKqTaE3Dtvxh/CbPcvHu62bpSZ7eZr9Y/j/+8vzhQs9ig+E3/FGhyL06ziYtB1rrxXWYo6Pqw==} + '@relayburn/sdk-linux-arm64-gnu@2.4.0': + resolution: {integrity: sha512-sl0+x0QcHHEpgixmP7wl/AB/f24nVvzxucPlj6Pz3ZurASkh29XMl+nzOlE1VYE9/wUrwgCHdlQPM7TjMpq5ZQ==} engines: {node: '>=22'} cpu: [arm64] os: [linux] - '@relayburn/sdk-linux-x64-gnu@2.3.0': - resolution: {integrity: sha512-TRgPnQ/wB+yWlu7J7iULoWQ/T9JppFpeJwKx1p7nisHqGD3nIclMd8FlJ1CYbqaYRaRD/HMuBVqo02xzYXCl8Q==} + '@relayburn/sdk-linux-x64-gnu@2.4.0': + resolution: {integrity: sha512-RqmOZaHD/7xWYJljS41dW3F5aAn+YYbBbz9W0frhQKpirQqrjVG+N2pS90u/4cQ5DHhuRnFR5/jgxl8WnvWF+A==} engines: {node: '>=22'} cpu: [x64] os: [linux] @@ -387,28 +387,28 @@ snapshots: '@napi-rs/cli@2.18.4': {} - '@relayburn/cli-darwin-arm64@2.3.0': + '@relayburn/cli-darwin-arm64@2.4.0': optional: true - '@relayburn/cli-darwin-x64@2.3.0': + '@relayburn/cli-darwin-x64@2.4.0': optional: true - '@relayburn/cli-linux-arm64-gnu@2.3.0': + '@relayburn/cli-linux-arm64-gnu@2.4.0': optional: true - '@relayburn/cli-linux-x64-gnu@2.3.0': + '@relayburn/cli-linux-x64-gnu@2.4.0': optional: true - '@relayburn/sdk-darwin-arm64@2.3.0': + '@relayburn/sdk-darwin-arm64@2.4.0': optional: true - '@relayburn/sdk-darwin-x64@2.3.0': + '@relayburn/sdk-darwin-x64@2.4.0': optional: true - '@relayburn/sdk-linux-arm64-gnu@2.3.0': + '@relayburn/sdk-linux-arm64-gnu@2.4.0': optional: true - '@relayburn/sdk-linux-x64-gnu@2.3.0': + '@relayburn/sdk-linux-x64-gnu@2.4.0': optional: true '@types/node@22.19.18':