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
2 changes: 1 addition & 1 deletion .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ jobs:
- batch: graphile-unit
packages: 'graphile/graphile-plugin-utils graphile/graphile-realtime-subscriptions graphile/graphile-sql-expression-validator graphile/graphile-upload-plugin'
- batch: agentic
packages: 'agentic/protocol agentic/agentic-kit agentic/agent agentic/harness agentic/chat agentic/cli agentic/pi agentic/react agentic/agentic-server agentic/anthropic agentic/openai agentic/ollama'
packages: 'agentic/protocol agentic/agentic-kit agentic/agent agentic/harness agentic/chat agentic/cli agentic/pi agentic/react agentic/agentic-server agentic/anthropic agentic/openai agentic/ollama agentic/run-log'
- batch: pgpm-unit
packages: 'pgpm/types pgpm/naming-spec pgpm/diff pgpm/import pgpm/slice pgpm/transform'
- batch: pglite
Expand Down
95 changes: 95 additions & 0 deletions agentic/run-log/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<p align="center" width="100%">
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
</p>

# @agentic-kit/run-log

The append-only **run log**: one ordered record of what an agent run did, wherever it ran.

A coding agent run has to be observable from a desktop app, a web UI and a CLI; it has to be resumable after a crash, a restart, or a move from the cloud to a laptop; and it has to be meterable. Those are usually four subsystems. Here they are four *projections* of one log:

```
pi session entries ──► run log (append-only) ──┬──► transcript parts (what a UI draws)
├──► usage totals (what a run cost)
├──► tool + approval (what is blocked)
└──► pi session file (how it resumes)
```

The log stores **pi entries verbatim** under four platform-owned fields. pi already versions and migrates its own session format, so re-encoding it into a second semantic event model would mean maintaining a translation layer that silently loses whatever pi adds next.

```ts
{
runId, // which run
seq, // 1-based, gapless, strictly increasing
recordedAt, // when the platform durably recorded it
piSessionVersion, // pi's session format version at write time
entry // the pi entry, byte-for-byte
}
```

## Install

```sh
npm install @agentic-kit/run-log
```

## Writing

Writing is an `append` against a store. Appends are **idempotent by pi entry id**, so a writer that restarts and replays its tail does not duplicate history.

```ts
import { MemoryRunLogStore } from '@agentic-kit/run-log';

const store = new MemoryRunLogStore();
await store.append('run-1', [entry]); // → the records actually written
await store.append('run-1', [entry]); // → [] — already present
```

Two stores ship here: `MemoryRunLogStore` (the reference implementation, and the test double) and `FileRunLogStore` from the node-only entry point:

```ts
import { FileRunLogStore, writeSessionFile } from '@agentic-kit/run-log/file-store';
```

The main entry is **browser-safe** — renderers import the projectors, so nothing in it may reach for a node builtin. Anything touching the filesystem lives behind `/file-store` (the same split, for the same reason, as `12factor-env/dotenv`).

The durable store is Postgres, in `constructive-db`: `append` becomes an insert, `read` a keyset scan. Neither is in this package, because the interface is the contract.

## Reading

Every surface is the same reader: hold a cursor, ask for what came after it. That is what makes a local run and a cloud run indistinguishable to a UI.

```ts
import { follow, projectParts, readAll } from '@agentic-kit/run-log';

const records = await readAll(store, 'run-1');

for await (const batch of follow(store, 'run-1', { waitForChange })) {
render(projectParts(batch).parts);
}
```

`waitForChange` (Postgres `LISTEN/NOTIFY`, IPC, a websocket) is an *optimisation*: it races the poll delay, and without one `follow` degrades to polling. No new transport is required for a run to stream.

## Projections

| Projection | Answers |
| --- | --- |
| `projectParts` | the renderable transcript — a tool call and its later result collapse into one part, so no renderer correlates messages itself |
| `projectUsage` | tokens and cost, per `provider/model` and per run, including nested tool usage and compaction calls |
| `projectToolState` | what is running, and what is waiting on a human (approvals ride in the log as pi `custom` messages, so a surface that reconnects hours later still sees a pending request) |
| `projectSession` | a pi session file — the resume path, cloud → local included |

All four are pure and total: the same records always produce the same output, whichever host wrote them, which is the property the tests assert as *placement invariance*.

Unreadable input fails loudly. A corrupt record, a mixed-version log, or a session whose header is not first throws rather than degrading into an empty transcript — a run that silently renders as blank is worse than one that reports it cannot be read. An *unknown* entry type is different: it is carried through as an `unknown` part, so a log written by a newer pi still renders.

## Related

- [`@agentic-kit/pi`](../pi) — Constructive's typed db tools as a pi extension
- [`@agentic-kit/harness`](../harness) — host-neutral gates and policy
- [`agentic-server`](../agentic-server) — the metered inference gateway

## License

SEE LICENSE IN LICENSE
40 changes: 40 additions & 0 deletions agentic/run-log/__tests__/entry-points.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';

/**
* The main entry is imported by browsers, Electron renderers and Next client
* components. A `node:` import anywhere in its module graph breaks those
* bundles, and it breaks them at build time in someone else's repo — so the
* boundary is asserted here rather than discovered there.
*/
const srcDir = join(__dirname, '..', 'src');

const sources = (dir: string): string[] =>
readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) return sources(path);
return name.endsWith('.ts') ? [path] : [];
});

/** Only this file may touch the filesystem. */
const NODE_ONLY = ['file-store.ts'];

describe('entry points', () => {
const browserSafe = sources(srcDir).filter((path) => !NODE_ONLY.some((name) => path.endsWith(name)));

it.each(browserSafe.map((path) => [path.slice(srcDir.length + 1), path]))(
'src/%s imports no node builtin',
(_name, path) => {
const contents = readFileSync(path, 'utf8');
expect(contents).not.toMatch(/from ['"]node:/);
expect(contents).not.toMatch(/require\(['"]node:/);
}
);

it('keeps the filesystem store out of the main entry', () => {
const index = readFileSync(join(srcDir, 'index.ts'), 'utf8');
expect(index).not.toMatch(/from ['"]\.\/file-store['"]/);
// eslint-disable-next-line @typescript-eslint/no-var-requires
expect(Object.keys(require('../src'))).not.toContain('FileRunLogStore');
});
});
86 changes: 86 additions & 0 deletions agentic/run-log/__tests__/file-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { appendFileSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { parseSessionJsonl, projectParts, readAll } from '../src';
import { FileRunLogStore, writeSessionFile } from '../src/file-store';
import { assistantText, assistantToolCall, header, resetIds, toolResult, userMessage } from './fixtures';

let dir: string;
let path: string;

beforeEach(() => {
resetIds();
dir = mkdtempSync(join(tmpdir(), 'run-log-'));
path = join(dir, 'nested', 'run.jsonl');
});

describe('FileRunLogStore', () => {
it('creates the log on first append and survives a new store instance', async () => {
const writer = new FileRunLogStore({ path });
await writer.append('run-1', [header(), userMessage('hi')]);
await writer.append('run-1', [assistantText('hello')]);

const reader = new FileRunLogStore({ path });
const records = await readAll(reader, 'run-1');
expect(records.map((r) => r.seq)).toEqual([1, 2, 3]);
expect(readFileSync(path, 'utf8').trimEnd().split('\n')).toHaveLength(3);
});

it('reads nothing for a log that does not exist yet', async () => {
expect((await new FileRunLogStore({ path }).read('run-1')).records).toEqual([]);
});

it('resumes sequence numbering after a process restart', async () => {
await new FileRunLogStore({ path }).append('run-1', [userMessage('a')]);
const written = await new FileRunLogStore({ path }).append('run-1', [userMessage('b', 2)]);
expect(written[0].seq).toBe(2);
});

it('skips entries already on disk, so a restarted writer can replay its tail', async () => {
const entries = [header(), userMessage('a'), assistantText('b')];
await new FileRunLogStore({ path }).append('run-1', entries);
const retry = await new FileRunLogStore({ path }).append('run-1', entries);
expect(retry).toEqual([]);
expect(readFileSync(path, 'utf8').trimEnd().split('\n')).toHaveLength(3);
});

it('keeps runs separate within one file', async () => {
const store = new FileRunLogStore({ path });
await store.append('run-1', [userMessage('a')]);
await store.append('run-2', [userMessage('b')]);
expect((await store.read('run-2')).records[0].seq).toBe(1);
expect((await store.read('run-1')).records).toHaveLength(1);
});

it('throws on a truncated or corrupt line instead of returning a partial log', async () => {
const store = new FileRunLogStore({ path });
await store.append('run-1', [userMessage('a')]);
appendFileSync(path, '{"runId":"run-1","seq":2,');
await expect(store.read('run-1')).rejects.toThrow(/line 2 is not valid JSON/);

writeFileSync(path, '{"runId":"run-1","seq":1,"recordedAt":"x","piSessionVersion":3}\n');
await expect(store.read('run-1')).rejects.toThrow(/must be an object/);
});
});

describe('writeSessionFile', () => {
it('writes a pi session file the log can be resumed from', async () => {
const store = new FileRunLogStore({ path });
await store.append('run-1', [
header(),
userMessage('add a test'),
assistantToolCall({ id: 'call-1', name: 'write_file' }),
toolResult({ toolCallId: 'call-1', toolName: 'write_file', text: 'ok' })
]);
const records = await readAll(store, 'run-1');

const sessionPath = writeSessionFile(join(dir, 'sessions', 'session-1.jsonl'), records);
const entries = parseSessionJsonl(readFileSync(sessionPath, 'utf8'));

expect(entries[0]).toMatchObject({ type: 'session', id: 'session-1' });
expect(entries).toHaveLength(4);
// The transcript a UI would draw is unchanged by the round trip.
expect(projectParts(records).parts).toHaveLength(2);
});
});
112 changes: 112 additions & 0 deletions agentic/run-log/__tests__/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { PiSessionEntry, PiSessionHeader, PiUsage } from '../src/pi-entry';

let counter = 0;
const nextId = (): string => {
counter += 1;
return counter.toString(16).padStart(8, '0');
};

export const resetIds = (): void => {
counter = 0;
};

const at = (n: number): string => new Date(Date.UTC(2026, 0, 1, 0, 0, n)).toISOString();

export const usage = (over: Partial<PiUsage> = {}): PiUsage => ({
input: 100,
output: 20,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 120,
cost: { input: 0.001, output: 0.002, cacheRead: 0, cacheWrite: 0, total: 0.003 },
...over
});

export const header = (over: Partial<PiSessionHeader> = {}): PiSessionEntry => ({
type: 'session',
version: 3,
id: 'session-1',
timestamp: at(0),
cwd: '/repo',
...over
});

const entry = (type: string, rest: Record<string, unknown>, seq: number): PiSessionEntry =>
({ type, id: nextId(), parentId: null, timestamp: at(seq), ...rest }) as PiSessionEntry;

export const userMessage = (text: string, seq = 1): PiSessionEntry =>
entry('message', { message: { role: 'user', content: text, timestamp: seq } }, seq);

export const assistantText = (text: string, seq = 2, over: Record<string, unknown> = {}): PiSessionEntry =>
entry(
'message',
{
message: {
role: 'assistant',
content: [{ type: 'text', text }],
provider: 'anthropic',
model: 'claude-sonnet-4-5',
usage: usage(),
stopReason: 'stop',
...over
}
},
seq
);

export const assistantToolCall = (
call: { id: string; name: string; arguments?: Record<string, unknown> },
seq = 3,
over: Record<string, unknown> = {}
): PiSessionEntry =>
entry(
'message',
{
message: {
role: 'assistant',
content: [{ type: 'toolCall', ...call }],
provider: 'anthropic',
model: 'claude-sonnet-4-5',
usage: usage({ output: 30, totalTokens: 130 }),
stopReason: 'toolUse',
...over
}
},
seq
);

export const toolResult = (
result: { toolCallId: string; toolName: string; text: string; isError?: boolean; usage?: PiUsage },
seq = 4
): PiSessionEntry =>
entry(
'message',
{
message: {
role: 'toolResult',
toolCallId: result.toolCallId,
toolName: result.toolName,
content: [{ type: 'text', text: result.text }],
isError: result.isError ?? false,
...(result.usage ? { usage: result.usage } : {})
}
},
seq
);

export const custom = (
message: { customType: string; content: string; details?: unknown; display?: boolean },
seq = 5
): PiSessionEntry => entry('message', { message: { role: 'custom', display: true, ...message } }, seq);

export const bash = (command: string, output: string, exitCode = 0, seq = 6): PiSessionEntry =>
entry('message', { message: { role: 'bashExecution', command, output, exitCode } }, seq);

export const compaction = (summary: string, seq = 7, over: Record<string, unknown> = {}): PiSessionEntry =>
entry('compaction', { summary, tokensBefore: 50_000, ...over }, seq);

export const branchSummary = (summary: string, seq = 8, over: Record<string, unknown> = {}): PiSessionEntry =>
entry('branch_summary', { summary, fromId: 'aaaaaaaa', ...over }, seq);

/** An entry type this version of the package has never seen. */
export const futureEntry = (seq = 9): PiSessionEntry => entry('quantum_thought', { intensity: 11 }, seq);
Loading
Loading