Skip to content

Commit 011deb3

Browse files
committed
feat(session): flag-gated append-only session event log
Single append-only session event stream (UUIDv7 id + parentId linkage, one JSON line per event) that coexists with the segmented turns.jsonl writer behind the settings.sessionEventLog / CORBITS_SESSION_EVENT_LOG flag. No read-path change: the module is inert unless the flag is on, and nothing in the existing write/read path imports it. - createEventStreamWriter: append-only writer with append, flush, readFrom, latestCursor, projectTurns, projectPrompt, backfillFromLegacy; a reopened writer continues the on-disk line count - appends serialized with withResolvedDirLock; seq derives from the on-disk complete line count inside the lock, so concurrent writers cannot interleave lines or duplicate seqs - torn tails (unterminated partial lines) are dropped on read and truncated before the next append, keeping seq contiguous - backfillFromLegacy is idempotent via a per-line content hash - openEventStreamWriter: flag-gated seam returning null unless the flag is enabled; explicit "off" env values win over settings - unit tests: append, order, linkage, reopen, cursor reads, concurrency, torn-tail recovery, projections, legacy backfill, flag gating
1 parent 40e19ce commit 011deb3

2 files changed

Lines changed: 751 additions & 0 deletions

File tree

‎src/session/event-log.test.ts‎

Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
import { describe, test, expect } from "bun:test";
2+
import fs from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
5+
6+
import { isSessionId } from "./index.js";
7+
import {
8+
EVENT_LOG_FILE,
9+
SESSION_EVENT_LOG_ENV,
10+
createEventStreamWriter,
11+
isSessionEventLogEnabled,
12+
openEventStreamWriter,
13+
} from "./event-log.js";
14+
15+
function tempDir(): string {
16+
return fs.mkdtempSync(path.join(os.tmpdir(), "event-stream-"));
17+
}
18+
19+
function rawLines(dir: string, file: string = EVENT_LOG_FILE): string[] {
20+
const raw = fs.readFileSync(path.join(dir, file), "utf8");
21+
return raw.split("\n").filter((line) => line.length > 0);
22+
}
23+
24+
describe("createEventStreamWriter", () => {
25+
test("appends one JSON line per event with generated UUIDv7 id, seq, ts, payload", async () => {
26+
const dir = tempDir();
27+
const writer = createEventStreamWriter(dir, {
28+
now: () => 1_700_000_000_000,
29+
});
30+
const first = await writer.append({
31+
type: "session.started",
32+
payload: { task: "hello" },
33+
});
34+
const second = await writer.append({ id: "fixed-id", type: "turn.done" });
35+
36+
// Event ids are UUIDv7 when the caller does not supply one.
37+
expect(isSessionId(first.id)).toBe(true);
38+
expect(first).toEqual({
39+
id: first.id,
40+
type: "session.started",
41+
seq: 0,
42+
ts: 1_700_000_000_000,
43+
payload: { task: "hello" },
44+
});
45+
expect(second.id).toBe("fixed-id");
46+
expect(second.seq).toBe(1);
47+
48+
const lines = rawLines(dir);
49+
expect(lines).toHaveLength(2);
50+
for (const line of lines) {
51+
expect(() => JSON.parse(line)).not.toThrow();
52+
expect(line.endsWith("\n")).toBe(false);
53+
}
54+
expect(lines[0]).toBe(JSON.stringify(first));
55+
});
56+
57+
test("preserves append order and assigns seq in order", async () => {
58+
const dir = tempDir();
59+
const writer = createEventStreamWriter(dir);
60+
const ids = ["a", "b", "c", "d", "e"];
61+
for (const id of ids) {
62+
await writer.append({ id, type: "event" });
63+
}
64+
65+
const records = await writer.readFrom(-1);
66+
expect(records.map((r) => r.id)).toEqual(ids);
67+
expect(records.map((r) => r.seq)).toEqual([0, 1, 2, 3, 4]);
68+
69+
// File order matches append order.
70+
const lines = rawLines(dir).map(
71+
(line) => JSON.parse(line) as { id: string },
72+
);
73+
expect(lines.map((l) => l.id)).toEqual(ids);
74+
});
75+
76+
test("keeps parentId linkage resolvable across the stream", async () => {
77+
const dir = tempDir();
78+
const writer = createEventStreamWriter(dir);
79+
await writer.append({ id: "root", type: "session.started" });
80+
await writer.append({ id: "turn-1", parentId: "root", type: "turn.done" });
81+
await writer.append({
82+
id: "turn-2",
83+
parentId: "turn-1",
84+
type: "turn.done",
85+
});
86+
87+
const records = await writer.readFrom(-1);
88+
expect(records.map((r) => r.parentId)).toEqual([
89+
undefined,
90+
"root",
91+
"turn-1",
92+
]);
93+
94+
// Walk the chain from the tail back to the root through parentId.
95+
const byId = new Map(records.map((r) => [r.id, r]));
96+
const chain: string[] = [];
97+
let cursor: string | undefined = "turn-2";
98+
while (cursor !== undefined) {
99+
const record = byId.get(cursor);
100+
if (record === undefined) throw new Error(`missing event ${cursor}`);
101+
chain.push(record.id);
102+
cursor = record.parentId;
103+
}
104+
expect(chain).toEqual(["turn-2", "turn-1", "root"]);
105+
});
106+
107+
test("a reopened writer continues seq and preserves prior events", async () => {
108+
const dir = tempDir();
109+
const first = createEventStreamWriter(dir);
110+
await first.append({ id: "evt-1", type: "session.started" });
111+
await first.append({ id: "evt-2", type: "turn.done" });
112+
await first.flush();
113+
114+
// New writer on the same file: seq continues from the on-disk line count.
115+
const reopened = createEventStreamWriter(dir);
116+
const third = await reopened.append({ id: "evt-3", type: "turn.done" });
117+
expect(third.seq).toBe(2);
118+
119+
const records = await reopened.readFrom(-1);
120+
expect(records.map((r) => r.id)).toEqual(["evt-1", "evt-2", "evt-3"]);
121+
expect(records.map((r) => r.seq)).toEqual([0, 1, 2]);
122+
expect(await reopened.latestCursor()).toBe(2);
123+
});
124+
125+
test("flush awaits previously appended events", async () => {
126+
const dir = tempDir();
127+
const writer = createEventStreamWriter(dir);
128+
await writer.append({ id: "a", type: "event" });
129+
await writer.append({ id: "b", type: "event" });
130+
await writer.flush();
131+
expect(rawLines(dir)).toHaveLength(2);
132+
});
133+
134+
test("concurrent writers on one dir cannot duplicate seqs or interleave", async () => {
135+
const dir = tempDir();
136+
const writerA = createEventStreamWriter(dir);
137+
const writerB = createEventStreamWriter(dir);
138+
const events = await Promise.all(
139+
Array.from({ length: 20 }, (_, i) =>
140+
(i % 2 === 0 ? writerA : writerB).append({
141+
id: `evt-${i}`,
142+
type: "event",
143+
}),
144+
),
145+
);
146+
147+
const seqs = events.map((e) => e.seq);
148+
expect(new Set(seqs).size).toBe(seqs.length);
149+
expect(Math.max(...seqs)).toBe(seqs.length - 1);
150+
151+
const records = await writerA.readFrom(-1);
152+
expect(records).toHaveLength(20);
153+
expect(new Set(records.map((r) => r.id)).size).toBe(20);
154+
expect(records.map((r) => r.seq)).toEqual([...seqs].sort((a, b) => a - b));
155+
});
156+
157+
test("a torn tail on reopen is dropped: reads succeed and append gets a contiguous seq", async () => {
158+
const dir = tempDir();
159+
const writer = createEventStreamWriter(dir);
160+
await writer.append({ id: "evt-1", type: "session.started" });
161+
await writer.flush();
162+
163+
// Simulate a crash mid-append: a partial line with no trailing newline.
164+
fs.appendFileSync(
165+
path.join(dir, EVENT_LOG_FILE),
166+
'{"id":"torn","type":"eve',
167+
);
168+
169+
// Reads drop the torn tail instead of throwing on it.
170+
const reader = createEventStreamWriter(dir);
171+
expect((await reader.readFrom(-1)).map((r) => r.id)).toEqual(["evt-1"]);
172+
expect(await reader.latestCursor()).toBe(0);
173+
174+
// The next append truncates the torn bytes and continues the seq.
175+
const reopened = createEventStreamWriter(dir);
176+
const next = await reopened.append({ id: "evt-2", type: "turn.done" });
177+
expect(next.seq).toBe(1);
178+
179+
const records = await reopened.readFrom(-1);
180+
expect(records.map((r) => r.id)).toEqual(["evt-1", "evt-2"]);
181+
expect(records.map((r) => r.seq)).toEqual([0, 1]);
182+
});
183+
184+
test("readFrom returns only events past the cursor", async () => {
185+
const dir = tempDir();
186+
const writer = createEventStreamWriter(dir);
187+
for (const id of ["a", "b", "c", "d"]) {
188+
await writer.append({ id, type: "event" });
189+
}
190+
const after = await writer.readFrom(1);
191+
expect(after.map((r) => r.id)).toEqual(["c", "d"]);
192+
expect(await writer.readFrom(3)).toEqual([]);
193+
});
194+
195+
test("latestCursor reports -1 on an empty log and the last seq after appends", async () => {
196+
const dir = tempDir();
197+
const writer = createEventStreamWriter(dir);
198+
expect(await writer.latestCursor()).toBe(-1);
199+
await writer.append({ id: "a", type: "event" });
200+
await writer.append({ id: "b", type: "event" });
201+
expect(await writer.latestCursor()).toBe(1);
202+
});
203+
204+
test("projectTurns returns turn events in order", async () => {
205+
const dir = tempDir();
206+
const writer = createEventStreamWriter(dir);
207+
await writer.append({ id: "s", type: "session.started" });
208+
await writer.append({
209+
id: "t1",
210+
type: "turn.user",
211+
payload: { text: "hi" },
212+
});
213+
await writer.append({
214+
id: "m",
215+
type: "model.selected",
216+
payload: { model: "x" },
217+
});
218+
await writer.append({
219+
id: "t2",
220+
type: "turn.assistant",
221+
payload: { text: "yo" },
222+
});
223+
224+
const turns = (await writer.projectTurns()) as { id: string }[];
225+
expect(turns.map((t) => t.id)).toEqual(["t1", "t2"]);
226+
});
227+
228+
test("projectPrompt joins text payloads in order", async () => {
229+
const dir = tempDir();
230+
const writer = createEventStreamWriter(dir);
231+
await writer.append({
232+
id: "a",
233+
type: "turn.user",
234+
payload: { text: "first" },
235+
});
236+
await writer.append({
237+
id: "b",
238+
type: "turn.user",
239+
payload: { text: "second" },
240+
});
241+
await writer.append({ id: "c", type: "turn.user" });
242+
expect(await writer.projectPrompt()).toBe("first\nsecond");
243+
});
244+
245+
test("backfillFromLegacy appends turns.jsonl as chained turn.legacy events", async () => {
246+
const dir = tempDir();
247+
fs.writeFileSync(
248+
path.join(dir, "turns.jsonl"),
249+
'{"role":"user","text":"one"}\n{"role":"assistant","text":"two"}\n',
250+
);
251+
const writer = createEventStreamWriter(dir);
252+
expect(await writer.backfillFromLegacy()).toBe(2);
253+
254+
const records = await writer.readFrom(-1);
255+
expect(records).toHaveLength(2);
256+
expect(records.every((r) => r.type === "turn.legacy")).toBe(true);
257+
const [first, second] = records;
258+
if (first === undefined || second === undefined) {
259+
throw new Error("expected two backfilled events");
260+
}
261+
expect(first.parentId).toBeUndefined();
262+
expect(second.parentId).toBe(first.id);
263+
expect((first.payload as { record: unknown }).record).toEqual({
264+
role: "user",
265+
text: "one",
266+
});
267+
});
268+
269+
test("backfillFromLegacy returns 0 when turns.jsonl is absent", async () => {
270+
const dir = tempDir();
271+
const writer = createEventStreamWriter(dir);
272+
expect(await writer.backfillFromLegacy()).toBe(0);
273+
});
274+
275+
test("backfillFromLegacy is idempotent across re-runs", async () => {
276+
const dir = tempDir();
277+
fs.writeFileSync(
278+
path.join(dir, "turns.jsonl"),
279+
'{"role":"user","text":"one"}\n{"role":"assistant","text":"two"}\n',
280+
);
281+
const writer = createEventStreamWriter(dir);
282+
expect(await writer.backfillFromLegacy()).toBe(2);
283+
// A second run (e.g. after an interrupted first attempt) adds nothing.
284+
expect(await writer.backfillFromLegacy()).toBe(0);
285+
286+
const records = await writer.readFrom(-1);
287+
expect(records).toHaveLength(2);
288+
expect(records.map((r) => r.type)).toEqual(["turn.legacy", "turn.legacy"]);
289+
});
290+
291+
test("backfillFromLegacy skips only already-backfilled lines", async () => {
292+
const dir = tempDir();
293+
fs.writeFileSync(
294+
path.join(dir, "turns.jsonl"),
295+
'{"role":"user","text":"one"}\n{"role":"assistant","text":"two"}\n',
296+
);
297+
const writer = createEventStreamWriter(dir);
298+
expect(await writer.backfillFromLegacy()).toBe(2);
299+
300+
// Grow the legacy file; only the new line is backfilled.
301+
fs.appendFileSync(
302+
path.join(dir, "turns.jsonl"),
303+
'{"role":"user","text":"three"}\n',
304+
);
305+
expect(await writer.backfillFromLegacy()).toBe(1);
306+
307+
const records = await writer.readFrom(-1);
308+
expect(records).toHaveLength(3);
309+
});
310+
});
311+
312+
describe("event stream flag gating", () => {
313+
test("isSessionEventLogEnabled honors settings and env, with explicit off", () => {
314+
expect(isSessionEventLogEnabled({})).toBe(false);
315+
expect(
316+
isSessionEventLogEnabled({ settings: { sessionEventLog: true } }),
317+
).toBe(true);
318+
expect(
319+
isSessionEventLogEnabled({ settings: { sessionEventLog: false } }),
320+
).toBe(false);
321+
expect(
322+
isSessionEventLogEnabled({ flagEnv: { [SESSION_EVENT_LOG_ENV]: "1" } }),
323+
).toBe(true);
324+
expect(
325+
isSessionEventLogEnabled({
326+
flagEnv: { [SESSION_EVENT_LOG_ENV]: "true" },
327+
}),
328+
).toBe(true);
329+
// An explicit "off" env value wins over settings.
330+
for (const off of ["", "0", "false", "no", "off"]) {
331+
expect(
332+
isSessionEventLogEnabled({
333+
settings: { sessionEventLog: true },
334+
flagEnv: { [SESSION_EVENT_LOG_ENV]: off },
335+
}),
336+
).toBe(false);
337+
}
338+
});
339+
340+
test("openEventStreamWriter returns null (inert) when the flag is off", () => {
341+
const dir = tempDir();
342+
expect(
343+
openEventStreamWriter(dir, { settings: { sessionEventLog: false } }),
344+
).toBeNull();
345+
expect(
346+
openEventStreamWriter(dir, {
347+
flagEnv: { [SESSION_EVENT_LOG_ENV]: "off" },
348+
}),
349+
).toBeNull();
350+
// Nothing is created on disk while the flag is off.
351+
expect(fs.existsSync(path.join(dir, EVENT_LOG_FILE))).toBe(false);
352+
});
353+
354+
test("openEventStreamWriter returns a live writer when the flag is on", async () => {
355+
const dir = tempDir();
356+
const writer = openEventStreamWriter(dir, {
357+
settings: { sessionEventLog: true },
358+
});
359+
expect(writer).not.toBeNull();
360+
if (writer === null)
361+
throw new Error("flag-enabled writer should not be null");
362+
await writer.append({ type: "session.started" });
363+
expect(await writer.readFrom(-1)).toHaveLength(1);
364+
expect(fs.existsSync(path.join(dir, EVENT_LOG_FILE))).toBe(true);
365+
});
366+
});

0 commit comments

Comments
 (0)