@crewhaus/event-log 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@crewhaus/event-log",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Append-only JSONL transcript log per session",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/errors": "0.0.0"
16
+ },
17
+ "license": "Apache-2.0",
18
+ "author": {
19
+ "name": "Max Meier",
20
+ "email": "max@studiomax.io",
21
+ "url": "https://studiomax.io"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/crewhaus/factory.git",
26
+ "directory": "packages/event-log"
27
+ },
28
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/event-log#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/crewhaus/factory/issues"
31
+ },
32
+ "publishConfig": {
33
+ "access": "restricted"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "README.md",
38
+ "LICENSE",
39
+ "NOTICE"
40
+ ]
41
+ }
@@ -0,0 +1,172 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { type Event, openEventLog } from "./index";
6
+
7
+ const TMP_ROOTS: string[] = [];
8
+ function newTempRoot(): string {
9
+ const dir = mkdtempSync(join(tmpdir(), "crewhaus-event-log-"));
10
+ TMP_ROOTS.push(dir);
11
+ return dir;
12
+ }
13
+ afterAll(() => {
14
+ for (const dir of TMP_ROOTS) {
15
+ rmSync(dir, { recursive: true, force: true });
16
+ }
17
+ });
18
+
19
+ const TEST_ID = "sess_0123456789abcdef";
20
+
21
+ async function collect(events: AsyncIterable<Event>): Promise<Event[]> {
22
+ const out: Event[] = [];
23
+ for await (const ev of events) out.push(ev);
24
+ return out;
25
+ }
26
+
27
+ describe("event-log — round-trip", () => {
28
+ test("append + read returns events in insertion order with version 1", async () => {
29
+ const rootDir = newTempRoot();
30
+ let clock = 1_700_000_000_000;
31
+ const log = await openEventLog(TEST_ID, { rootDir, now: () => clock++ });
32
+ await log.append({ kind: "user_message", payload: { content: "hello" } });
33
+ await log.append({
34
+ kind: "assistant_message",
35
+ payload: { content: [{ type: "text", text: "hi" }] },
36
+ });
37
+ await log.append({
38
+ kind: "tool_use",
39
+ payload: { id: "tu_1", name: "Read", input: { path: "x" } },
40
+ });
41
+ await log.append({
42
+ kind: "tool_result",
43
+ payload: { toolUseId: "tu_1", content: "ok", isError: false },
44
+ });
45
+ await log.append({ kind: "error", payload: { name: "E", message: "boom" } });
46
+ await log.append({ kind: "compaction", payload: { kind: "snip", before: 100, after: 30 } });
47
+ await log.close();
48
+
49
+ const all = await collect(log.read());
50
+ expect(all.length).toBe(6);
51
+ expect(all.map((e) => e.kind)).toEqual([
52
+ "user_message",
53
+ "assistant_message",
54
+ "tool_use",
55
+ "tool_result",
56
+ "error",
57
+ "compaction",
58
+ ]);
59
+ for (const ev of all) {
60
+ expect(ev.version).toBe(1);
61
+ expect(typeof ev.ts).toBe("number");
62
+ }
63
+ // ts values should be strictly increasing because of clock++
64
+ for (let i = 1; i < all.length; i++) {
65
+ const prev = all[i - 1];
66
+ const curr = all[i];
67
+ if (prev === undefined || curr === undefined) throw new Error("unreachable");
68
+ expect(curr.ts).toBeGreaterThan(prev.ts);
69
+ }
70
+ expect(all[0]?.payload).toEqual({ content: "hello" });
71
+ });
72
+
73
+ test("read filters by since and until", async () => {
74
+ const rootDir = newTempRoot();
75
+ let clock = 100;
76
+ const log = await openEventLog(TEST_ID, { rootDir, now: () => clock });
77
+ for (let i = 0; i < 5; i++) {
78
+ clock = 100 + i * 10;
79
+ await log.append({ kind: "user_message", payload: { i } });
80
+ }
81
+ await log.close();
82
+
83
+ const all = await collect(log.read());
84
+ expect(all.length).toBe(5);
85
+ expect(all.map((e) => e.ts)).toEqual([100, 110, 120, 130, 140]);
86
+
87
+ const sinceOnly = await collect(log.read({ since: 120 }));
88
+ expect(sinceOnly.map((e) => e.ts)).toEqual([120, 130, 140]);
89
+
90
+ const untilOnly = await collect(log.read({ until: 120 }));
91
+ expect(untilOnly.map((e) => e.ts)).toEqual([100, 110, 120]);
92
+
93
+ const both = await collect(log.read({ since: 110, until: 130 }));
94
+ expect(both.map((e) => e.ts)).toEqual([110, 120, 130]);
95
+ });
96
+
97
+ test("read of a never-written log yields zero events", async () => {
98
+ const rootDir = newTempRoot();
99
+ const log = await openEventLog(TEST_ID, { rootDir });
100
+ const all = await collect(log.read());
101
+ expect(all).toEqual([]);
102
+ });
103
+
104
+ test("malformed line throws RuntimeError carrying the line number", async () => {
105
+ const rootDir = newTempRoot();
106
+ const log = await openEventLog(TEST_ID, { rootDir });
107
+ await log.append({ kind: "user_message", payload: { i: 1 } });
108
+ // Manually corrupt the file with a bogus trailing line.
109
+ writeFileSync(
110
+ join(rootDir, `${TEST_ID}.jsonl`),
111
+ `{"ts":1,"version":1,"kind":"user_message","payload":{"i":1}}\nNOT JSON\n`,
112
+ );
113
+ await expect(collect(log.read())).rejects.toThrow(/malformed JSON on line 2/);
114
+ });
115
+
116
+ test("ignores blank lines without bumping the line counter visibly", async () => {
117
+ const rootDir = newTempRoot();
118
+ const log = await openEventLog(TEST_ID, { rootDir });
119
+ await log.append({ kind: "user_message", payload: {} });
120
+ // Append a blank line + a real event manually.
121
+ writeFileSync(
122
+ join(rootDir, `${TEST_ID}.jsonl`),
123
+ `{"ts":1,"version":1,"kind":"user_message","payload":{}}\n\n{"ts":2,"version":1,"kind":"user_message","payload":{}}\n`,
124
+ );
125
+ const all = await collect(log.read());
126
+ expect(all.length).toBe(2);
127
+ });
128
+
129
+ test("rejects an invalid session id", async () => {
130
+ const rootDir = newTempRoot();
131
+ await expect(openEventLog("../escape", { rootDir })).rejects.toThrow(/invalid sessionId/);
132
+ await expect(openEventLog("sess_short", { rootDir })).rejects.toThrow(/invalid sessionId/);
133
+ });
134
+
135
+ test("creates the root directory if it does not exist", async () => {
136
+ const rootDir = join(newTempRoot(), "deep", "nested", "dir");
137
+ const log = await openEventLog(TEST_ID, { rootDir });
138
+ await log.append({ kind: "user_message", payload: { ok: true } });
139
+ const all = await collect(log.read());
140
+ expect(all.length).toBe(1);
141
+ });
142
+ });
143
+
144
+ describe("event-log — T7 load", () => {
145
+ test("10 000 appends round-trip cleanly", async () => {
146
+ const rootDir = newTempRoot();
147
+ const log = await openEventLog(TEST_ID, { rootDir });
148
+
149
+ const COUNT = 10_000;
150
+ const start = performance.now();
151
+ for (let i = 0; i < COUNT; i++) {
152
+ await log.append({ kind: "user_message", payload: { i, padding: "x".repeat(20) } });
153
+ }
154
+ const appendMs = performance.now() - start;
155
+ expect(appendMs).toBeLessThan(15_000);
156
+
157
+ const fullPath = join(rootDir, `${TEST_ID}.jsonl`);
158
+ expect(statSync(fullPath).size).toBeGreaterThan(COUNT * 60);
159
+
160
+ const readStart = performance.now();
161
+ const all = await collect(log.read());
162
+ const readMs = performance.now() - readStart;
163
+ expect(all.length).toBe(COUNT);
164
+ for (let i = 0; i < COUNT; i++) {
165
+ const ev = all[i];
166
+ if (ev === undefined) throw new Error("unreachable");
167
+ expect((ev.payload as { i: number }).i).toBe(i);
168
+ }
169
+ // Read should be substantially faster than the append loop.
170
+ expect(readMs).toBeLessThan(5_000);
171
+ }, 30_000);
172
+ });
package/src/index.ts ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Catalog R7 `event-log` — append-only JSONL transcript per session.
3
+ *
4
+ * One event per line at `<rootDir>/<sessionId>.jsonl` (default rootDir
5
+ * `.crewhaus/sessions`). Every line is a self-describing JSON object:
6
+ * `{ ts, version: 1, kind, payload }`. The schema-version field is
7
+ * stamped onto every event so future migrations can fan out on it.
8
+ *
9
+ * Append semantics: each `append()` calls `appendFileSync(...)` with
10
+ * mode 0o600 (owner-only) per the
11
+ * `claude-code/utils/sessionStorage.ts` precedent. Synchronous append on
12
+ * POSIX is atomic per line (when `len < PIPE_BUF`), so concurrent runs
13
+ * cannot interleave partial JSON. The API is async to keep the door open
14
+ * for a future buffered-writer optimisation; today it resolves
15
+ * immediately.
16
+ *
17
+ * Read semantics: `read({ since?, until? })` opens a fresh read stream
18
+ * via `node:readline`, parses each line as JSON, and yields events in
19
+ * insertion order (filtered by epoch `ts` if either bound is supplied).
20
+ * Missing files yield zero events. A malformed line throws
21
+ * `RuntimeError` carrying the line number — event logs must round-trip
22
+ * cleanly.
23
+ *
24
+ * Reference: `claude-code/utils/sessionStorage.ts`,
25
+ * `AI-Harness-Systems.md` §append-only event history.
26
+ */
27
+ import { appendFileSync, createReadStream, existsSync, mkdirSync } from "node:fs";
28
+ import { join } from "node:path";
29
+ import { createInterface } from "node:readline";
30
+ import { RuntimeError } from "@crewhaus/errors";
31
+
32
+ export const DEFAULT_ROOT_DIR = ".crewhaus/sessions";
33
+ const ID_REGEX = /^sess_[0-9a-f]{16}$/;
34
+
35
+ export type EventKind =
36
+ | "user_message"
37
+ | "assistant_message"
38
+ | "tool_use"
39
+ | "tool_result"
40
+ | "error"
41
+ | "compaction"
42
+ | "sub_agent_start"
43
+ | "sub_agent_end"
44
+ // Section 22 — CRW (multi-agent crew) lifecycle events. Single durable
45
+ // sessionId across an entire crew run; every role's turn writes here.
46
+ | "role_start"
47
+ | "role_end"
48
+ | "handoff"
49
+ | "a2a_message"
50
+ // a2a_turn_start / a2a_turn_end bracket the nested inline `runChatLoop`
51
+ // an A2A peer call drives. Because every role in a crew shares one
52
+ // session JSONL, the peer's `user_message` + `assistant_message`
53
+ // events land in the parent's log; on a later role's `resume`,
54
+ // `replayMessageHistory` uses these markers to skip the peer's nested
55
+ // transcript and keep the parent's `tool_use → tool_result` pair
56
+ // immediately adjacent (Claude API requires it). Symmetric to
57
+ // `sub_agent_start/end` for Section-13 sub-agents.
58
+ | "a2a_turn_start"
59
+ | "a2a_turn_end"
60
+ | "crew_done";
61
+
62
+ export type Event = {
63
+ readonly ts: number;
64
+ readonly version: 1;
65
+ readonly kind: EventKind;
66
+ readonly payload: unknown;
67
+ };
68
+
69
+ export type AppendEvent = Pick<Event, "kind" | "payload">;
70
+
71
+ export type OpenEventLogOptions = {
72
+ readonly rootDir?: string;
73
+ readonly now?: () => number;
74
+ };
75
+
76
+ export interface EventLog {
77
+ append(event: AppendEvent): Promise<void>;
78
+ read(opts?: { since?: number; until?: number }): AsyncIterable<Event>;
79
+ close(): Promise<void>;
80
+ }
81
+
82
+ function validateId(sessionId: string): void {
83
+ if (!ID_REGEX.test(sessionId)) {
84
+ throw new RuntimeError(`event-log: invalid sessionId "${sessionId}" — expected sess_<16 hex>`);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Open (or implicitly create) the JSONL log for `sessionId`. Creates the
90
+ * parent directory on demand. Subsequent `append()` calls write
91
+ * synchronously to the file; `read()` opens its own read stream so it
92
+ * sees a consistent snapshot of the bytes already on disk.
93
+ */
94
+ export async function openEventLog(
95
+ sessionId: string,
96
+ opts: OpenEventLogOptions = {},
97
+ ): Promise<EventLog> {
98
+ validateId(sessionId);
99
+ const rootDir = opts.rootDir ?? DEFAULT_ROOT_DIR;
100
+ const now = opts.now ?? (() => Date.now());
101
+ const fullPath = join(rootDir, `${sessionId}.jsonl`);
102
+ mkdirSync(rootDir, { recursive: true });
103
+
104
+ return {
105
+ async append(event: AppendEvent): Promise<void> {
106
+ const wire: Event = {
107
+ ts: now(),
108
+ version: 1,
109
+ kind: event.kind,
110
+ payload: event.payload,
111
+ };
112
+ const line = `${JSON.stringify(wire)}\n`;
113
+ appendFileSync(fullPath, line, { mode: 0o600 });
114
+ },
115
+
116
+ read(readOpts: { since?: number; until?: number } = {}): AsyncIterable<Event> {
117
+ return readEvents(fullPath, readOpts);
118
+ },
119
+
120
+ async close(): Promise<void> {
121
+ // No persistent handle today; reserved for a future buffered writer.
122
+ },
123
+ };
124
+ }
125
+
126
+ async function* readEvents(
127
+ fullPath: string,
128
+ opts: { since?: number; until?: number },
129
+ ): AsyncIterable<Event> {
130
+ if (!existsSync(fullPath)) return;
131
+ const stream = createReadStream(fullPath, { encoding: "utf8" });
132
+ const rl = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
133
+ let lineNumber = 0;
134
+ try {
135
+ for await (const raw of rl) {
136
+ lineNumber += 1;
137
+ if (raw === "") continue;
138
+ let parsed: Event;
139
+ try {
140
+ parsed = JSON.parse(raw) as Event;
141
+ } catch (err) {
142
+ throw new RuntimeError(
143
+ `event-log: malformed JSON on line ${lineNumber} of ${fullPath}`,
144
+ err,
145
+ );
146
+ }
147
+ if (opts.since !== undefined && parsed.ts < opts.since) continue;
148
+ if (opts.until !== undefined && parsed.ts > opts.until) continue;
149
+ yield parsed;
150
+ }
151
+ } finally {
152
+ rl.close();
153
+ stream.close();
154
+ }
155
+ }