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]

### Added

- `burn archive status` (text + `--json`) now reports `tool_result_events` row counts alongside sessions / turns / tool_calls / compactions, and `burn archive build` / `rebuild` summary lines include the count of tool-result events materialized this run (#101).

## [0.24.0] - 2026-04-26

### Added
Expand Down
36 changes: 34 additions & 2 deletions packages/cli/src/commands/archive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { tmpdir } from 'node:os';
import * as path from 'node:path';
import { after, beforeEach, describe, it } from 'node:test';

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

import { runArchive } from './archive.js';

Expand Down Expand Up @@ -134,6 +134,38 @@ describe('burn archive CLI', () => {
assert.equal(afterStatus.rowCounts.turns, beforeStatus.rowCounts.turns);
});

it('archive status --json surfaces tool_result_events row count', async () => {
await appendTurns([fakeTurn({ sessionId: 's-tre-cli', messageId: 'tre-cli-1' })]);
const events: ToolResultEventRecord[] = [
{
v: 1,
source: 'claude-code',
sessionId: 's-tre-cli',
messageId: 'tre-cli-1',
toolUseId: 'tu-cli-1',
callIndex: 0,
eventIndex: 0,
ts: '2026-04-20T00:00:01.000Z',
status: 'completed',
eventSource: 'tool_result',
contentLength: 7,
contentHash: 'cli-h1',
},
];
await appendToolResultEvents(events);
await captureRun({}, ['build']);

const statusOut = await captureRun({ json: true }, ['status']);
assert.equal(statusOut.code, 0);
const parsed = JSON.parse(statusOut.stdout) as {
rowCounts: { toolResultEvents: number };
};
assert.equal(parsed.rowCounts.toolResultEvents, 1);

const human = await captureRun({}, ['status']);
assert.match(human.stdout, /tool_result_events:\s+1/);
});

it('archive status --json surfaces a fidelity histogram on turns (#110)', async () => {
// Use distinctive ts/usage so the writer's content-fingerprint dedup
// doesn't collide with prior CLI tests in the same module (the in-memory
Expand Down
12 changes: 7 additions & 5 deletions packages/cli/src/commands/archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ function printBuildResult(
` ${formatInt(result.turnsApplied)} turn${result.turnsApplied === 1 ? '' : 's'} applied,` +
` ${formatInt(result.sessionsTouched)} session${result.sessionsTouched === 1 ? '' : 's'} touched,` +
` ${formatInt(result.stampsApplied)} stamp${result.stampsApplied === 1 ? '' : 's'},` +
` ${formatInt(result.compactionsApplied)} compaction${result.compactionsApplied === 1 ? '' : 's'}`,
` ${formatInt(result.compactionsApplied)} compaction${result.compactionsApplied === 1 ? '' : 's'},` +
` ${formatInt(result.toolResultEventsApplied)} tool-result event${result.toolResultEventsApplied === 1 ? '' : 's'}`,
);
lines.push(` ${formatInt(result.scannedBytes)} bytes scanned from ledger tail`);
process.stdout.write(lines.join('\n') + '\n');
Expand Down Expand Up @@ -104,10 +105,11 @@ async function runStatus(args: ParsedArgs): Promise<number> {
if (status.lastBuiltAt) lines.push(` last build: ${status.lastBuiltAt}`);
if (status.lastRebuildAt) lines.push(` last rebuild: ${status.lastRebuildAt}`);
lines.push(' rows:');
lines.push(` sessions: ${formatInt(status.rowCounts.sessions)}`);
lines.push(` turns: ${formatInt(status.rowCounts.turns)}`);
lines.push(` tool_calls: ${formatInt(status.rowCounts.toolCalls)}`);
lines.push(` compactions: ${formatInt(status.rowCounts.compactions)}`);
lines.push(` sessions: ${formatInt(status.rowCounts.sessions)}`);
lines.push(` turns: ${formatInt(status.rowCounts.turns)}`);
lines.push(` tool_calls: ${formatInt(status.rowCounts.toolCalls)}`);
lines.push(` tool_result_events: ${formatInt(status.rowCounts.toolResultEvents)}`);
lines.push(` compactions: ${formatInt(status.rowCounts.compactions)}`);
process.stdout.write(lines.join('\n') + '\n');
return 0;
}
Expand Down
8 changes: 8 additions & 0 deletions packages/ledger/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`tool_result_events` archive table is now populated** (#101). `buildArchive()` materializes `ToolResultEventLine` ledger lines into the previously-empty `tool_result_events` table during incremental builds, keyed on (`source`, `session_id`, `message_id`, `tool_use_id`, `event_index`). Columns mirror the canonical `ToolResultEventRecord`: `status`, `content_length`, `content_hash`, `subagent_session_id`, `agent_id`, `event_source`, `ts`, `call_index`. Cursor uses the same `archive_state.ledger_offset_bytes` as turns / stamps / compactions, so no parallel cursor and no extra disk read. `rebuildArchive()` replays tool-result events alongside the other line kinds and yields the same row count deterministically. New `idx_tool_result_events_use_id` / `_session` / `_subagent` indexes for the obvious join paths. `BuildResult.toolResultEventsApplied` and `ArchiveStatus.rowCounts.toolResultEvents` expose the new counts. Closes #101, refs #40, #42, #77.

### Changed

- **Bumped `ARCHIVE_VERSION` to 2** to pick up the new `tool_result_events` indexes on existing archives. The next `buildArchive()` call detects the version mismatch and rebuilds from scratch — safe because the archive is derived state.

## [0.24.0] - 2026-04-26

### Added
Expand Down
272 changes: 270 additions & 2 deletions packages/ledger/src/archive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@ import * as path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { after, before, beforeEach, describe, it } from 'node:test';

import type { TurnRecord } from '@relayburn/reader';
import type { ToolResultEventRecord, TurnRecord } from '@relayburn/reader';

import { __resetIndexCacheForTesting } from './index-sidecar.js';
import { appendCompactions, appendTurns, stamp } from './writer.js';
import {
appendCompactions,
appendToolResultEvents,
appendTurns,
stamp,
} from './writer.js';
import { archivePath, ledgerPath } from './paths.js';
import {
ARCHIVE_VERSION,
Expand Down Expand Up @@ -269,6 +274,269 @@ describe('archive', () => {
}
});

it('round-trips tool_result_event lines from ledger to archive', async () => {
await appendTurns([fakeTurn({ sessionId: 's-tre', messageId: 'mtre-1' })]);
const events: ToolResultEventRecord[] = [
{
v: 1,
source: 'claude-code',
sessionId: 's-tre',
messageId: 'mtre-1',
toolUseId: 'tu-A',
callIndex: 0,
eventIndex: 0,
ts: '2026-04-20T00:00:01.000Z',
status: 'completed',
eventSource: 'tool_result',
contentLength: 1234,
contentHash: 'abc123',
agentId: 'agent-X',
},
{
v: 1,
source: 'claude-code',
sessionId: 's-tre',
messageId: 'mtre-1',
toolUseId: 'tu-B',
callIndex: 1,
eventIndex: 1,
ts: '2026-04-20T00:00:02.000Z',
status: 'errored',
eventSource: 'tool_result',
contentLength: 0,
contentHash: 'def456',
isError: true,
},
];
await appendToolResultEvents(events);
const result = await buildArchive();
assert.equal(result.toolResultEventsApplied, 2);

const status = await getArchiveStatus();
assert.equal(status.rowCounts.toolResultEvents, 2);

const db = await openArchive();
try {
const rows = db
.prepare(
`SELECT source, session_id, message_id, tool_use_id, call_index,
event_index, status, content_length, content_hash, is_error,
agent_id, event_source, ts
FROM tool_result_events ORDER BY event_index`,
)
.all() as Array<{
source: string;
session_id: string;
message_id: string;
tool_use_id: string;
call_index: number | bigint;
event_index: number | bigint;
status: string;
content_length: number | bigint | null;
content_hash: string | null;
is_error: number | bigint | null;
agent_id: string | null;
event_source: string;
ts: string | null;
}>;
assert.equal(rows.length, 2);
const r0 = rows[0]!;
assert.equal(r0.source, 'claude-code');
assert.equal(r0.session_id, 's-tre');
assert.equal(r0.message_id, 'mtre-1');
assert.equal(r0.tool_use_id, 'tu-A');
assert.equal(Number(r0.call_index), 0);
assert.equal(Number(r0.event_index), 0);
assert.equal(r0.status, 'completed');
assert.equal(Number(r0.content_length), 1234);
assert.equal(r0.content_hash, 'abc123');
assert.equal(r0.agent_id, 'agent-X');
assert.equal(r0.event_source, 'tool_result');
assert.equal(r0.ts, '2026-04-20T00:00:01.000Z');
assert.equal(r0.is_error, null);
const r1 = rows[1]!;
assert.equal(r1.tool_use_id, 'tu-B');
assert.equal(r1.status, 'errored');
assert.equal(Number(r1.call_index), 1);
assert.equal(Number(r1.is_error), 1);
} finally {
db.close();
}
});

it('rebuilds tool_result_events from zero deterministically (idempotent)', async () => {
await appendTurns([fakeTurn({ sessionId: 's-rb-tre', messageId: 'mrb-tre-1' })]);
const events: ToolResultEventRecord[] = Array.from({ length: 5 }, (_, i) => ({
v: 1,
source: 'claude-code',
sessionId: 's-rb-tre',
messageId: 'mrb-tre-1',
toolUseId: `tu-${i}`,
callIndex: i,
eventIndex: i,
ts: `2026-04-20T00:00:0${i}.000Z`,
status: 'completed',
eventSource: 'tool_result',
contentLength: 100 * i,
contentHash: `h${i}`,
}));
await appendToolResultEvents(events);
await buildArchive();
const before = await getArchiveStatus();
assert.equal(before.rowCounts.toolResultEvents, 5);

// Rebuild from zero replays the same lines and yields the same row count.
const rb = await rebuildArchive();
assert.equal(rb.rebuiltFromZero, true);
assert.equal(rb.toolResultEventsApplied, 5);
const after = await getArchiveStatus();
assert.equal(after.rowCounts.toolResultEvents, 5);

// A second build with no new ledger writes is a clean no-op.
const noop = await buildArchive();
assert.equal(noop.toolResultEventsApplied, 0);
const noopStatus = await getArchiveStatus();
assert.equal(noopStatus.rowCounts.toolResultEvents, 5);
});

it('materializes mixed-source tool_result events (claude-code, codex, opencode)', async () => {
// Synthesize events from each supported source so the archive table
// doesn't accidentally lock to a single source's column shape.
const events: ToolResultEventRecord[] = [
{
v: 1,
source: 'claude-code',
sessionId: 's-cc',
messageId: 'mcc-1',
toolUseId: 'tu-cc',
callIndex: 0,
eventIndex: 0,
ts: '2026-04-20T00:00:00.000Z',
status: 'completed',
eventSource: 'tool_result',
contentLength: 10,
contentHash: 'cc',
},
{
v: 1,
source: 'codex',
sessionId: 's-cx',
messageId: 'mcx-1',
toolUseId: 'call-cx',
callIndex: 0,
eventIndex: 0,
ts: '2026-04-20T00:00:01.000Z',
status: 'completed',
eventSource: 'function_call_output',
contentLength: 20,
contentHash: 'cx',
},
{
v: 1,
source: 'opencode',
sessionId: 's-oc',
messageId: 'moc-1',
toolUseId: 'callid-oc',
callIndex: 0,
eventIndex: 0,
ts: '2026-04-20T00:00:02.000Z',
status: 'completed',
eventSource: 'tool_result',
contentLength: 30,
contentHash: 'oc',
},
];
await appendToolResultEvents(events);
await buildArchive();
const db = await openArchive();
try {
const rows = db
.prepare(
'SELECT DISTINCT source FROM tool_result_events ORDER BY source',
)
.all() as Array<{ source: string }>;
assert.deepEqual(
rows.map((r) => r.source),
['claude-code', 'codex', 'opencode'],
);
const total = (db
.prepare('SELECT COUNT(*) AS n FROM tool_result_events')
.get() as { n: number | bigint }).n;
assert.equal(Number(total), 3);
} finally {
db.close();
}
});

it('tool_result_events: incremental tail picks up newly-appended events', async () => {
await appendToolResultEvents([
{
v: 1,
source: 'claude-code',
sessionId: 's-tail-tre',
messageId: 'mtt-1',
toolUseId: 'tu-1',
callIndex: 0,
eventIndex: 0,
ts: '2026-04-20T00:00:00.000Z',
status: 'completed',
eventSource: 'tool_result',
contentLength: 1,
contentHash: 'h1',
},
]);
await buildArchive();
const status1 = await getArchiveStatus();
assert.equal(status1.rowCounts.toolResultEvents, 1);

await appendToolResultEvents([
{
v: 1,
source: 'claude-code',
sessionId: 's-tail-tre',
messageId: 'mtt-1',
toolUseId: 'tu-2',
callIndex: 1,
eventIndex: 1,
ts: '2026-04-20T00:00:01.000Z',
status: 'completed',
eventSource: 'tool_result',
contentLength: 2,
contentHash: 'h2',
},
]);
const tail = await buildArchive();
assert.equal(tail.toolResultEventsApplied, 1);
const status2 = await getArchiveStatus();
assert.equal(status2.rowCounts.toolResultEvents, 2);
assert.equal(status2.upToDate, true);
});

it('tool_result_events: PK is dedup-safe — replaying the same event is a no-op upsert', async () => {
const ev: ToolResultEventRecord = {
v: 1,
source: 'claude-code',
sessionId: 's-dup',
messageId: 'mdup-1',
toolUseId: 'tu-dup',
callIndex: 0,
eventIndex: 0,
ts: '2026-04-20T00:00:00.000Z',
status: 'completed',
eventSource: 'tool_result',
contentLength: 99,
contentHash: 'h-dup',
};
await appendToolResultEvents([ev]);
await buildArchive();

// rebuildArchive replays the entire ledger — including the same tool
// result event line — and must land exactly one row.
await rebuildArchive();
const status = await getArchiveStatus();
assert.equal(status.rowCounts.toolResultEvents, 1);
});

it('archive lives at RELAYBURN_HOME/archive.sqlite', async () => {
await buildArchive();
const expected = path.join(tmpDir, 'archive.sqlite');
Expand Down
Loading