Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Cross-package release notes for relayburn. Package changelogs contain package-le

## [Unreleased]

- Read/report commands (`summary`, `hotspots`, `hotspots --findings`, and `sessions list`) now warn when the ledger has not received data within the configurable staleness threshold (24 hours by default); SDK and MCP consumers receive the same last-write timestamp and stale flag as data.

## [4.0.0] - 2026-06-23

- **BREAKING (`relayburn-sdk`):** the published Rust SDK no longer re-exports its low-level `analyze`-layer internals (detector/aggregator functions and helper types such as `PricingTable`, `CompareTable`, `CompareCell`) — these were never the intended embedding surface. Embed through the verb layer instead: `LedgerHandle` methods / `summary_report` / `hotspots` / `compare`. CLI, MCP, and `@relayburn/sdk` behavior is unchanged.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,13 +212,14 @@ content/search data in `content.sqlite`.
|---|---|
| `~/.agentworkforce/burn/burn.sqlite` | Events, stamps, sessions, relationships, and archive metadata. |
| `~/.agentworkforce/burn/content.sqlite` | Content blobs and the FTS5 search index. |
| `~/.agentworkforce/burn/config.json` | Content-storage and retention configuration. |
| `~/.agentworkforce/burn/config.json` | Content-storage, retention, and report-staleness configuration (`staleness.thresholdHours`; default `24`). |
| `~/.agentworkforce/burn/pending-stamps/` | Temporary manifests used by launchers that do not expose a session ID before spawn. |
| `RELAYBURN_HOME` | Override the whole Burn data directory. |
| `RELAYBURN_SQLITE_PATH` | Override the events database path. |
| `RELAYBURN_CONTENT_PATH` | Override the content database path. |
| `RELAYBURN_CONTENT_STORE=full|hash-only|off` | Control content sidecar storage. Default: `full`. |
| `RELAYBURN_CONTENT_STORE=full\|hash-only\|off` | Control content sidecar storage. Default: `full`. |
| `RELAYBURN_CONTENT_TTL_DAYS=<n>` | Sidecar retention. Default: `90`. |
| `RELAYBURN_STALE_AFTER_HOURS=<n>` | Age after which reads warn that the ledger is stale. Default: `24`; set `-1` to disable. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Reports read local data from the ledger and derived sidecars.

Expand Down
18 changes: 18 additions & 0 deletions crates/relayburn-cli/src/commands/freshness.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
use relayburn_sdk::LedgerFreshness;

use crate::cli::GlobalArgs;

/// Present SDK freshness data on stderr without coupling the SDK to a UI.
pub(crate) fn warn_if_stale(freshness: &LedgerFreshness, globals: &GlobalArgs) {
if !freshness.stale {
return;
}
let last = relayburn_cli::util::time::format_optional_epoch_ms(freshness.last_write_at_ms);
let threshold_hours = freshness.stale_after_ms.unwrap_or_default() as f64 / 3_600_000.0;
crate::render::ux::print_warning(
&format!(
"ledger data may be stale (last write: {last}; threshold: {threshold_hours:.1}h). If expected activity is missing, run `burn ingest` before relying on this report."
),
globals,
);
}
2 changes: 2 additions & 0 deletions crates/relayburn-cli/src/commands/hotspots/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ fn run_inner(globals: &GlobalArgs, args: HotspotsArgs) -> anyhow::Result<i32> {
let raw_opts = progress.ingest_options(ledger_home.clone());
ingest_all(handle.raw_mut(), &raw_opts)?;
}
let freshness = handle.ledger_freshness()?;
drop(handle);

let session_filter = match args.session.as_deref() {
Expand All @@ -283,6 +284,7 @@ fn run_inner(globals: &GlobalArgs, args: HotspotsArgs) -> anyhow::Result<i32> {
ledger_home,
})?;
progress.finish_and_clear();
crate::commands::freshness::warn_if_stale(&freshness, globals);

if globals.json {
emit_json(&result)?;
Expand Down
57 changes: 38 additions & 19 deletions crates/relayburn-cli/src/commands/mcp_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,10 @@ impl Server {
"Cheap polling primitive over the burn ledger. Returns \
`{count}:{maxMtimeUnix}:{totalBytes}` — three integers \
joined by colons. Clients keep the last-seen value and \
skip re-querying when it's unchanged. Optionally scoped \
to a session id or a project path. Read-only.",
skip re-querying when it's unchanged. The response also \
includes ledgerFreshness; check ledgerFreshness.stale \
before relying on ledger reads. Optionally scoped to a \
session id or a project path. Read-only.",
"inputSchema": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -352,23 +354,25 @@ impl Server {

let handle_guard = self.handle.lock().await;
let result = handle_guard.fingerprint(scope);
let freshness = handle_guard.ledger_freshness();
drop(handle_guard);

let fp = match result {
Ok(fp) => fp,
Err(err) => {
write_success(
id,
json!({
"content": [{ "type": "text", "text": err.to_string() }],
"isError": true,
}),
);
write_tool_error(id, err.to_string());
return;
}
};
let freshness = match freshness {
Comment thread
willwashburn marked this conversation as resolved.
Ok(value) => value,
Err(err) => {
Comment thread
willwashburn marked this conversation as resolved.
write_tool_error(id, err.to_string());
return;
}
};

let payload = json!({ "fingerprint": fp.as_str() });
let payload = json!({ "fingerprint": fp.as_str(), "ledgerFreshness": freshness });
let text = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
write_success(
id,
Expand Down Expand Up @@ -404,22 +408,16 @@ impl Server {
};
let handle_guard = self.handle.lock().await;
let result = handle_guard.session_cost(opts);
let freshness = handle_guard.ledger_freshness();
drop(handle_guard);

let mut payload: SessionCostResult = match result {
Ok(r) => r,
Err(err) => {
let msg = err.to_string();
// Per MCP convention: tool errors are non-throwing
// results with `isError: true`. Reserve JSON-RPC errors
// for protocol problems (parse / method-not-found).
write_success(
id,
json!({
"content": [{ "type": "text", "text": msg }],
"isError": true,
}),
);
write_tool_error(id, err.to_string());
return;
}
};
Expand All @@ -434,7 +432,18 @@ impl Server {
Some("no session id provided and server was not registered with one".to_string());
}

let value = serde_json::to_value(&payload).unwrap_or(Value::Null);
let mut value = serde_json::to_value(&payload).unwrap_or(Value::Null);
match freshness {
Ok(freshness) => {
if let Some(object) = value.as_object_mut() {
object.insert("ledgerFreshness".to_string(), json!(freshness));
}
}
Err(err) => {
write_tool_error(id, err.to_string());
return;
}
}
let text = serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string());
write_success(
id,
Expand All @@ -459,6 +468,16 @@ fn write_success(id: &Value, result: Value) {
write_response(&serde_json::to_value(&env).unwrap_or(Value::Null));
}

fn write_tool_error(id: &Value, message: impl Into<String>) {
write_success(
id,
json!({
"content": [{ "type": "text", "text": message.into() }],
"isError": true,
}),
);
}

fn error_envelope(id: &Value, code: i32, message: &str, data: Option<Value>) -> Value {
let env = JsonRpcError {
jsonrpc: "2.0",
Expand Down
1 change: 1 addition & 0 deletions crates/relayburn-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

pub mod compare;
pub mod flow;
mod freshness;
pub mod hotspots;
pub mod ingest;
pub mod mcp_server;
Expand Down
2 changes: 2 additions & 0 deletions crates/relayburn-cli/src/commands/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ fn run_list_inner(globals: &GlobalArgs, args: SessionsListArgs) -> anyhow::Resul
let result = handle.sessions_list(sdk_opts).inspect_err(|_| {
progress.finish_and_clear();
})?;
let freshness = handle.ledger_freshness()?;
progress.finish_and_clear();
crate::commands::freshness::warn_if_stale(&freshness, globals);

if globals.json {
emit_json(
Expand Down
3 changes: 3 additions & 0 deletions crates/relayburn-cli/src/commands/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ fn format_status(s: &StateStatus) -> String {
" last rebuild: {}\n",
s.archive.last_rebuild_at.as_deref().unwrap_or("never")
));
let last_write =
relayburn_cli::util::time::format_optional_epoch_ms(s.archive.last_write_at_ms);
out.push_str(&format!(" last write: {last_write}\n"));
out.push_str("config:\n");
out.push_str(&format!(" store: {}\n", s.config.store));
let retention = if s.config.retention_forever {
Expand Down
3 changes: 3 additions & 0 deletions crates/relayburn-cli/src/commands/summary/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ fn run_inner(globals: &GlobalArgs, args: SummaryArgs) -> anyhow::Result<i32> {
include_quality: args.quality,
ledger_home: None,
};
let freshness = handle.ledger_freshness()?;

// `--bucket` switches to a per-bucket time-series of the grouped summary.
// Parsing/validation already happened above, before the ledger was opened.
Expand All @@ -337,6 +338,7 @@ fn run_inner(globals: &GlobalArgs, args: SummaryArgs) -> anyhow::Result<i32> {
progress.finish_and_clear();
})?;
progress.finish_and_clear();
crate::commands::freshness::warn_if_stale(&freshness, globals);
return emit_summary_timeseries(globals, &series, &ingest_report);
}

Expand All @@ -345,6 +347,7 @@ fn run_inner(globals: &GlobalArgs, args: SummaryArgs) -> anyhow::Result<i32> {
progress.finish_and_clear();
})?;
progress.finish_and_clear();
crate::commands::freshness::warn_if_stale(&freshness, globals);

match report {
SummaryReport::Grouped(report) => {
Expand Down
19 changes: 19 additions & 0 deletions crates/relayburn-cli/src/util/time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ pub fn iso_from_system_time(t: std::time::SystemTime) -> String {
iso_from_ms(total_ms)
}

/// Format an optional Unix millisecond timestamp for human status/warning
/// surfaces. Missing ledger history is rendered consistently as `never`.
pub fn format_optional_epoch_ms(value: Option<u64>) -> String {
value
.map(|ms| {
iso_from_system_time(std::time::UNIX_EPOCH + std::time::Duration::from_millis(ms))
})
.unwrap_or_else(|| "never".to_string())
}

fn iso_from_ms(total_ms: i64) -> String {
let total_secs = total_ms.div_euclid(1000);
let ms = total_ms.rem_euclid(1000) as u32;
Expand Down Expand Up @@ -102,4 +112,13 @@ mod tests {
let t = UNIX_EPOCH + Duration::from_millis(0);
assert_eq!(iso_from_system_time(t), "1970-01-01T00:00:00.000Z");
}

#[test]
fn optional_epoch_ms_formats_value_and_missing() {
assert_eq!(format_optional_epoch_ms(None), "never");
assert_eq!(
format_optional_epoch_ms(Some(1_234)),
"1970-01-01T00:00:01.234Z"
);
}
}
34 changes: 33 additions & 1 deletion crates/relayburn-cli/tests/golden.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,29 @@ fn normalize(text: &str, ledger_home: &Path, project_dir: &Path) -> String {
out = squash_numeric_field(&out, "ledgerMtimeMsCurrent", "${MTIME}");
out = squash_numeric_field(&out, "lastBuiltAt", "${TS}");
out = squash_numeric_field(&out, "lastRebuildAt", "${TS}");
out = squash_numeric_field(&out, "lastWriteAtMs", "${TS}");
out = squash_line_value(&out, " last write:", "${TS}");
out
}

/// Replace the value after a human-output label while preserving the label's
/// padding. This keeps wall-clock fields deterministic in golden snapshots.
fn squash_line_value(text: &str, label: &str, placeholder: &str) -> String {
let mut out = String::with_capacity(text.len());
for segment in text.split_inclusive('\n') {
let (line, newline) = segment
.strip_suffix('\n')
.map_or((segment, ""), |line| (line, "\n"));
if let Some(rest) = line.strip_prefix(label) {
let padding_len = rest.len() - rest.trim_start().len();
out.push_str(label);
out.push_str(&rest[..padding_len]);
out.push_str(placeholder);
} else {
out.push_str(line);
}
out.push_str(newline);
}
out
}

Expand Down Expand Up @@ -380,7 +403,7 @@ fn tempdir_under(parent: &Path) -> PathBuf {

#[cfg(test)]
mod tests {
use super::squash_numeric_field;
use super::{squash_line_value, squash_numeric_field};

#[test]
fn squash_numeric_field_matches_space_and_tab() {
Expand Down Expand Up @@ -417,4 +440,13 @@ mod tests {
let out = squash_numeric_field(input, "lastBuiltAt", "${TS}");
assert_eq!(out, input);
}

#[test]
fn squash_line_value_preserves_padding_and_trailing_newline() {
let input = "archive state:\n last write: 2026-08-03T04:00:00Z\nconfig:\n";
assert_eq!(
squash_line_value(input, " last write:", "${TS}"),
"archive state:\n last write: ${TS}\nconfig:\n"
);
}
}
79 changes: 79 additions & 0 deletions crates/relayburn-cli/tests/smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,85 @@ fn burn() -> Command {
Command::cargo_bin("burn").expect("`burn` binary must build for the smoke test")
}

fn burn_without_stale_threshold_env() -> Command {
let mut command = burn();
command.env_remove("RELAYBURN_STALE_AFTER_HOURS");
command
}

fn seed_one_turn(home: &std::path::Path) {
let mut handle = relayburn_sdk::Ledger::open(relayburn_sdk::LedgerOpenOptions::with_home(home))
.expect("open test ledger");
let turn: relayburn_sdk::TurnRecord = serde_json::from_value(serde_json::json!({
"v": 1,
"source": "codex",
"sessionId": "stale-session",
"messageId": "stale-message",
"turnIndex": 0,
"ts": "2026-01-01T00:00:00.000Z",
"model": "gpt-5.2-codex",
"usage": {"input": 1, "output": 1, "reasoning": 0, "cacheRead": 0, "cacheCreate5m": 0, "cacheCreate1h": 0},
"toolCalls": []
}))
.expect("deserialize test turn");
handle.raw_mut().append_turns(&[turn]).expect("seed turn");
}

#[test]
fn stale_warning_is_uniform_across_requested_read_surface() {
let home = tempfile::TempDir::new().expect("tmp RELAYBURN_HOME");
seed_one_turn(home.path());
std::fs::write(
home.path().join("config.json"),
r#"{"staleness":{"thresholdHours":0}}"#,
)
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(2));

for args in [
vec!["summary"],
vec!["hotspots"],
vec!["hotspots", "--findings"],
vec!["sessions", "list", "--since", "12m"],
] {
burn_without_stale_threshold_env()
.args(["--ledger-path", home.path().to_str().expect("utf-8 path")])
.args(&args)
.assert()
.success()
.stderr(predicate::str::contains("ledger data may be stale"));
}
}

#[test]
fn fresh_ledger_does_not_warn() {
let home = tempfile::TempDir::new().expect("tmp RELAYBURN_HOME");
seed_one_turn(home.path());
burn_without_stale_threshold_env()
.args([
"--ledger-path",
home.path().to_str().expect("utf-8 path"),
"summary",
])
.assert()
.success()
.stderr(predicate::str::contains("ledger data may be stale").not());
}

#[test]
fn never_written_ledger_warns() {
let home = tempfile::TempDir::new().expect("tmp RELAYBURN_HOME");
burn_without_stale_threshold_env()
.args([
"--ledger-path",
home.path().to_str().expect("utf-8 path"),
"summary",
])
.assert()
.success()
.stderr(predicate::str::contains("ledger data may be stale"));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

#[test]
fn top_level_help_lists_every_subcommand() {
let output = burn().arg("--help").assert().success().get_output().clone();
Expand Down
Loading
Loading