@crewhaus/event-log 0.1.4 → 0.1.6

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.
@@ -0,0 +1,28 @@
1
+ export declare const DEFAULT_ROOT_DIR = ".crewhaus/sessions";
2
+ export type EventKind = "user_message" | "assistant_message" | "tool_use" | "tool_result" | "error" | "compaction" | "sub_agent_start" | "sub_agent_end" | "role_start" | "role_end" | "handoff" | "a2a_message" | "a2a_turn_start" | "a2a_turn_end" | "crew_done" | "cost_accrual";
3
+ export type Event = {
4
+ readonly ts: number;
5
+ readonly version: 1;
6
+ readonly kind: EventKind;
7
+ readonly payload: unknown;
8
+ };
9
+ export type AppendEvent = Pick<Event, "kind" | "payload">;
10
+ export type OpenEventLogOptions = {
11
+ readonly rootDir?: string;
12
+ readonly now?: () => number;
13
+ };
14
+ export interface EventLog {
15
+ append(event: AppendEvent): Promise<void>;
16
+ read(opts?: {
17
+ since?: number;
18
+ until?: number;
19
+ }): AsyncIterable<Event>;
20
+ close(): Promise<void>;
21
+ }
22
+ /**
23
+ * Open (or implicitly create) the JSONL log for `sessionId`. Creates the
24
+ * parent directory on demand. Subsequent `append()` calls write
25
+ * synchronously to the file; `read()` opens its own read stream so it
26
+ * sees a consistent snapshot of the bytes already on disk.
27
+ */
28
+ export declare function openEventLog(sessionId: string, opts?: OpenEventLogOptions): Promise<EventLog>;
package/dist/index.js ADDED
@@ -0,0 +1,110 @@
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, resolve } from "node:path";
29
+ import { createInterface } from "node:readline";
30
+ import { RuntimeError } from "@crewhaus/errors";
31
+ import { assertSamePath, currentTenantContext, requireTenant } from "@crewhaus/tenancy";
32
+ export const DEFAULT_ROOT_DIR = ".crewhaus/sessions";
33
+ const ID_REGEX = /^sess_[0-9a-f]{16}$/;
34
+ function validateId(sessionId) {
35
+ if (!ID_REGEX.test(sessionId)) {
36
+ throw new RuntimeError(`event-log: invalid sessionId "${sessionId}" — expected sess_<16 hex>`);
37
+ }
38
+ }
39
+ /**
40
+ * Open (or implicitly create) the JSONL log for `sessionId`. Creates the
41
+ * parent directory on demand. Subsequent `append()` calls write
42
+ * synchronously to the file; `read()` opens its own read stream so it
43
+ * sees a consistent snapshot of the bytes already on disk.
44
+ */
45
+ export async function openEventLog(sessionId, opts = {}) {
46
+ validateId(sessionId);
47
+ const rootDir = opts.rootDir ?? DEFAULT_ROOT_DIR;
48
+ const now = opts.now ?? (() => Date.now());
49
+ const fullPath = resolve(rootDir, `${sessionId}.jsonl`);
50
+ mkdirSync(rootDir, { recursive: true });
51
+ // When a tenant context is active, fail closed on a resolved path that
52
+ // escapes the tenant's sessionRoot (CWE-1230). Outside a tenant scope (the
53
+ // common CLI case) this is a no-op so non-tenant behaviour is unchanged.
54
+ function fence() {
55
+ if (currentTenantContext() !== undefined) {
56
+ assertSamePath(fullPath, requireTenant().sessionRoot);
57
+ }
58
+ }
59
+ fence();
60
+ return {
61
+ async append(event) {
62
+ fence();
63
+ const wire = {
64
+ ts: now(),
65
+ version: 1,
66
+ kind: event.kind,
67
+ payload: event.payload,
68
+ };
69
+ const line = `${JSON.stringify(wire)}\n`;
70
+ appendFileSync(fullPath, line, { mode: 0o600 });
71
+ },
72
+ read(readOpts = {}) {
73
+ fence();
74
+ return readEvents(fullPath, readOpts);
75
+ },
76
+ async close() {
77
+ // No persistent handle today; reserved for a future buffered writer.
78
+ },
79
+ };
80
+ }
81
+ async function* readEvents(fullPath, opts) {
82
+ if (!existsSync(fullPath))
83
+ return;
84
+ const stream = createReadStream(fullPath, { encoding: "utf8" });
85
+ const rl = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
86
+ let lineNumber = 0;
87
+ try {
88
+ for await (const raw of rl) {
89
+ lineNumber += 1;
90
+ if (raw === "")
91
+ continue;
92
+ let parsed;
93
+ try {
94
+ parsed = JSON.parse(raw);
95
+ }
96
+ catch (err) {
97
+ throw new RuntimeError(`event-log: malformed JSON on line ${lineNumber} of ${fullPath}`, err);
98
+ }
99
+ if (opts.since !== undefined && parsed.ts < opts.since)
100
+ continue;
101
+ if (opts.until !== undefined && parsed.ts > opts.until)
102
+ continue;
103
+ yield parsed;
104
+ }
105
+ }
106
+ finally {
107
+ rl.close();
108
+ stream.close();
109
+ }
110
+ }
package/package.json CHANGED
@@ -1,19 +1,22 @@
1
1
  {
2
2
  "name": "@crewhaus/event-log",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "Append-only JSONL transcript log per session",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/errors": "0.1.4",
16
- "@crewhaus/tenancy": "0.1.4"
18
+ "@crewhaus/errors": "0.1.6",
19
+ "@crewhaus/tenancy": "0.1.6"
17
20
  },
18
21
  "license": "Apache-2.0",
19
22
  "author": {
@@ -33,5 +36,5 @@
33
36
  "publishConfig": {
34
37
  "access": "public"
35
38
  },
36
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
39
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
37
40
  }
package/src/index.test.ts DELETED
@@ -1,262 +0,0 @@
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 { TenancyError, buildTenant, withTenant } from "@crewhaus/tenancy";
6
- import { type Event, openEventLog } from "./index";
7
-
8
- const TMP_ROOTS: string[] = [];
9
- function newTempRoot(): string {
10
- const dir = mkdtempSync(join(tmpdir(), "crewhaus-event-log-"));
11
- TMP_ROOTS.push(dir);
12
- return dir;
13
- }
14
- afterAll(() => {
15
- for (const dir of TMP_ROOTS) {
16
- rmSync(dir, { recursive: true, force: true });
17
- }
18
- });
19
-
20
- const TEST_ID = "sess_0123456789abcdef";
21
-
22
- async function collect(events: AsyncIterable<Event>): Promise<Event[]> {
23
- const out: Event[] = [];
24
- for await (const ev of events) out.push(ev);
25
- return out;
26
- }
27
-
28
- describe("event-log — round-trip", () => {
29
- test("append + read returns events in insertion order with version 1", async () => {
30
- const rootDir = newTempRoot();
31
- let clock = 1_700_000_000_000;
32
- const log = await openEventLog(TEST_ID, { rootDir, now: () => clock++ });
33
- await log.append({ kind: "user_message", payload: { content: "hello" } });
34
- await log.append({
35
- kind: "assistant_message",
36
- payload: { content: [{ type: "text", text: "hi" }] },
37
- });
38
- await log.append({
39
- kind: "tool_use",
40
- payload: { id: "tu_1", name: "Read", input: { path: "x" } },
41
- });
42
- await log.append({
43
- kind: "tool_result",
44
- payload: { toolUseId: "tu_1", content: "ok", isError: false },
45
- });
46
- await log.append({ kind: "error", payload: { name: "E", message: "boom" } });
47
- await log.append({ kind: "compaction", payload: { kind: "snip", before: 100, after: 30 } });
48
- await log.close();
49
-
50
- const all = await collect(log.read());
51
- expect(all.length).toBe(6);
52
- expect(all.map((e) => e.kind)).toEqual([
53
- "user_message",
54
- "assistant_message",
55
- "tool_use",
56
- "tool_result",
57
- "error",
58
- "compaction",
59
- ]);
60
- for (const ev of all) {
61
- expect(ev.version).toBe(1);
62
- expect(typeof ev.ts).toBe("number");
63
- }
64
- // ts values should be strictly increasing because of clock++
65
- for (let i = 1; i < all.length; i++) {
66
- const prev = all[i - 1];
67
- const curr = all[i];
68
- if (prev === undefined || curr === undefined) throw new Error("unreachable");
69
- expect(curr.ts).toBeGreaterThan(prev.ts);
70
- }
71
- expect(all[0]?.payload).toEqual({ content: "hello" });
72
- });
73
-
74
- test("read filters by since and until", async () => {
75
- const rootDir = newTempRoot();
76
- let clock = 100;
77
- const log = await openEventLog(TEST_ID, { rootDir, now: () => clock });
78
- for (let i = 0; i < 5; i++) {
79
- clock = 100 + i * 10;
80
- await log.append({ kind: "user_message", payload: { i } });
81
- }
82
- await log.close();
83
-
84
- const all = await collect(log.read());
85
- expect(all.length).toBe(5);
86
- expect(all.map((e) => e.ts)).toEqual([100, 110, 120, 130, 140]);
87
-
88
- const sinceOnly = await collect(log.read({ since: 120 }));
89
- expect(sinceOnly.map((e) => e.ts)).toEqual([120, 130, 140]);
90
-
91
- const untilOnly = await collect(log.read({ until: 120 }));
92
- expect(untilOnly.map((e) => e.ts)).toEqual([100, 110, 120]);
93
-
94
- const both = await collect(log.read({ since: 110, until: 130 }));
95
- expect(both.map((e) => e.ts)).toEqual([110, 120, 130]);
96
- });
97
-
98
- test("read of a never-written log yields zero events", async () => {
99
- const rootDir = newTempRoot();
100
- const log = await openEventLog(TEST_ID, { rootDir });
101
- const all = await collect(log.read());
102
- expect(all).toEqual([]);
103
- });
104
-
105
- test("malformed line throws RuntimeError carrying the line number", async () => {
106
- const rootDir = newTempRoot();
107
- const log = await openEventLog(TEST_ID, { rootDir });
108
- await log.append({ kind: "user_message", payload: { i: 1 } });
109
- // Manually corrupt the file with a bogus trailing line.
110
- writeFileSync(
111
- join(rootDir, `${TEST_ID}.jsonl`),
112
- `{"ts":1,"version":1,"kind":"user_message","payload":{"i":1}}\nNOT JSON\n`,
113
- );
114
- await expect(collect(log.read())).rejects.toThrow(/malformed JSON on line 2/);
115
- });
116
-
117
- test("ignores blank lines without bumping the line counter visibly", async () => {
118
- const rootDir = newTempRoot();
119
- const log = await openEventLog(TEST_ID, { rootDir });
120
- await log.append({ kind: "user_message", payload: {} });
121
- // Append a blank line + a real event manually.
122
- writeFileSync(
123
- join(rootDir, `${TEST_ID}.jsonl`),
124
- `{"ts":1,"version":1,"kind":"user_message","payload":{}}\n\n{"ts":2,"version":1,"kind":"user_message","payload":{}}\n`,
125
- );
126
- const all = await collect(log.read());
127
- expect(all.length).toBe(2);
128
- });
129
-
130
- test("rejects an invalid session id", async () => {
131
- const rootDir = newTempRoot();
132
- await expect(openEventLog("../escape", { rootDir })).rejects.toThrow(/invalid sessionId/);
133
- await expect(openEventLog("sess_short", { rootDir })).rejects.toThrow(/invalid sessionId/);
134
- });
135
-
136
- test("creates the root directory if it does not exist", async () => {
137
- const rootDir = join(newTempRoot(), "deep", "nested", "dir");
138
- const log = await openEventLog(TEST_ID, { rootDir });
139
- await log.append({ kind: "user_message", payload: { ok: true } });
140
- const all = await collect(log.read());
141
- expect(all.length).toBe(1);
142
- });
143
- });
144
-
145
- describe("event-log — T7 load", () => {
146
- test("10 000 appends round-trip cleanly", async () => {
147
- const rootDir = newTempRoot();
148
- const log = await openEventLog(TEST_ID, { rootDir });
149
-
150
- const COUNT = 10_000;
151
- const start = performance.now();
152
- for (let i = 0; i < COUNT; i++) {
153
- await log.append({ kind: "user_message", payload: { i, padding: "x".repeat(20) } });
154
- }
155
- const appendMs = performance.now() - start;
156
- expect(appendMs).toBeLessThan(15_000);
157
-
158
- const fullPath = join(rootDir, `${TEST_ID}.jsonl`);
159
- expect(statSync(fullPath).size).toBeGreaterThan(COUNT * 60);
160
-
161
- const readStart = performance.now();
162
- const all = await collect(log.read());
163
- const readMs = performance.now() - readStart;
164
- expect(all.length).toBe(COUNT);
165
- for (let i = 0; i < COUNT; i++) {
166
- const ev = all[i];
167
- if (ev === undefined) throw new Error("unreachable");
168
- expect((ev.payload as { i: number }).i).toBe(i);
169
- }
170
- // Read should be substantially faster than the append loop.
171
- expect(readMs).toBeLessThan(5_000);
172
- }, 30_000);
173
- });
174
-
175
- describe("event-log — cross-tenant fencing (CWE-1230)", () => {
176
- test("inside tenantA, a log rooted under tenantB fails closed", async () => {
177
- const tenantsRoot = newTempRoot();
178
- const tenantA = buildTenant("tenant-a", { tenantsRoot });
179
- const tenantB = buildTenant("tenant-b", { tenantsRoot });
180
- // Opening a log rooted under tenantB while tenantA is active resolves a
181
- // path outside tenantA's sessionRoot, so it fails closed.
182
- await expect(
183
- withTenant(tenantA, () => openEventLog(TEST_ID, { rootDir: tenantB.sessionRoot })),
184
- ).rejects.toThrow(TenancyError);
185
- });
186
-
187
- test("inside tenantA, a log rooted under tenantA round-trips", async () => {
188
- const tenantsRoot = newTempRoot();
189
- const tenantA = buildTenant("tenant-a", { tenantsRoot });
190
- await withTenant(tenantA, async () => {
191
- const log = await openEventLog(TEST_ID, { rootDir: tenantA.sessionRoot });
192
- await log.append({ kind: "user_message", payload: { ok: true } });
193
- const all = await collect(log.read());
194
- expect(all.length).toBe(1);
195
- });
196
- });
197
-
198
- test("no active tenant — behaviour is unchanged (no fencing)", async () => {
199
- const rootDir = newTempRoot();
200
- const log = await openEventLog(TEST_ID, { rootDir });
201
- await log.append({ kind: "user_message", payload: { ok: true } });
202
- const all = await collect(log.read());
203
- expect(all.length).toBe(1);
204
- });
205
- });
206
-
207
- describe("event-log — security invariants", () => {
208
- // The header documents owner-only (0o600) append semantics, mirroring the
209
- // claude-code sessionStorage precedent. Pin it down so a regression that
210
- // drops `{ mode: 0o600 }` (widening the transcript to group/other) fails.
211
- test("the JSONL file is created without group/other access", async () => {
212
- const rootDir = newTempRoot();
213
- const log = await openEventLog(TEST_ID, { rootDir });
214
- await log.append({ kind: "user_message", payload: { secret: "transcript" } });
215
- const mode = statSync(join(rootDir, `${TEST_ID}.jsonl`)).mode & 0o777;
216
- // No bits set for group (0o070) or other (0o007).
217
- expect(mode & 0o077).toBe(0);
218
- });
219
-
220
- // A hostile/corrupt log line carrying a `__proto__` key must round-trip as
221
- // plain data and must NOT pollute Object.prototype (CWE-1321). readEvents
222
- // only `JSON.parse`s + yields; it never merges into an existing object, so
223
- // there is no pollution sink — this guards against a future refactor adding
224
- // one.
225
- test("a __proto__ payload does not pollute Object.prototype", async () => {
226
- const rootDir = newTempRoot();
227
- const log = await openEventLog(TEST_ID, { rootDir });
228
- await log.append({ kind: "user_message", payload: {} });
229
- writeFileSync(
230
- join(rootDir, `${TEST_ID}.jsonl`),
231
- `${JSON.stringify({
232
- ts: 1,
233
- version: 1,
234
- kind: "user_message",
235
- payload: JSON.parse('{"__proto__":{"polluted":true}}'),
236
- })}\n`,
237
- );
238
- const all = await collect(log.read());
239
- expect(all.length).toBe(1);
240
- // The global prototype must remain unpolluted for everyone else.
241
- expect(({} as Record<string, unknown>)["polluted"]).toBeUndefined();
242
- expect((Object.prototype as Record<string, unknown>)["polluted"]).toBeUndefined();
243
- });
244
-
245
- // The sessionId becomes part of the on-disk path; only `sess_<16 hex>` is
246
- // accepted, so traversal / absolute-path / NUL injection in the id can never
247
- // reach the filesystem (CWE-22).
248
- test("rejects traversal, separators, and NUL in the session id", async () => {
249
- const rootDir = newTempRoot();
250
- for (const bad of [
251
- "sess_../../etc/passwd",
252
- "sess_0123456789abcde/", // 15 hex + slash
253
- "sess_0123456789abcdeg", // non-hex char
254
- "sess_0123456789ABCDEF", // uppercase hex not allowed
255
- "sess_0123456789abcdef0", // 17 hex (too long)
256
- "sess_000000000000",
257
- "../escape",
258
- ]) {
259
- await expect(openEventLog(bad, { rootDir })).rejects.toThrow(/invalid sessionId/);
260
- }
261
- });
262
- });
package/src/index.ts DELETED
@@ -1,174 +0,0 @@
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, resolve } from "node:path";
29
- import { createInterface } from "node:readline";
30
- import { RuntimeError } from "@crewhaus/errors";
31
- import { assertSamePath, currentTenantContext, requireTenant } from "@crewhaus/tenancy";
32
-
33
- export const DEFAULT_ROOT_DIR = ".crewhaus/sessions";
34
- const ID_REGEX = /^sess_[0-9a-f]{16}$/;
35
-
36
- export type EventKind =
37
- | "user_message"
38
- | "assistant_message"
39
- | "tool_use"
40
- | "tool_result"
41
- | "error"
42
- | "compaction"
43
- | "sub_agent_start"
44
- | "sub_agent_end"
45
- // Section 22 — CRW (multi-agent crew) lifecycle events. Single durable
46
- // sessionId across an entire crew run; every role's turn writes here.
47
- | "role_start"
48
- | "role_end"
49
- | "handoff"
50
- | "a2a_message"
51
- // a2a_turn_start / a2a_turn_end bracket the nested inline `runChatLoop`
52
- // an A2A peer call drives. Because every role in a crew shares one
53
- // session JSONL, the peer's `user_message` + `assistant_message`
54
- // events land in the parent's log; on a later role's `resume`,
55
- // `replayMessageHistory` uses these markers to skip the peer's nested
56
- // transcript and keep the parent's `tool_use → tool_result` pair
57
- // immediately adjacent (Claude API requires it). Symmetric to
58
- // `sub_agent_start/end` for Section-13 sub-agents.
59
- | "a2a_turn_start"
60
- | "a2a_turn_end"
61
- | "crew_done"
62
- // Section 27 — per-call cost accrual mirrored from the trace bus into the
63
- // session JSONL (opt-in via CREWHAUS_COST_TRACKING) so
64
- // `crewhaus cost-summary --session <id>` can sum spend after a run. It is not
65
- // a conversational message, so `replayMessageHistory` ignores it and resume
66
- // is unaffected.
67
- | "cost_accrual";
68
-
69
- export type Event = {
70
- readonly ts: number;
71
- readonly version: 1;
72
- readonly kind: EventKind;
73
- readonly payload: unknown;
74
- };
75
-
76
- export type AppendEvent = Pick<Event, "kind" | "payload">;
77
-
78
- export type OpenEventLogOptions = {
79
- readonly rootDir?: string;
80
- readonly now?: () => number;
81
- };
82
-
83
- export interface EventLog {
84
- append(event: AppendEvent): Promise<void>;
85
- read(opts?: { since?: number; until?: number }): AsyncIterable<Event>;
86
- close(): Promise<void>;
87
- }
88
-
89
- function validateId(sessionId: string): void {
90
- if (!ID_REGEX.test(sessionId)) {
91
- throw new RuntimeError(`event-log: invalid sessionId "${sessionId}" — expected sess_<16 hex>`);
92
- }
93
- }
94
-
95
- /**
96
- * Open (or implicitly create) the JSONL log for `sessionId`. Creates the
97
- * parent directory on demand. Subsequent `append()` calls write
98
- * synchronously to the file; `read()` opens its own read stream so it
99
- * sees a consistent snapshot of the bytes already on disk.
100
- */
101
- export async function openEventLog(
102
- sessionId: string,
103
- opts: OpenEventLogOptions = {},
104
- ): Promise<EventLog> {
105
- validateId(sessionId);
106
- const rootDir = opts.rootDir ?? DEFAULT_ROOT_DIR;
107
- const now = opts.now ?? (() => Date.now());
108
- const fullPath = resolve(rootDir, `${sessionId}.jsonl`);
109
- mkdirSync(rootDir, { recursive: true });
110
-
111
- // When a tenant context is active, fail closed on a resolved path that
112
- // escapes the tenant's sessionRoot (CWE-1230). Outside a tenant scope (the
113
- // common CLI case) this is a no-op so non-tenant behaviour is unchanged.
114
- function fence(): void {
115
- if (currentTenantContext() !== undefined) {
116
- assertSamePath(fullPath, requireTenant().sessionRoot);
117
- }
118
- }
119
- fence();
120
-
121
- return {
122
- async append(event: AppendEvent): Promise<void> {
123
- fence();
124
- const wire: Event = {
125
- ts: now(),
126
- version: 1,
127
- kind: event.kind,
128
- payload: event.payload,
129
- };
130
- const line = `${JSON.stringify(wire)}\n`;
131
- appendFileSync(fullPath, line, { mode: 0o600 });
132
- },
133
-
134
- read(readOpts: { since?: number; until?: number } = {}): AsyncIterable<Event> {
135
- fence();
136
- return readEvents(fullPath, readOpts);
137
- },
138
-
139
- async close(): Promise<void> {
140
- // No persistent handle today; reserved for a future buffered writer.
141
- },
142
- };
143
- }
144
-
145
- async function* readEvents(
146
- fullPath: string,
147
- opts: { since?: number; until?: number },
148
- ): AsyncIterable<Event> {
149
- if (!existsSync(fullPath)) return;
150
- const stream = createReadStream(fullPath, { encoding: "utf8" });
151
- const rl = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
152
- let lineNumber = 0;
153
- try {
154
- for await (const raw of rl) {
155
- lineNumber += 1;
156
- if (raw === "") continue;
157
- let parsed: Event;
158
- try {
159
- parsed = JSON.parse(raw) as Event;
160
- } catch (err) {
161
- throw new RuntimeError(
162
- `event-log: malformed JSON on line ${lineNumber} of ${fullPath}`,
163
- err,
164
- );
165
- }
166
- if (opts.since !== undefined && parsed.ts < opts.since) continue;
167
- if (opts.until !== undefined && parsed.ts > opts.until) continue;
168
- yield parsed;
169
- }
170
- } finally {
171
- rl.close();
172
- stream.close();
173
- }
174
- }