Skip to content

Commit 69f3fcd

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 - 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, projections, legacy backfill, flag gating
1 parent 40e19ce commit 69f3fcd

2 files changed

Lines changed: 615 additions & 0 deletions

File tree

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

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
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("readFrom returns only events past the cursor", async () => {
135+
const dir = tempDir();
136+
const writer = createEventStreamWriter(dir);
137+
for (const id of ["a", "b", "c", "d"]) {
138+
await writer.append({ id, type: "event" });
139+
}
140+
const after = await writer.readFrom(1);
141+
expect(after.map((r) => r.id)).toEqual(["c", "d"]);
142+
expect(await writer.readFrom(3)).toEqual([]);
143+
});
144+
145+
test("latestCursor reports -1 on an empty log and the last seq after appends", async () => {
146+
const dir = tempDir();
147+
const writer = createEventStreamWriter(dir);
148+
expect(await writer.latestCursor()).toBe(-1);
149+
await writer.append({ id: "a", type: "event" });
150+
await writer.append({ id: "b", type: "event" });
151+
expect(await writer.latestCursor()).toBe(1);
152+
});
153+
154+
test("projectTurns returns turn events in order", async () => {
155+
const dir = tempDir();
156+
const writer = createEventStreamWriter(dir);
157+
await writer.append({ id: "s", type: "session.started" });
158+
await writer.append({
159+
id: "t1",
160+
type: "turn.user",
161+
payload: { text: "hi" },
162+
});
163+
await writer.append({
164+
id: "m",
165+
type: "model.selected",
166+
payload: { model: "x" },
167+
});
168+
await writer.append({
169+
id: "t2",
170+
type: "turn.assistant",
171+
payload: { text: "yo" },
172+
});
173+
174+
const turns = (await writer.projectTurns()) as { id: string }[];
175+
expect(turns.map((t) => t.id)).toEqual(["t1", "t2"]);
176+
});
177+
178+
test("projectPrompt joins text payloads in order", async () => {
179+
const dir = tempDir();
180+
const writer = createEventStreamWriter(dir);
181+
await writer.append({
182+
id: "a",
183+
type: "turn.user",
184+
payload: { text: "first" },
185+
});
186+
await writer.append({
187+
id: "b",
188+
type: "turn.user",
189+
payload: { text: "second" },
190+
});
191+
await writer.append({ id: "c", type: "turn.user" });
192+
expect(await writer.projectPrompt()).toBe("first\nsecond");
193+
});
194+
195+
test("backfillFromLegacy appends turns.jsonl as chained turn.legacy events", async () => {
196+
const dir = tempDir();
197+
fs.writeFileSync(
198+
path.join(dir, "turns.jsonl"),
199+
'{"role":"user","text":"one"}\n{"role":"assistant","text":"two"}\n',
200+
);
201+
const writer = createEventStreamWriter(dir);
202+
expect(await writer.backfillFromLegacy()).toBe(2);
203+
204+
const records = await writer.readFrom(-1);
205+
expect(records).toHaveLength(2);
206+
expect(records.every((r) => r.type === "turn.legacy")).toBe(true);
207+
const [first, second] = records;
208+
if (first === undefined || second === undefined) {
209+
throw new Error("expected two backfilled events");
210+
}
211+
expect(first.parentId).toBeUndefined();
212+
expect(second.parentId).toBe(first.id);
213+
expect((first.payload as { record: unknown }).record).toEqual({
214+
role: "user",
215+
text: "one",
216+
});
217+
});
218+
219+
test("backfillFromLegacy returns 0 when turns.jsonl is absent", async () => {
220+
const dir = tempDir();
221+
const writer = createEventStreamWriter(dir);
222+
expect(await writer.backfillFromLegacy()).toBe(0);
223+
});
224+
});
225+
226+
describe("event stream flag gating", () => {
227+
test("isSessionEventLogEnabled honors settings and env, with explicit off", () => {
228+
expect(isSessionEventLogEnabled({})).toBe(false);
229+
expect(
230+
isSessionEventLogEnabled({ settings: { sessionEventLog: true } }),
231+
).toBe(true);
232+
expect(
233+
isSessionEventLogEnabled({ settings: { sessionEventLog: false } }),
234+
).toBe(false);
235+
expect(
236+
isSessionEventLogEnabled({ flagEnv: { [SESSION_EVENT_LOG_ENV]: "1" } }),
237+
).toBe(true);
238+
expect(
239+
isSessionEventLogEnabled({
240+
flagEnv: { [SESSION_EVENT_LOG_ENV]: "true" },
241+
}),
242+
).toBe(true);
243+
// An explicit "off" env value wins over settings.
244+
for (const off of ["", "0", "false", "no", "off"]) {
245+
expect(
246+
isSessionEventLogEnabled({
247+
settings: { sessionEventLog: true },
248+
flagEnv: { [SESSION_EVENT_LOG_ENV]: off },
249+
}),
250+
).toBe(false);
251+
}
252+
});
253+
254+
test("openEventStreamWriter returns null (inert) when the flag is off", () => {
255+
const dir = tempDir();
256+
expect(
257+
openEventStreamWriter(dir, { settings: { sessionEventLog: false } }),
258+
).toBeNull();
259+
expect(
260+
openEventStreamWriter(dir, {
261+
flagEnv: { [SESSION_EVENT_LOG_ENV]: "off" },
262+
}),
263+
).toBeNull();
264+
// Nothing is created on disk while the flag is off.
265+
expect(fs.existsSync(path.join(dir, EVENT_LOG_FILE))).toBe(false);
266+
});
267+
268+
test("openEventStreamWriter returns a live writer when the flag is on", async () => {
269+
const dir = tempDir();
270+
const writer = openEventStreamWriter(dir, {
271+
settings: { sessionEventLog: true },
272+
});
273+
expect(writer).not.toBeNull();
274+
if (writer === null)
275+
throw new Error("flag-enabled writer should not be null");
276+
await writer.append({ type: "session.started" });
277+
expect(await writer.readFrom(-1)).toHaveLength(1);
278+
expect(fs.existsSync(path.join(dir, EVENT_LOG_FILE))).toBe(true);
279+
});
280+
});

0 commit comments

Comments
 (0)