@crewhaus/event-log 0.1.3 → 0.1.5
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/dist/index.d.ts +28 -0
- package/dist/index.js +110 -0
- package/package.json +10 -7
- package/src/index.test.ts +0 -262
- package/src/index.ts +0 -168
package/dist/index.d.ts
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Append-only JSONL transcript log per session",
|
|
6
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
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.
|
|
16
|
-
"@crewhaus/tenancy": "0.1.
|
|
18
|
+
"@crewhaus/errors": "0.1.5",
|
|
19
|
+
"@crewhaus/tenancy": "0.1.5"
|
|
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": ["
|
|
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,168 +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
|
-
|
|
63
|
-
export type Event = {
|
|
64
|
-
readonly ts: number;
|
|
65
|
-
readonly version: 1;
|
|
66
|
-
readonly kind: EventKind;
|
|
67
|
-
readonly payload: unknown;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
export type AppendEvent = Pick<Event, "kind" | "payload">;
|
|
71
|
-
|
|
72
|
-
export type OpenEventLogOptions = {
|
|
73
|
-
readonly rootDir?: string;
|
|
74
|
-
readonly now?: () => number;
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
export interface EventLog {
|
|
78
|
-
append(event: AppendEvent): Promise<void>;
|
|
79
|
-
read(opts?: { since?: number; until?: number }): AsyncIterable<Event>;
|
|
80
|
-
close(): Promise<void>;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function validateId(sessionId: string): void {
|
|
84
|
-
if (!ID_REGEX.test(sessionId)) {
|
|
85
|
-
throw new RuntimeError(`event-log: invalid sessionId "${sessionId}" — expected sess_<16 hex>`);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Open (or implicitly create) the JSONL log for `sessionId`. Creates the
|
|
91
|
-
* parent directory on demand. Subsequent `append()` calls write
|
|
92
|
-
* synchronously to the file; `read()` opens its own read stream so it
|
|
93
|
-
* sees a consistent snapshot of the bytes already on disk.
|
|
94
|
-
*/
|
|
95
|
-
export async function openEventLog(
|
|
96
|
-
sessionId: string,
|
|
97
|
-
opts: OpenEventLogOptions = {},
|
|
98
|
-
): Promise<EventLog> {
|
|
99
|
-
validateId(sessionId);
|
|
100
|
-
const rootDir = opts.rootDir ?? DEFAULT_ROOT_DIR;
|
|
101
|
-
const now = opts.now ?? (() => Date.now());
|
|
102
|
-
const fullPath = resolve(rootDir, `${sessionId}.jsonl`);
|
|
103
|
-
mkdirSync(rootDir, { recursive: true });
|
|
104
|
-
|
|
105
|
-
// When a tenant context is active, fail closed on a resolved path that
|
|
106
|
-
// escapes the tenant's sessionRoot (CWE-1230). Outside a tenant scope (the
|
|
107
|
-
// common CLI case) this is a no-op so non-tenant behaviour is unchanged.
|
|
108
|
-
function fence(): void {
|
|
109
|
-
if (currentTenantContext() !== undefined) {
|
|
110
|
-
assertSamePath(fullPath, requireTenant().sessionRoot);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
fence();
|
|
114
|
-
|
|
115
|
-
return {
|
|
116
|
-
async append(event: AppendEvent): Promise<void> {
|
|
117
|
-
fence();
|
|
118
|
-
const wire: Event = {
|
|
119
|
-
ts: now(),
|
|
120
|
-
version: 1,
|
|
121
|
-
kind: event.kind,
|
|
122
|
-
payload: event.payload,
|
|
123
|
-
};
|
|
124
|
-
const line = `${JSON.stringify(wire)}\n`;
|
|
125
|
-
appendFileSync(fullPath, line, { mode: 0o600 });
|
|
126
|
-
},
|
|
127
|
-
|
|
128
|
-
read(readOpts: { since?: number; until?: number } = {}): AsyncIterable<Event> {
|
|
129
|
-
fence();
|
|
130
|
-
return readEvents(fullPath, readOpts);
|
|
131
|
-
},
|
|
132
|
-
|
|
133
|
-
async close(): Promise<void> {
|
|
134
|
-
// No persistent handle today; reserved for a future buffered writer.
|
|
135
|
-
},
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
async function* readEvents(
|
|
140
|
-
fullPath: string,
|
|
141
|
-
opts: { since?: number; until?: number },
|
|
142
|
-
): AsyncIterable<Event> {
|
|
143
|
-
if (!existsSync(fullPath)) return;
|
|
144
|
-
const stream = createReadStream(fullPath, { encoding: "utf8" });
|
|
145
|
-
const rl = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
|
|
146
|
-
let lineNumber = 0;
|
|
147
|
-
try {
|
|
148
|
-
for await (const raw of rl) {
|
|
149
|
-
lineNumber += 1;
|
|
150
|
-
if (raw === "") continue;
|
|
151
|
-
let parsed: Event;
|
|
152
|
-
try {
|
|
153
|
-
parsed = JSON.parse(raw) as Event;
|
|
154
|
-
} catch (err) {
|
|
155
|
-
throw new RuntimeError(
|
|
156
|
-
`event-log: malformed JSON on line ${lineNumber} of ${fullPath}`,
|
|
157
|
-
err,
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
if (opts.since !== undefined && parsed.ts < opts.since) continue;
|
|
161
|
-
if (opts.until !== undefined && parsed.ts > opts.until) continue;
|
|
162
|
-
yield parsed;
|
|
163
|
-
}
|
|
164
|
-
} finally {
|
|
165
|
-
rl.close();
|
|
166
|
-
stream.close();
|
|
167
|
-
}
|
|
168
|
-
}
|