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.
- package/dist/api/server-runtime.js +3 -0
- package/dist/api/server.js +179 -1
- package/dist/api/server.test.js +6 -0
- package/dist/api/turn-sse.integration.test.js +352 -0
- package/dist/config.js +3 -0
- package/dist/copilot/orchestrator.js +226 -9
- package/dist/copilot/orchestrator.test.js +42 -0
- package/dist/copilot/ring-buffer.js +34 -0
- package/dist/copilot/ring-buffer.test.js +82 -0
- package/dist/copilot/session-manager.js +14 -1
- package/dist/copilot/task-event-log.js +3 -26
- package/dist/copilot/turn-event-log.js +298 -0
- package/dist/copilot/turn-event-log.test.js +326 -0
- package/dist/copilot/turn-events.js +11 -0
- package/dist/store/db.js +15 -0
- package/package.json +1 -1
- package/web/dist/assets/index-D92WYeM5.js +219 -0
- package/web/dist/assets/index-D92WYeM5.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-vL9s_H8H.js +0 -213
- package/web/dist/assets/index-vL9s_H8H.js.map +0 -1
|
@@ -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