chapterhouse 0.3.9 → 0.3.11

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,298 @@
1
+ /**
2
+ * Per-turn in-memory event ring buffer with session-level fan-out — #130.
3
+ *
4
+ * Architecture decisions (from Mal #129):
5
+ * - Events accumulate in a per-turn ring buffer (200 events) during the turn.
6
+ * - A parallel per-session ring buffer (200 events) feeds the SSE reconnect path.
7
+ * - On turn completion, events are persisted to the `turn_events` SQLite table.
8
+ * - The per-turn buffer is cleared after a 30 s grace window so late reconnects
9
+ * can still replay from memory before SQLite is needed.
10
+ * - After the grace window, SSE replay falls back to SQLite.
11
+ *
12
+ * Public API surface:
13
+ * - `emitTurnEvent(sessionKey, event)` — emit an event (called from orchestrator.ts)
14
+ * - `subscribeTurn(turnId, listener)` — per-turn subscribe with immediate replay
15
+ * - `subscribeSession(sessionKey, listener, afterSeq?)` — session SSE subscribe + replay
16
+ * - `persistTurnEvents(turnId, sessionKey)` — write ring buffer to SQLite
17
+ * - `scheduleClearTurnLog(turnId)` — schedule buffer clear after grace window
18
+ * - `getSessionEventsFromDb(sessionKey, afterSeq)` — SQLite fallback for completed turns
19
+ *
20
+ * @module copilot/turn-event-log
21
+ */
22
+ import { childLogger } from "../util/logger.js";
23
+ import { getDb } from "../store/db.js";
24
+ import { RingBuffer } from "./ring-buffer.js";
25
+ const log = childLogger("turn-event-log");
26
+ // ---------------------------------------------------------------------------
27
+ // Capacity constants
28
+ // ---------------------------------------------------------------------------
29
+ /** Events retained per turn in memory (covers long turns with many tool calls). */
30
+ export const TURN_BUFFER_CAPACITY = 200;
31
+ /** Recent events retained per session for SSE reconnect replay. */
32
+ export const SESSION_BUFFER_CAPACITY = 200;
33
+ /** Grace window before per-turn buffer is cleared after turn completion (ms). */
34
+ export const TURN_CLEAR_GRACE_MS = 30_000;
35
+ // ---------------------------------------------------------------------------
36
+ // Singleton state
37
+ // ---------------------------------------------------------------------------
38
+ /** Per-turn ring buffer + associated session key. */
39
+ const turnBuffers = new Map();
40
+ /** Per-turn live listeners (for subscribeTurn()). */
41
+ const turnListeners = new Map();
42
+ /** Per-session ring buffer for SSE reconnect replay (sliding window across turns). */
43
+ const sessionBuffers = new Map();
44
+ /** Per-session live listeners (SSE connections). */
45
+ const sessionListeners = new Map();
46
+ /** Pending clear-buffer timers keyed by turnId. */
47
+ const clearTimers = new Map();
48
+ /** Monotonic global sequence counter — used as SSE `id:` for Last-Event-ID replay. */
49
+ let globalSeq = 0;
50
+ // ---------------------------------------------------------------------------
51
+ // Emit
52
+ // ---------------------------------------------------------------------------
53
+ /**
54
+ * Emit a turn event. Updates both the per-turn ring buffer and the per-session
55
+ * sliding buffer, then notifies all live listeners.
56
+ *
57
+ * Called from orchestrator.ts alongside the existing callback path.
58
+ */
59
+ export function emitTurnEvent(sessionKey, event) {
60
+ const indexed = { ...event, _seq: ++globalSeq, _ts: Date.now() };
61
+ // Per-turn buffer --------------------------------------------------------
62
+ const turnId = event.turnId;
63
+ let entry = turnBuffers.get(turnId);
64
+ if (!entry) {
65
+ entry = { buf: new RingBuffer(TURN_BUFFER_CAPACITY), sessionKey };
66
+ turnBuffers.set(turnId, entry);
67
+ }
68
+ entry.buf.push(indexed);
69
+ // Per-session buffer (sliding window across turns) -----------------------
70
+ let sessionBuf = sessionBuffers.get(sessionKey);
71
+ if (!sessionBuf) {
72
+ sessionBuf = new RingBuffer(SESSION_BUFFER_CAPACITY);
73
+ sessionBuffers.set(sessionKey, sessionBuf);
74
+ }
75
+ sessionBuf.push(indexed);
76
+ // Notify per-turn listeners ----------------------------------------------
77
+ const tListeners = turnListeners.get(turnId);
78
+ if (tListeners) {
79
+ for (const fn of tListeners) {
80
+ try {
81
+ fn(indexed);
82
+ }
83
+ catch { /* non-fatal */ }
84
+ }
85
+ }
86
+ // Notify per-session listeners (SSE connections) -------------------------
87
+ const sListeners = sessionListeners.get(sessionKey);
88
+ if (sListeners) {
89
+ for (const fn of sListeners) {
90
+ try {
91
+ fn(indexed);
92
+ }
93
+ catch { /* non-fatal */ }
94
+ }
95
+ }
96
+ }
97
+ // ---------------------------------------------------------------------------
98
+ // Subscribe — per-turn
99
+ // ---------------------------------------------------------------------------
100
+ /**
101
+ * Subscribe to live events for a specific turn.
102
+ *
103
+ * Immediately delivers all buffered events for this turn to `listener`
104
+ * (replay-on-subscribe), then delivers live events until the returned
105
+ * unsubscribe function is called.
106
+ *
107
+ * Returns `undefined` rather than calling listener if the turn is unknown
108
+ * and there are no buffered events — the caller is responsible for falling
109
+ * back to SQLite if needed.
110
+ */
111
+ export function subscribeTurn(turnId, listener) {
112
+ // Replay buffered events immediately
113
+ const entry = turnBuffers.get(turnId);
114
+ if (entry) {
115
+ for (const e of entry.buf.getAll()) {
116
+ try {
117
+ listener(e);
118
+ }
119
+ catch { /* non-fatal */ }
120
+ }
121
+ }
122
+ // Register live listener
123
+ let ls = turnListeners.get(turnId);
124
+ if (!ls) {
125
+ ls = new Set();
126
+ turnListeners.set(turnId, ls);
127
+ }
128
+ ls.add(listener);
129
+ return () => {
130
+ const set = turnListeners.get(turnId);
131
+ if (set) {
132
+ set.delete(listener);
133
+ if (set.size === 0)
134
+ turnListeners.delete(turnId);
135
+ }
136
+ };
137
+ }
138
+ // ---------------------------------------------------------------------------
139
+ // Subscribe — per-session (SSE)
140
+ // ---------------------------------------------------------------------------
141
+ /**
142
+ * Subscribe to all turn events for a session (multi-tab SSE channel).
143
+ *
144
+ * If `afterSeq` is provided, replays only buffered events with `_seq > afterSeq`
145
+ * (supports `Last-Event-ID` reconnect). Otherwise replays all buffered events.
146
+ *
147
+ * If the buffer doesn't cover the requested range (oldest buffered seq > afterSeq + 1),
148
+ * the caller should fall back to `getSessionEventsFromDb()` before calling this.
149
+ *
150
+ * Returns an unsubscribe function.
151
+ */
152
+ export function subscribeSession(sessionKey, listener, afterSeq) {
153
+ // Replay from session buffer
154
+ const buf = sessionBuffers.get(sessionKey);
155
+ if (buf) {
156
+ const all = buf.getAll();
157
+ const toReplay = afterSeq !== undefined ? all.filter((e) => e._seq > afterSeq) : all;
158
+ for (const e of toReplay) {
159
+ try {
160
+ listener(e);
161
+ }
162
+ catch { /* non-fatal */ }
163
+ }
164
+ }
165
+ // Register live listener
166
+ let ls = sessionListeners.get(sessionKey);
167
+ if (!ls) {
168
+ ls = new Set();
169
+ sessionListeners.set(sessionKey, ls);
170
+ }
171
+ ls.add(listener);
172
+ return () => {
173
+ const set = sessionListeners.get(sessionKey);
174
+ if (set) {
175
+ set.delete(listener);
176
+ if (set.size === 0)
177
+ sessionListeners.delete(sessionKey);
178
+ }
179
+ };
180
+ }
181
+ // ---------------------------------------------------------------------------
182
+ // Persistence
183
+ // ---------------------------------------------------------------------------
184
+ /**
185
+ * Persist the turn's buffered events to the `turn_events` SQLite table.
186
+ * Called on `turn:complete` and `turn:error` before scheduling the clear.
187
+ * No-ops silently if the turn has no buffered events.
188
+ */
189
+ export function persistTurnEvents(turnId, sessionKey) {
190
+ const entry = turnBuffers.get(turnId);
191
+ if (!entry)
192
+ return;
193
+ const events = entry.buf.getAll();
194
+ if (events.length === 0)
195
+ return;
196
+ try {
197
+ const db = getDb();
198
+ const stmt = db.prepare(`INSERT OR IGNORE INTO turn_events (turn_id, session_key, seq, ts, event_type, payload)
199
+ VALUES (?, ?, ?, ?, ?, ?)`);
200
+ db.transaction(() => {
201
+ for (const e of events) {
202
+ stmt.run(turnId, sessionKey, e._seq, e._ts, e.type, JSON.stringify(e));
203
+ }
204
+ })();
205
+ }
206
+ catch (err) {
207
+ log.warn({ err: err instanceof Error ? err.message : err, turnId }, "turn-event-log: SQLite persist failed");
208
+ }
209
+ }
210
+ // ---------------------------------------------------------------------------
211
+ // Lifecycle — clear
212
+ // ---------------------------------------------------------------------------
213
+ /**
214
+ * Schedule clearing of the per-turn ring buffer and listeners after `delayMs`.
215
+ * Any existing pending timer for this turnId is cancelled first.
216
+ *
217
+ * Defaults to TURN_CLEAR_GRACE_MS (30 s) to allow late reconnects to replay
218
+ * from memory rather than SQLite.
219
+ */
220
+ export function scheduleClearTurnLog(turnId, delayMs = TURN_CLEAR_GRACE_MS) {
221
+ const existing = clearTimers.get(turnId);
222
+ if (existing)
223
+ clearTimeout(existing);
224
+ const timer = setTimeout(() => {
225
+ turnBuffers.delete(turnId);
226
+ turnListeners.delete(turnId);
227
+ clearTimers.delete(turnId);
228
+ log.debug({ turnId }, "turn-event-log: buffer cleared after grace window");
229
+ }, delayMs);
230
+ clearTimers.set(turnId, timer);
231
+ }
232
+ /**
233
+ * Immediately clear the per-turn buffer without waiting for the grace window.
234
+ * Used in tests and explicit eviction.
235
+ */
236
+ export function clearTurnLog(turnId) {
237
+ const existing = clearTimers.get(turnId);
238
+ if (existing)
239
+ clearTimeout(existing);
240
+ clearTimers.delete(turnId);
241
+ turnBuffers.delete(turnId);
242
+ turnListeners.delete(turnId);
243
+ }
244
+ // ---------------------------------------------------------------------------
245
+ // SQLite read-back (fallback for completed turns)
246
+ // ---------------------------------------------------------------------------
247
+ /**
248
+ * Return persisted events for a specific turn from SQLite.
249
+ * Used when the in-memory buffer has been cleared after the grace window.
250
+ */
251
+ export function getTurnEventsFromDb(turnId, afterSeq = 0) {
252
+ try {
253
+ const db = getDb();
254
+ const rows = db
255
+ .prepare(`SELECT payload FROM turn_events WHERE turn_id = ? AND seq > ? ORDER BY seq ASC`)
256
+ .all(turnId, afterSeq);
257
+ return rows.map((r) => JSON.parse(r.payload));
258
+ }
259
+ catch (err) {
260
+ log.warn({ err: err instanceof Error ? err.message : err, turnId }, "turn-event-log: SQLite read failed");
261
+ return [];
262
+ }
263
+ }
264
+ /**
265
+ * Return persisted events for a session from SQLite, after a given sequence number.
266
+ * Used as SSE replay fallback when the session buffer doesn't cover the requested range.
267
+ */
268
+ export function getSessionEventsFromDb(sessionKey, afterSeq = 0) {
269
+ try {
270
+ const db = getDb();
271
+ const rows = db
272
+ .prepare(`SELECT payload FROM turn_events
273
+ WHERE session_key = ? AND seq > ?
274
+ ORDER BY seq ASC
275
+ LIMIT 500`)
276
+ .all(sessionKey, afterSeq);
277
+ return rows.map((r) => JSON.parse(r.payload));
278
+ }
279
+ catch (err) {
280
+ log.warn({ err: err instanceof Error ? err.message : err, sessionKey }, "turn-event-log: SQLite session read failed");
281
+ return [];
282
+ }
283
+ }
284
+ // ---------------------------------------------------------------------------
285
+ // Diagnostics
286
+ // ---------------------------------------------------------------------------
287
+ /** Number of turns currently tracked in memory. Useful for tests. */
288
+ export function turnLogSize() {
289
+ return turnBuffers.size;
290
+ }
291
+ /** Oldest _seq in the per-session buffer, or undefined if empty. */
292
+ export function oldestSessionSeq(sessionKey) {
293
+ const buf = sessionBuffers.get(sessionKey);
294
+ if (!buf || buf.size === 0)
295
+ return undefined;
296
+ return buf.getAll()[0]._seq;
297
+ }
298
+ //# sourceMappingURL=turn-event-log.js.map
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Unit tests for src/copilot/turn-event-log.ts — #130
3
+ *
4
+ * Covers:
5
+ * 1. emitTurnEvent: pushes to per-turn buffer
6
+ * 2. emitTurnEvent: pushes to per-session buffer
7
+ * 3. emitTurnEvent: notifies per-turn listeners
8
+ * 4. emitTurnEvent: notifies per-session listeners
9
+ * 5. subscribeTurn: replays buffered events immediately
10
+ * 6. subscribeTurn: delivers live events after subscribe
11
+ * 7. subscribeTurn: unsub stops delivery
12
+ * 8. subscribeSession: replays all buffered events
13
+ * 9. subscribeSession: replays only afterSeq events on reconnect
14
+ * 10. subscribeSession: delivers live events after subscribe
15
+ * 11. subscribeSession: unsub stops delivery
16
+ * 12. scheduleClearTurnLog: clears buffer after delay
17
+ * 13. clearTurnLog: immediate clear
18
+ * 14. Multiple sessions tracked independently
19
+ * 15. turnLogSize: reflects active buffers
20
+ *
21
+ * Uses node:test (same pattern as existing *.test.ts files in this project).
22
+ * SQLite-dependent functions (persistTurnEvents, getSessionEventsFromDb) are
23
+ * tested in the integration suite to avoid needing a real DB here.
24
+ */
25
+ import { describe, it, afterEach } from "node:test";
26
+ import assert from "node:assert/strict";
27
+ import { setTimeout as setTimeoutPromise } from "node:timers/promises";
28
+ import { emitTurnEvent, subscribeTurn, subscribeSession, clearTurnLog, scheduleClearTurnLog, turnLogSize, oldestSessionSeq, TURN_BUFFER_CAPACITY, SESSION_BUFFER_CAPACITY, } from "./turn-event-log.js";
29
+ // ---------------------------------------------------------------------------
30
+ // Helpers
31
+ // ---------------------------------------------------------------------------
32
+ let turnCounter = 0;
33
+ let sessionCounter = 0;
34
+ function freshTurnId() {
35
+ return `turn-test-${++turnCounter}-${Date.now()}`;
36
+ }
37
+ function freshSessionKey() {
38
+ return `session-test-${++sessionCounter}-${Date.now()}`;
39
+ }
40
+ function makeStarted(turnId, sessionKey) {
41
+ return { type: "turn:started", turnId, sessionKey, prompt: "hello" };
42
+ }
43
+ function makeDelta(turnId, sessionKey, text) {
44
+ return { type: "turn:delta", turnId, sessionKey, part: { type: "text", text } };
45
+ }
46
+ function makeComplete(turnId, sessionKey) {
47
+ return { type: "turn:complete", turnId, sessionKey, finalMessage: "done" };
48
+ }
49
+ // ---------------------------------------------------------------------------
50
+ // Teardown: clear each turn/session used in a test to avoid cross-test pollution
51
+ // ---------------------------------------------------------------------------
52
+ const usedTurnIds = [];
53
+ const registeredUnsubs = [];
54
+ afterEach(() => {
55
+ // Clean up registered subscriptions
56
+ for (const fn of registeredUnsubs.splice(0)) {
57
+ try {
58
+ fn();
59
+ }
60
+ catch { /* already unsub'd */ }
61
+ }
62
+ // Clear turn buffers
63
+ for (const id of usedTurnIds.splice(0)) {
64
+ clearTurnLog(id);
65
+ }
66
+ });
67
+ function trackTurn(turnId) {
68
+ usedTurnIds.push(turnId);
69
+ return turnId;
70
+ }
71
+ function trackUnsub(fn) {
72
+ registeredUnsubs.push(fn);
73
+ return fn;
74
+ }
75
+ // ---------------------------------------------------------------------------
76
+ // Tests
77
+ // ---------------------------------------------------------------------------
78
+ describe("turn-event-log", () => {
79
+ describe("emitTurnEvent", () => {
80
+ it("assigns a monotonically increasing _seq", () => {
81
+ const session = freshSessionKey();
82
+ const turn1 = trackTurn(freshTurnId());
83
+ const turn2 = trackTurn(freshTurnId());
84
+ const collected = [];
85
+ trackUnsub(subscribeSession(session, (e) => collected.push(e)));
86
+ emitTurnEvent(session, makeStarted(turn1, session));
87
+ emitTurnEvent(session, makeStarted(turn2, session));
88
+ assert.equal(collected.length, 2);
89
+ assert.ok(collected[0]._seq < collected[1]._seq, "_seq must be strictly increasing");
90
+ });
91
+ it("attaches a _ts timestamp (ms epoch)", () => {
92
+ const before = Date.now();
93
+ const session = freshSessionKey();
94
+ const turnId = trackTurn(freshTurnId());
95
+ const collected = [];
96
+ trackUnsub(subscribeSession(session, (e) => collected.push(e)));
97
+ emitTurnEvent(session, makeStarted(turnId, session));
98
+ const after = Date.now();
99
+ assert.ok(collected[0]._ts >= before && collected[0]._ts <= after);
100
+ });
101
+ });
102
+ describe("subscribeTurn", () => {
103
+ it("replays existing buffered events immediately on subscribe", () => {
104
+ const session = freshSessionKey();
105
+ const turnId = trackTurn(freshTurnId());
106
+ emitTurnEvent(session, makeStarted(turnId, session));
107
+ emitTurnEvent(session, makeDelta(turnId, session, "chunk1"));
108
+ const received = [];
109
+ trackUnsub(subscribeTurn(turnId, (e) => received.push(e)));
110
+ assert.equal(received.length, 2, "should replay 2 buffered events immediately");
111
+ assert.equal(received[0].type, "turn:started");
112
+ assert.equal(received[1].type, "turn:delta");
113
+ });
114
+ it("delivers live events after subscribe", () => {
115
+ const session = freshSessionKey();
116
+ const turnId = trackTurn(freshTurnId());
117
+ const received = [];
118
+ trackUnsub(subscribeTurn(turnId, (e) => received.push(e)));
119
+ emitTurnEvent(session, makeStarted(turnId, session));
120
+ emitTurnEvent(session, makeDelta(turnId, session, "live"));
121
+ assert.equal(received.length, 2);
122
+ });
123
+ it("replay + live: receives buffered then subsequent events", () => {
124
+ const session = freshSessionKey();
125
+ const turnId = trackTurn(freshTurnId());
126
+ emitTurnEvent(session, makeStarted(turnId, session));
127
+ const received = [];
128
+ trackUnsub(subscribeTurn(turnId, (e) => received.push(e)));
129
+ // 1 replayed
130
+ assert.equal(received.length, 1);
131
+ emitTurnEvent(session, makeDelta(turnId, session, "live-delta"));
132
+ // 1 replayed + 1 live
133
+ assert.equal(received.length, 2);
134
+ assert.equal(received[1].type, "turn:delta");
135
+ });
136
+ it("unsubscribe stops live delivery", () => {
137
+ const session = freshSessionKey();
138
+ const turnId = trackTurn(freshTurnId());
139
+ const received = [];
140
+ const unsub = subscribeTurn(turnId, (e) => received.push(e));
141
+ emitTurnEvent(session, makeStarted(turnId, session));
142
+ assert.equal(received.length, 1);
143
+ unsub(); // stop listening
144
+ emitTurnEvent(session, makeDelta(turnId, session, "after-unsub"));
145
+ assert.equal(received.length, 1, "should not receive events after unsub");
146
+ });
147
+ it("multiple subscribers to the same turn all receive events", () => {
148
+ const session = freshSessionKey();
149
+ const turnId = trackTurn(freshTurnId());
150
+ const a = [];
151
+ const b = [];
152
+ trackUnsub(subscribeTurn(turnId, (e) => a.push(e)));
153
+ trackUnsub(subscribeTurn(turnId, (e) => b.push(e)));
154
+ emitTurnEvent(session, makeStarted(turnId, session));
155
+ assert.equal(a.length, 1);
156
+ assert.equal(b.length, 1);
157
+ });
158
+ });
159
+ describe("subscribeSession", () => {
160
+ it("replays all buffered session events on subscribe (no afterSeq)", () => {
161
+ const session = freshSessionKey();
162
+ const turn1 = trackTurn(freshTurnId());
163
+ const turn2 = trackTurn(freshTurnId());
164
+ emitTurnEvent(session, makeStarted(turn1, session));
165
+ emitTurnEvent(session, makeComplete(turn2, session));
166
+ const received = [];
167
+ trackUnsub(subscribeSession(session, (e) => received.push(e)));
168
+ assert.equal(received.length, 2);
169
+ });
170
+ it("replays only events after afterSeq on reconnect", () => {
171
+ const session = freshSessionKey();
172
+ const turnId = trackTurn(freshTurnId());
173
+ emitTurnEvent(session, makeStarted(turnId, session));
174
+ const seqAfterFirst = oldestSessionSeq(session) + 1; // seqAfterFirst > first event's seq
175
+ emitTurnEvent(session, makeDelta(turnId, session, "second"));
176
+ // Collect first subscribe (simulates initial connection)
177
+ const first = [];
178
+ const unsub1 = subscribeSession(session, (e) => first.push(e));
179
+ unsub1();
180
+ // Remember the seq of the last event we saw
181
+ const lastSeenSeq = first[first.length - 1]._seq;
182
+ emitTurnEvent(session, makeDelta(turnId, session, "third"));
183
+ // Reconnect with Last-Event-ID = lastSeenSeq
184
+ const reconnect = [];
185
+ trackUnsub(subscribeSession(session, (e) => reconnect.push(e), lastSeenSeq));
186
+ // Should only replay the "third" event (emitted after lastSeenSeq)
187
+ assert.ok(reconnect.every((e) => e._seq > lastSeenSeq), "replayed events must all have _seq > lastSeenSeq");
188
+ assert.equal(reconnect.length, 1);
189
+ assert.equal(reconnect[0].part.text, "third");
190
+ void seqAfterFirst; // suppress unused var warning
191
+ });
192
+ it("delivers live events after subscribe", () => {
193
+ const session = freshSessionKey();
194
+ const turnId = trackTurn(freshTurnId());
195
+ const received = [];
196
+ trackUnsub(subscribeSession(session, (e) => received.push(e)));
197
+ emitTurnEvent(session, makeStarted(turnId, session));
198
+ assert.equal(received.length, 1);
199
+ });
200
+ it("unsubscribe stops delivery", () => {
201
+ const session = freshSessionKey();
202
+ const turnId = trackTurn(freshTurnId());
203
+ const received = [];
204
+ const unsub = subscribeSession(session, (e) => received.push(e));
205
+ emitTurnEvent(session, makeStarted(turnId, session));
206
+ assert.equal(received.length, 1);
207
+ unsub();
208
+ emitTurnEvent(session, makeComplete(turnId, session));
209
+ assert.equal(received.length, 1);
210
+ });
211
+ it("two sessions are isolated from each other", () => {
212
+ const session1 = freshSessionKey();
213
+ const session2 = freshSessionKey();
214
+ const turn1 = trackTurn(freshTurnId());
215
+ const turn2 = trackTurn(freshTurnId());
216
+ const recv1 = [];
217
+ const recv2 = [];
218
+ trackUnsub(subscribeSession(session1, (e) => recv1.push(e)));
219
+ trackUnsub(subscribeSession(session2, (e) => recv2.push(e)));
220
+ emitTurnEvent(session1, makeStarted(turn1, session1));
221
+ emitTurnEvent(session2, makeStarted(turn2, session2));
222
+ assert.equal(recv1.length, 1);
223
+ assert.equal(recv2.length, 1);
224
+ assert.equal(recv1[0].turnId, turn1);
225
+ assert.equal(recv2[0].turnId, turn2);
226
+ });
227
+ });
228
+ describe("clearTurnLog", () => {
229
+ it("removes the per-turn buffer immediately", () => {
230
+ const session = freshSessionKey();
231
+ const turnId = trackTurn(freshTurnId());
232
+ emitTurnEvent(session, makeStarted(turnId, session));
233
+ clearTurnLog(turnId);
234
+ // Subscribing after clear should receive no replayed events
235
+ const received = [];
236
+ trackUnsub(subscribeTurn(turnId, (e) => received.push(e)));
237
+ assert.equal(received.length, 0, "buffer should be empty after clearTurnLog");
238
+ });
239
+ it("does not affect other turns", () => {
240
+ const session = freshSessionKey();
241
+ const turn1 = trackTurn(freshTurnId());
242
+ const turn2 = trackTurn(freshTurnId());
243
+ emitTurnEvent(session, makeStarted(turn1, session));
244
+ emitTurnEvent(session, makeStarted(turn2, session));
245
+ clearTurnLog(turn1);
246
+ const received = [];
247
+ trackUnsub(subscribeTurn(turn2, (e) => received.push(e)));
248
+ assert.equal(received.length, 1, "turn2 buffer should be unaffected");
249
+ });
250
+ });
251
+ describe("scheduleClearTurnLog", () => {
252
+ it("clears the buffer after the specified delay", async () => {
253
+ const session = freshSessionKey();
254
+ const turnId = trackTurn(freshTurnId());
255
+ emitTurnEvent(session, makeStarted(turnId, session));
256
+ scheduleClearTurnLog(turnId, 50); // 50 ms delay for testing
257
+ // Before delay: buffer still populated
258
+ {
259
+ const received = [];
260
+ const unsub = subscribeTurn(turnId, (e) => received.push(e));
261
+ unsub();
262
+ assert.equal(received.length, 1, "buffer should exist before grace window");
263
+ }
264
+ await setTimeoutPromise(100); // wait for the grace window
265
+ // After delay: buffer cleared
266
+ {
267
+ const received = [];
268
+ trackUnsub(subscribeTurn(turnId, (e) => received.push(e)));
269
+ assert.equal(received.length, 0, "buffer should be cleared after grace window");
270
+ }
271
+ });
272
+ it("cancels a previous pending timer when called twice", async () => {
273
+ const session = freshSessionKey();
274
+ const turnId = trackTurn(freshTurnId());
275
+ emitTurnEvent(session, makeStarted(turnId, session));
276
+ scheduleClearTurnLog(turnId, 30);
277
+ scheduleClearTurnLog(turnId, 200); // reset to longer delay
278
+ await setTimeoutPromise(60); // first timer would have fired
279
+ // Buffer should still exist because we reset the timer
280
+ {
281
+ const received = [];
282
+ const unsub = subscribeTurn(turnId, (e) => received.push(e));
283
+ unsub();
284
+ assert.equal(received.length, 1, "buffer should not be cleared yet (timer was reset)");
285
+ }
286
+ // Clean up before test ends
287
+ clearTurnLog(turnId);
288
+ });
289
+ });
290
+ describe("turnLogSize", () => {
291
+ it("reflects the number of active turn buffers", () => {
292
+ const session = freshSessionKey();
293
+ const before = turnLogSize();
294
+ const turnId = trackTurn(freshTurnId());
295
+ emitTurnEvent(session, makeStarted(turnId, session));
296
+ assert.ok(turnLogSize() >= before + 1, "size should increase after emit");
297
+ clearTurnLog(turnId);
298
+ assert.ok(turnLogSize() <= before, "size should decrease after clear");
299
+ });
300
+ });
301
+ describe("capacity", () => {
302
+ it("session buffer evicts oldest when SESSION_BUFFER_CAPACITY is reached", () => {
303
+ const session = freshSessionKey();
304
+ const turnId = trackTurn(freshTurnId());
305
+ // Overfill the session buffer
306
+ for (let i = 0; i < SESSION_BUFFER_CAPACITY + 10; i++) {
307
+ emitTurnEvent(session, makeDelta(turnId, session, `chunk-${i}`));
308
+ }
309
+ const received = [];
310
+ trackUnsub(subscribeSession(session, (e) => received.push(e)));
311
+ // Should only have SESSION_BUFFER_CAPACITY items (oldest evicted)
312
+ assert.equal(received.length, SESSION_BUFFER_CAPACITY);
313
+ });
314
+ it("turn buffer evicts oldest when TURN_BUFFER_CAPACITY is reached", () => {
315
+ const session = freshSessionKey();
316
+ const turnId = trackTurn(freshTurnId());
317
+ for (let i = 0; i < TURN_BUFFER_CAPACITY + 5; i++) {
318
+ emitTurnEvent(session, makeDelta(turnId, session, `chunk-${i}`));
319
+ }
320
+ const received = [];
321
+ trackUnsub(subscribeTurn(turnId, (e) => received.push(e)));
322
+ assert.equal(received.length, TURN_BUFFER_CAPACITY);
323
+ });
324
+ });
325
+ });
326
+ //# sourceMappingURL=turn-event-log.test.js.map
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Turn event types for the per-session SSE channel (#130).
3
+ *
4
+ * These mirror the `Part` discriminated union from `web/src/api-schemas.ts` for use
5
+ * on the daemon side without a cross-package import. Keep in sync when the frontend
6
+ * type changes.
7
+ *
8
+ * @module copilot/turn-events
9
+ */
10
+ export {};
11
+ //# sourceMappingURL=turn-events.js.map
package/dist/store/db.js CHANGED
@@ -175,6 +175,21 @@ export function getDb() {
175
175
  // Backfill from loaded_at so sidebar is not empty for existing projects
176
176
  db.exec(`UPDATE project_squads SET last_used_at = CAST((julianday(loaded_at) - 2440587.5) * 86400000 AS INTEGER) WHERE last_used_at IS NULL`);
177
177
  }
178
+ // turn_events: append-only per-turn event log for the SSE chat channel (#130).
179
+ // Events are written on turn completion; ring buffer serves live/recent replay.
180
+ db.exec(`
181
+ CREATE TABLE IF NOT EXISTS turn_events (
182
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
183
+ turn_id TEXT NOT NULL,
184
+ session_key TEXT NOT NULL DEFAULT 'default',
185
+ seq INTEGER NOT NULL,
186
+ ts INTEGER NOT NULL,
187
+ event_type TEXT NOT NULL,
188
+ payload TEXT NOT NULL
189
+ )
190
+ `);
191
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_turn_events_turn_id ON turn_events(turn_id, seq)`);
192
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_turn_events_session_key ON turn_events(session_key, seq)`);
178
193
  // Prune conversation log at startup — keep more history for better recovery
179
194
  db.prepare(`DELETE FROM conversation_log WHERE id NOT IN (SELECT id FROM conversation_log ORDER BY id DESC LIMIT 1000)`).run();
180
195
  // Set up FTS5 for memory search (graceful fallback if not available)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterhouse",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "Chapterhouse — a team-level AI assistant for engineering teams, built on the GitHub Copilot SDK. Web UI only.",
5
5
  "bin": {
6
6
  "chapterhouse": "dist/cli.js"