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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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.
- Pricing recognizes Claude 5 and GPT-5.6 models, prefers first-party tariffs over reseller duplicates, and applies long-context price tiers.
- `burn hotspots --findings` surfaces unknown model pricing explicitly and ranks unpriced sessions by token volume instead of treating them as $0.00.

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 @@ -265,6 +265,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 @@ -284,6 +285,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"
);
}
}
44 changes: 43 additions & 1 deletion crates/relayburn-cli/tests/golden.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,33 @@ 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.
/// The `never` sentinel (a ledger with no write clock) is not a wall-clock
/// value and passes through unchanged, so goldens can distinguish it from a
/// real timestamp.
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"));
match line.strip_prefix(label) {
Some(rest) if rest.trim() != "never" => {
let padding_len = rest.len() - rest.trim_start().len();
out.push_str(label);
out.push_str(&rest[..padding_len]);
out.push_str(placeholder);
}
_ => out.push_str(line),
}
out.push_str(newline);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
out
}

Expand Down Expand Up @@ -380,7 +407,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 +444,19 @@ 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"
);
}

#[test]
fn squash_line_value_preserves_never_sentinel() {
let input = "archive state:\n last write: never\nconfig:\n";
assert_eq!(squash_line_value(input, " last write:", "${TS}"), input);
}
}
Loading
Loading