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
4 changes: 4 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **`burn summary` now reads from `archive.sqlite` instead of streaming `ledger.jsonl`** ([#82](https://github.com/AgentWorkforce/burn/issues/82)). The default hot path calls `buildArchive()` (cheap incremental tail scan after the per-invocation `ingestAll`) and issues SQL with filters lowered to indexed `WHERE` clauses against `turns`, replacing the per-invocation full ledger walk + stamp fold. Subagent-tree (`--subagent-tree`) and `--by-subagent-type` modes consume the same archive-derived turn slice. Output (text + `--json`) is parity-preserved against the legacy reader for the `byModel`, `totalCost`, and `fidelity` blocks. Two escape hatches preserve the old behavior: a new `--no-archive` flag and the `RELAYBURN_ARCHIVE=0` env var both revert to `queryAll`. If the archive path throws (corrupt sqlite, schema mismatch we couldn't recover from cleanly), the command transparently falls back to the streaming reader and surfaces the reason on stderr — the archive can never wedge `burn summary`.

## [0.30.0] - 2026-04-27

### Changed
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const HELP = `burn — token usage & cost attribution for agent CLIs

Usage:
burn summary [--since 7d] [--project <path>] [--session <id>] [--workflow <id>] [--agent <id>] [--provider <p>] [--quality]
[--by-provider] [--subagent-tree <session-id>] [--by-subagent-type]
[--by-provider] [--subagent-tree <session-id>] [--by-subagent-type] [--no-archive]
burn by-tool [--since 7d] [--project <path>] [--session <id>] [--provider <p>]
burn waste [--since 7d] [--project <path>] [--session <id>] [--workflow <id>] [--provider <p>] [--all] [--json]
[--patterns[=retries,failures,compaction,reverts]]
Expand Down
203 changes: 203 additions & 0 deletions packages/cli/src/commands/summary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { strict as assert } from 'node:assert';
import { mkdtemp, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import * as path from 'node:path';
import { after, beforeEach, describe, it } from 'node:test';

import { appendTurns, archivePath, stamp } from '@relayburn/ledger';
import type { TurnRecord } from '@relayburn/reader';

import { runSummary } from './summary.js';

function fakeTurn(overrides: Partial<TurnRecord> = {}): TurnRecord {
return {
v: 1,
source: 'claude-code',
sessionId: 's-1',
messageId: 'msg-1',
turnIndex: 0,
ts: '2026-04-20T00:00:00.000Z',
model: 'claude-sonnet-4-6',
usage: {
input: 1000,
output: 500,
reasoning: 0,
cacheRead: 1000,
cacheCreate5m: 0,
cacheCreate1h: 0,
},
toolCalls: [],
project: '/tmp/project',
...overrides,
};
}

interface CapturedOutput {
stdout: string;
stderr: string;
code: number;
}

async function captureSummary(
flags: Record<string, string | true> = {},
): Promise<CapturedOutput> {
const origStdout = process.stdout.write.bind(process.stdout);
const origStderr = process.stderr.write.bind(process.stderr);
let stdout = '';
let stderr = '';
process.stdout.write = ((chunk: string | Uint8Array): boolean => {
stdout += typeof chunk === 'string' ? chunk : chunk.toString();
return true;
}) as typeof process.stdout.write;
process.stderr.write = ((chunk: string | Uint8Array): boolean => {
stderr += typeof chunk === 'string' ? chunk : chunk.toString();
return true;
}) as typeof process.stderr.write;
let code: number;
try {
code = await runSummary({ flags, tags: {}, positional: [], passthrough: [] });
} finally {
process.stdout.write = origStdout;
process.stderr.write = origStderr;
}
return { stdout, stderr, code };
}

describe('burn summary archive integration (#82)', () => {
let tmpHome: string;
let tmpRelay: string;
const originalHome = process.env['HOME'];
const originalRelay = process.env['RELAYBURN_HOME'];
const originalArchive = process.env['RELAYBURN_ARCHIVE'];

beforeEach(async () => {
tmpHome = await mkdtemp(path.join(tmpdir(), 'burn-summary-home-'));
tmpRelay = await mkdtemp(path.join(tmpdir(), 'burn-summary-relay-'));
process.env['HOME'] = tmpHome;
process.env['RELAYBURN_HOME'] = tmpRelay;
delete process.env['RELAYBURN_ARCHIVE'];
});

after(async () => {
if (originalHome !== undefined) process.env['HOME'] = originalHome;
else delete process.env['HOME'];
if (originalRelay !== undefined) process.env['RELAYBURN_HOME'] = originalRelay;
else delete process.env['RELAYBURN_HOME'];
if (originalArchive !== undefined) process.env['RELAYBURN_ARCHIVE'] = originalArchive;
else delete process.env['RELAYBURN_ARCHIVE'];
await rm(tmpHome, { recursive: true, force: true });
await rm(tmpRelay, { recursive: true, force: true });
});

it('--json output is identical between archive and ledger paths (parity)', async () => {
await appendTurns([
fakeTurn({ sessionId: 's-A', messageId: 'pa-1' }),
fakeTurn({
sessionId: 's-A',
messageId: 'pa-2',
turnIndex: 1,
ts: '2026-04-20T00:01:00.000Z',
}),
fakeTurn({
sessionId: 's-B',
messageId: 'pa-3',
ts: '2026-04-20T00:02:00.000Z',
model: 'claude-haiku-4-5',
project: '/tmp/other',
}),
]);
await stamp({ sessionId: 's-A' }, { workflowId: 'wf-parity' });

// Default path: builds the archive, then queries SQL.
const archiveOut = await captureSummary({ json: true });
assert.equal(archiveOut.code, 0);

// Fallback path: streams the ledger.
const ledgerOut = await captureSummary({ json: true, 'no-archive': true });
assert.equal(ledgerOut.code, 0);

interface SummaryPayload {
turns: number;
totalCost: { total: number };
byModel: Array<{ model: string; turns: number; usage: Record<string, number>; cost: { total: number } }>;
fidelity: unknown;
}
const archive = JSON.parse(archiveOut.stdout) as SummaryPayload;
const ledger = JSON.parse(ledgerOut.stdout) as SummaryPayload;
assert.equal(archive.turns, ledger.turns);
assert.equal(archive.turns, 3);
assert.deepEqual(
archive.byModel.map((r) => ({ model: r.model, turns: r.turns, usage: r.usage, cost: r.cost })),
ledger.byModel.map((r) => ({ model: r.model, turns: r.turns, usage: r.usage, cost: r.cost })),
);
assert.deepEqual(archive.totalCost, ledger.totalCost);
assert.deepEqual(archive.fidelity, ledger.fidelity);
});

it('default path auto-builds archive.sqlite on first run', async () => {
await appendTurns([fakeTurn({ sessionId: 's-AB', messageId: 'ab-1' })]);
// Pre-condition: no archive on disk.
await assert.rejects(stat(archivePath()), /ENOENT/);

const out = await captureSummary({ json: true });
assert.equal(out.code, 0);

// Post-condition: `loadTurns` ran `buildArchive()` and the file exists.
const st = await stat(archivePath());
assert.equal(st.isFile(), true);
});

it('--no-archive flag does NOT build the archive (fallback path)', async () => {
await appendTurns([fakeTurn({ sessionId: 's-NA', messageId: 'na-1' })]);
await assert.rejects(stat(archivePath()), /ENOENT/);

const out = await captureSummary({ json: true, 'no-archive': true });
assert.equal(out.code, 0);

// The archive should still be missing — we hit the legacy `queryAll` path.
await assert.rejects(stat(archivePath()), /ENOENT/);
});

it('RELAYBURN_ARCHIVE=0 env disables the archive path (fallback)', async () => {
await appendTurns([fakeTurn({ sessionId: 's-ENV', messageId: 'env-1' })]);
await assert.rejects(stat(archivePath()), /ENOENT/);

process.env['RELAYBURN_ARCHIVE'] = '0';
try {
const out = await captureSummary({ json: true });
assert.equal(out.code, 0);
} finally {
delete process.env['RELAYBURN_ARCHIVE'];
}
// Same fallback behavior — no archive built.
await assert.rejects(stat(archivePath()), /ENOENT/);
});

it('text output matches between archive and ledger paths (parity)', async () => {
await appendTurns([
fakeTurn({ sessionId: 's-T', messageId: 'tx-1' }),
fakeTurn({
sessionId: 's-T',
messageId: 'tx-2',
turnIndex: 1,
ts: '2026-04-20T00:01:00.000Z',
}),
]);

const archiveOut = await captureSummary({});
assert.equal(archiveOut.code, 0);
const ledgerOut = await captureSummary({ 'no-archive': true });
assert.equal(ledgerOut.code, 0);

// The "ingested N new sessions (+M turns)" preamble depends on the live
// ingest pass which is a no-op here (no ~/.claude or ~/.codex sessions in
// the temp HOME), but stripping the preamble keeps the test resilient if
// that contract ever changes. Compare the body — model table + total
// cost.
const stripPreamble = (s: string): string => {
const idx = s.indexOf('turns analyzed:');
return idx >= 0 ? s.slice(idx) : s;
};
assert.equal(stripPreamble(archiveOut.stdout), stripPreamble(ledgerOut.stdout));
});
});
44 changes: 42 additions & 2 deletions packages/cli/src/commands/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ import type {
QualityResult,
SubagentTreeNode,
} from '@relayburn/analyze';
import { queryAll, readContent, type Query } from '@relayburn/ledger';
import {
buildArchive,
queryAll,
queryAllFromArchive,
readContent,
type Query,
} from '@relayburn/ledger';
import type { EnrichedTurn } from '@relayburn/ledger';
import type { ContentRecord } from '@relayburn/reader';

Expand Down Expand Up @@ -45,7 +51,7 @@ export async function runSummary(args: ParsedArgs): Promise<number> {

const ingestReport = await ingestAll();
const pricing = await loadPricing();
const turns = filterTurnsByProvider(await queryAll(q), providerFilter);
const turns = filterTurnsByProvider(await loadTurns(q, args), providerFilter);

if (subagentTreeFlag !== undefined) {
return renderSubagentTreeMode(args, turns, pricing, subagentTreeFlag, q);
Expand Down Expand Up @@ -347,6 +353,40 @@ function renderFidelityNotice(f: FidelitySummary): string | undefined {
return `fidelity: ${parts.join(' / ')} (use --json for per-field coverage)`;
}

/**
* Load the turns slice that drives every summary mode.
*
* Default path: bring `archive.sqlite` current via `buildArchive()` (cheap
* incremental tail scan after `ingestAll`'s appends), then issue SQL with
* filters lowered as `WHERE` clauses against indexed columns. Replaces the
* full ledger walk (`queryAll`) on the hot path.
*
* Fallback path: `--no-archive` flag or `RELAYBURN_ARCHIVE=0` env reverts
* to the legacy `queryAll` ledger stream — kept as an escape hatch for
* parity validation and for environments where the archive is missing /
* corrupt. If a build/query against the archive throws, we transparently
* fall back to the same legacy path so a wedged archive can never break
* the command.
*/
async function loadTurns(q: Query, args: ParsedArgs): Promise<EnrichedTurn[]> {
const noArchiveFlag = args.flags['no-archive'] === true;
const envDisabled = process.env['RELAYBURN_ARCHIVE'] === '0';
if (noArchiveFlag || envDisabled) {
return queryAll(q);
}
try {
await buildArchive();
return await queryAllFromArchive(q);
} catch (err) {
// Don't let an archive-side failure (corrupt sqlite, schema mismatch we
// didn't recover from cleanly, etc.) take down `burn summary`. Surface
// the reason on stderr and fall back to the streaming reader.
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`burn: archive read failed (${msg}); falling back to ledger walk\n`);
return queryAll(q);
}
}

function aggregateByModel(turns: EnrichedTurn[], pricing: Parameters<typeof costForTurn>[1]): ModelRow[] {
return aggregateTurns(turns, pricing, (t) => t.model || 'unknown');
}
Expand Down
4 changes: 4 additions & 0 deletions packages/ledger/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`queryAllFromArchive(query)` + `archiveAvailable()`** ([#82](https://github.com/AgentWorkforce/burn/issues/82)). New read-side entry point in `@relayburn/ledger` that issues SQL against `archive.sqlite` and returns the same `EnrichedTurn[]` shape as `queryAll`, so consumers (starting with `burn summary`) can swap implementations without touching their aggregation code. Filters land as `WHERE` clauses against indexed columns (`ts`, `model`, `project_key`, `session_id`, `source`, materialized enrichment columns); arbitrary stamp keys not promoted to columns fall back to a `json_extract` over `enrichment_json` to match `queryAll` semantics. Tool calls are bulk-hydrated keyed on `(source, session_id, message_id)` so callers that read `turn.toolCalls` keep working without an extra round-trip. Fidelity is reconstructed from the persisted `attribution_fidelity` / `tokens_present` / `cost_present` columns plus class-implied coverage defaults — class equality (the load-bearing parity contract for `summarizeFidelity`) is preserved; the synthesized coverage shape may differ from the on-ledger blob for classes that don't pin every flag.

## [0.30.0] - 2026-04-27

### Changed
Expand Down
Loading