chapterhouse 0.3.8 → 0.3.10

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,139 @@
1
+ /**
2
+ * Per-task in-memory event ring buffer — #116.
3
+ *
4
+ * Maintains a `Map<taskId, RingBuffer<TaskEvent>>` in the daemon process,
5
+ * capped at 500 events per task. Subscribes to the process-wide
6
+ * `squadEventBus` for `session:tool_call` events so the ring buffer stays
7
+ * in sync without the caller needing to wire anything extra.
8
+ *
9
+ * Consumers:
10
+ * 1. `GET /api/workers/:taskId/events` — REST hydration on page-load / SSE reconnect.
11
+ * The ring buffer is checked first (fast path, in-memory); SQLite is the fallback
12
+ * for completed tasks whose ring buffer has been cleared.
13
+ * 2. Per-task SSE subscribers — `subscribeTaskLog` delivers events as they arrive
14
+ * so the SSE frame fires immediately (no SQLite round-trip).
15
+ *
16
+ * Lifecycle:
17
+ * - `initTaskEventLog()` must be called once from `initOrchestrator()`.
18
+ * It returns an unsub function; call it on shutdown / test teardown.
19
+ * - `clearTaskLog(taskId)` should be called when a task is destroyed so the
20
+ * Map doesn't grow unbounded.
21
+ *
22
+ * @module copilot/task-event-log
23
+ */
24
+ import { squadEventBus } from "./squad-event-bus.js";
25
+ import { RingBuffer } from "./ring-buffer.js";
26
+ // ---------------------------------------------------------------------------
27
+ // Ring buffer — re-export so existing imports stay compatible
28
+ // ---------------------------------------------------------------------------
29
+ export { RingBuffer };
30
+ export const RING_BUFFER_CAPACITY = 500;
31
+ // ---------------------------------------------------------------------------
32
+ // Singleton state
33
+ // ---------------------------------------------------------------------------
34
+ const taskBuffers = new Map();
35
+ const taskListeners = new Map();
36
+ // ---------------------------------------------------------------------------
37
+ // Internal helpers
38
+ // ---------------------------------------------------------------------------
39
+ function getOrCreateBuffer(taskId) {
40
+ let buf = taskBuffers.get(taskId);
41
+ if (!buf) {
42
+ buf = new RingBuffer(RING_BUFFER_CAPACITY);
43
+ taskBuffers.set(taskId, buf);
44
+ }
45
+ return buf;
46
+ }
47
+ function notifyListeners(taskId, event) {
48
+ const listeners = taskListeners.get(taskId);
49
+ if (!listeners)
50
+ return;
51
+ for (const fn of listeners) {
52
+ try {
53
+ fn(event);
54
+ }
55
+ catch { /* non-fatal */ }
56
+ }
57
+ }
58
+ // ---------------------------------------------------------------------------
59
+ // Public API
60
+ // ---------------------------------------------------------------------------
61
+ /**
62
+ * Subscribe to the squadEventBus and maintain the ring buffer.
63
+ * Call once from initOrchestrator(). Returns an unsub / cleanup function.
64
+ */
65
+ export function initTaskEventLog() {
66
+ const unsubToolCall = squadEventBus.subscribe("session:tool_call", (event) => {
67
+ const taskId = event.sessionId;
68
+ if (!taskId)
69
+ return;
70
+ const p = event.payload;
71
+ const taskEvent = {
72
+ id: 0, // not a DB row — id is meaningless for ring-buffer entries
73
+ taskId,
74
+ seq: p._seq ?? 0,
75
+ ts: p._ts ?? Date.now(),
76
+ kind: p._kind === "tool_complete" ? "tool_complete" : "tool_start",
77
+ toolName: p.toolName ?? null,
78
+ summary: p._summary ?? null,
79
+ };
80
+ const buf = getOrCreateBuffer(taskId);
81
+ buf.push(taskEvent);
82
+ notifyListeners(taskId, taskEvent);
83
+ });
84
+ const unsubDestroyed = squadEventBus.subscribe("session:destroyed", (event) => {
85
+ if (event.sessionId)
86
+ clearTaskLog(event.sessionId);
87
+ });
88
+ return () => {
89
+ unsubToolCall();
90
+ unsubDestroyed();
91
+ };
92
+ }
93
+ /**
94
+ * Return all ring-buffered events for a task, optionally filtered to seq > afterSeq.
95
+ * Returns an empty array if the task has no buffer (task unknown or already cleared).
96
+ */
97
+ export function getTaskLogEvents(taskId, afterSeq = 0) {
98
+ const buf = taskBuffers.get(taskId);
99
+ if (!buf)
100
+ return [];
101
+ const all = buf.getAll();
102
+ return afterSeq === 0 ? all : all.filter((e) => e.seq > afterSeq);
103
+ }
104
+ /**
105
+ * Subscribe to live events for a specific task.
106
+ * Listener is called synchronously when a new event arrives (from the bus subscriber above).
107
+ * Returns an unsubscribe function.
108
+ */
109
+ export function subscribeTaskLog(taskId, listener) {
110
+ let listeners = taskListeners.get(taskId);
111
+ if (!listeners) {
112
+ listeners = new Set();
113
+ taskListeners.set(taskId, listeners);
114
+ }
115
+ listeners.add(listener);
116
+ return () => {
117
+ const ls = taskListeners.get(taskId);
118
+ if (ls) {
119
+ ls.delete(listener);
120
+ if (ls.size === 0)
121
+ taskListeners.delete(taskId);
122
+ }
123
+ };
124
+ }
125
+ /**
126
+ * Remove the ring buffer and any listeners for a task.
127
+ * Called automatically when `session:destroyed` fires; also callable manually.
128
+ */
129
+ export function clearTaskLog(taskId) {
130
+ taskBuffers.delete(taskId);
131
+ taskListeners.delete(taskId);
132
+ }
133
+ /**
134
+ * Number of tasks currently tracked. Useful for testing and diagnostics.
135
+ */
136
+ export function taskLogSize() {
137
+ return taskBuffers.size;
138
+ }
139
+ //# sourceMappingURL=task-event-log.js.map
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Unit tests for src/copilot/task-event-log.ts — #116
3
+ *
4
+ * Covers:
5
+ * 1. RingBuffer capacity and eviction
6
+ * 2. RingBuffer getAll returns a copy
7
+ * 3. Ring buffer singleton: bus events populate the buffer
8
+ * 4. getTaskLogEvents returns [] for unknown taskId
9
+ * 5. getTaskLogEvents filters by afterSeq
10
+ * 6. subscribeTaskLog delivers events as they arrive
11
+ * 7. subscribeTaskLog unsub stops delivery
12
+ * 8. clearTaskLog removes buffer and listeners
13
+ * 9. session:destroyed auto-clears the ring buffer
14
+ * 10. Multiple tasks tracked independently
15
+ * 11. RING_BUFFER_CAPACITY constant: 500 items, evicts oldest
16
+ *
17
+ * Uses node:test (same pattern as hooks.test.ts, agents.squad.test.ts) so the
18
+ * daemon's `npm test` runner (node --test) picks these up from dist/.
19
+ */
20
+ import { describe, it, beforeEach, afterEach } from "node:test";
21
+ import assert from "node:assert/strict";
22
+ import { EventBus } from "@bradygaster/squad-sdk/runtime/event-bus";
23
+ import { RingBuffer, RING_BUFFER_CAPACITY, } from "./task-event-log.js";
24
+ // ---------------------------------------------------------------------------
25
+ // Helpers
26
+ // ---------------------------------------------------------------------------
27
+ function makeToolCallEvent(opts) {
28
+ return {
29
+ type: "session:tool_call",
30
+ sessionId: opts.sessionId,
31
+ payload: {
32
+ toolName: opts.toolName ?? "bash",
33
+ toolArgs: {},
34
+ _kind: opts.kind ?? "tool_start",
35
+ _seq: opts.seq ?? 1,
36
+ _ts: opts.ts ?? Date.now(),
37
+ _summary: opts.summary ?? null,
38
+ },
39
+ timestamp: new Date(),
40
+ };
41
+ }
42
+ function makeDestroyedEvent(sessionId) {
43
+ return {
44
+ type: "session:destroyed",
45
+ sessionId,
46
+ agentName: "test-agent",
47
+ payload: { agentName: "test-agent", reason: "complete" },
48
+ timestamp: new Date(),
49
+ };
50
+ }
51
+ /**
52
+ * Builds an isolated task-event-log wired to a fresh EventBus so tests don't
53
+ * share state through the process-level squadEventBus singleton.
54
+ */
55
+ function buildIsolatedLog(bus) {
56
+ const CAPACITY = RING_BUFFER_CAPACITY;
57
+ const buffers = new Map();
58
+ const listeners = new Map();
59
+ function getOrCreate(taskId) {
60
+ let b = buffers.get(taskId);
61
+ if (!b) {
62
+ b = new RingBuffer(CAPACITY);
63
+ buffers.set(taskId, b);
64
+ }
65
+ return b;
66
+ }
67
+ function notify(taskId, event) {
68
+ const ls = listeners.get(taskId);
69
+ if (!ls)
70
+ return;
71
+ for (const fn of ls) {
72
+ try {
73
+ fn(event);
74
+ }
75
+ catch { /* non-fatal */ }
76
+ }
77
+ }
78
+ const unsubToolCall = bus.subscribe("session:tool_call", (event) => {
79
+ const taskId = event.sessionId;
80
+ if (!taskId)
81
+ return;
82
+ const p = event.payload;
83
+ const ev = {
84
+ id: 0, taskId,
85
+ seq: p._seq ?? 0,
86
+ ts: p._ts ?? Date.now(),
87
+ kind: p._kind === "tool_complete" ? "tool_complete" : "tool_start",
88
+ toolName: p.toolName ?? null,
89
+ summary: p._summary ?? null,
90
+ };
91
+ getOrCreate(taskId).push(ev);
92
+ notify(taskId, ev);
93
+ });
94
+ const unsubDestroyed = bus.subscribe("session:destroyed", (event) => {
95
+ if (event.sessionId) {
96
+ buffers.delete(event.sessionId);
97
+ listeners.delete(event.sessionId);
98
+ }
99
+ });
100
+ return {
101
+ getEvents: (taskId, afterSeq = 0) => {
102
+ const buf = buffers.get(taskId);
103
+ if (!buf)
104
+ return [];
105
+ const all = buf.getAll();
106
+ return afterSeq === 0 ? all : all.filter((e) => e.seq > afterSeq);
107
+ },
108
+ subscribe: (taskId, fn) => {
109
+ let ls = listeners.get(taskId);
110
+ if (!ls) {
111
+ ls = new Set();
112
+ listeners.set(taskId, ls);
113
+ }
114
+ ls.add(fn);
115
+ return () => {
116
+ const lss = listeners.get(taskId);
117
+ if (lss) {
118
+ lss.delete(fn);
119
+ if (lss.size === 0)
120
+ listeners.delete(taskId);
121
+ }
122
+ };
123
+ },
124
+ clear: (taskId) => {
125
+ buffers.delete(taskId);
126
+ listeners.delete(taskId);
127
+ },
128
+ size: () => buffers.size,
129
+ shutdown: () => { unsubToolCall(); unsubDestroyed(); },
130
+ };
131
+ }
132
+ // ---------------------------------------------------------------------------
133
+ // RingBuffer — pure class tests
134
+ // ---------------------------------------------------------------------------
135
+ describe("RingBuffer", () => {
136
+ it("starts empty", () => {
137
+ const buf = new RingBuffer(3);
138
+ assert.equal(buf.size, 0);
139
+ assert.deepEqual(buf.getAll(), []);
140
+ });
141
+ it("pushes items up to capacity", () => {
142
+ const buf = new RingBuffer(3);
143
+ buf.push(1);
144
+ buf.push(2);
145
+ buf.push(3);
146
+ assert.equal(buf.size, 3);
147
+ assert.deepEqual(buf.getAll(), [1, 2, 3]);
148
+ });
149
+ it("evicts oldest item when capacity is exceeded", () => {
150
+ const buf = new RingBuffer(3);
151
+ buf.push(1);
152
+ buf.push(2);
153
+ buf.push(3);
154
+ buf.push(4);
155
+ assert.equal(buf.size, 3);
156
+ assert.deepEqual(buf.getAll(), [2, 3, 4]);
157
+ });
158
+ it("evicts multiple oldest for many pushes", () => {
159
+ const buf = new RingBuffer(3);
160
+ for (let i = 1; i <= 10; i++)
161
+ buf.push(i);
162
+ assert.equal(buf.size, 3);
163
+ assert.deepEqual(buf.getAll(), [8, 9, 10]);
164
+ });
165
+ it("getAll returns a copy, not the internal array", () => {
166
+ const buf = new RingBuffer(3);
167
+ buf.push(1);
168
+ const snapshot = buf.getAll();
169
+ buf.push(2);
170
+ assert.deepEqual(snapshot, [1]);
171
+ });
172
+ it("capacity constant is 500", () => {
173
+ const buf = new RingBuffer(RING_BUFFER_CAPACITY);
174
+ assert.equal(buf.capacity, 500);
175
+ });
176
+ it("throws RangeError for capacity < 1", () => {
177
+ assert.throws(() => new RingBuffer(0), { name: "RangeError" });
178
+ });
179
+ it("501 pushes: only last 500 remain, oldest evicted", () => {
180
+ const buf = new RingBuffer(RING_BUFFER_CAPACITY);
181
+ for (let i = 1; i <= 501; i++)
182
+ buf.push(i);
183
+ assert.equal(buf.size, 500);
184
+ assert.equal(buf.getAll()[0], 2); // 1 was evicted
185
+ assert.equal(buf.getAll()[499], 501); // newest retained
186
+ });
187
+ });
188
+ // ---------------------------------------------------------------------------
189
+ // Task event log (bus-wired) tests
190
+ // ---------------------------------------------------------------------------
191
+ describe("task event log — bus-wired", () => {
192
+ let bus;
193
+ let log;
194
+ beforeEach(() => {
195
+ bus = new EventBus();
196
+ log = buildIsolatedLog(bus);
197
+ });
198
+ afterEach(() => {
199
+ log.shutdown();
200
+ });
201
+ it("returns [] for an unknown taskId", () => {
202
+ assert.deepEqual(log.getEvents("no-such-task"), []);
203
+ });
204
+ it("populates ring buffer on session:tool_call bus event", async () => {
205
+ await bus.emit(makeToolCallEvent({ sessionId: "task-001", kind: "tool_start", seq: 1, toolName: "view" }));
206
+ const events = log.getEvents("task-001");
207
+ assert.equal(events.length, 1);
208
+ assert.equal(events[0].kind, "tool_start");
209
+ assert.equal(events[0].toolName, "view");
210
+ assert.equal(events[0].taskId, "task-001");
211
+ assert.equal(events[0].seq, 1);
212
+ });
213
+ it("accumulates multiple events in order", async () => {
214
+ await bus.emit(makeToolCallEvent({ sessionId: "task-002", kind: "tool_start", seq: 1, toolName: "bash" }));
215
+ await bus.emit(makeToolCallEvent({ sessionId: "task-002", kind: "tool_complete", seq: 2, summary: "ok" }));
216
+ await bus.emit(makeToolCallEvent({ sessionId: "task-002", kind: "tool_start", seq: 3, toolName: "view" }));
217
+ const events = log.getEvents("task-002");
218
+ assert.equal(events.length, 3);
219
+ assert.equal(events[0].seq, 1);
220
+ assert.equal(events[2].seq, 3);
221
+ assert.equal(events[1].kind, "tool_complete");
222
+ });
223
+ it("getEvents filters by afterSeq", async () => {
224
+ await bus.emit(makeToolCallEvent({ sessionId: "task-003", seq: 1 }));
225
+ await bus.emit(makeToolCallEvent({ sessionId: "task-003", seq: 2 }));
226
+ await bus.emit(makeToolCallEvent({ sessionId: "task-003", seq: 3 }));
227
+ assert.equal(log.getEvents("task-003", 1).length, 2);
228
+ assert.equal(log.getEvents("task-003", 2).length, 1);
229
+ assert.equal(log.getEvents("task-003", 3).length, 0);
230
+ });
231
+ it("subscribe delivers events as they arrive", async () => {
232
+ const received = [];
233
+ log.subscribe("task-004", (e) => received.push(e.toolName ?? "?"));
234
+ await bus.emit(makeToolCallEvent({ sessionId: "task-004", toolName: "bash" }));
235
+ await bus.emit(makeToolCallEvent({ sessionId: "task-004", toolName: "view" }));
236
+ assert.deepEqual(received, ["bash", "view"]);
237
+ });
238
+ it("subscribe unsub stops delivery", async () => {
239
+ const received = [];
240
+ const unsub = log.subscribe("task-005", (e) => received.push(e.toolName ?? "?"));
241
+ await bus.emit(makeToolCallEvent({ sessionId: "task-005", toolName: "bash" }));
242
+ unsub();
243
+ await bus.emit(makeToolCallEvent({ sessionId: "task-005", toolName: "view" }));
244
+ assert.equal(received.length, 1);
245
+ assert.equal(received[0], "bash");
246
+ });
247
+ it("clearTaskLog removes buffer and listeners", async () => {
248
+ await bus.emit(makeToolCallEvent({ sessionId: "task-006", seq: 1 }));
249
+ assert.equal(log.getEvents("task-006").length, 1);
250
+ log.clear("task-006");
251
+ assert.equal(log.getEvents("task-006").length, 0);
252
+ });
253
+ it("session:destroyed auto-clears ring buffer", async () => {
254
+ await bus.emit(makeToolCallEvent({ sessionId: "task-007", seq: 1 }));
255
+ await bus.emit(makeToolCallEvent({ sessionId: "task-007", seq: 2 }));
256
+ assert.equal(log.getEvents("task-007").length, 2);
257
+ await bus.emit(makeDestroyedEvent("task-007"));
258
+ assert.equal(log.getEvents("task-007").length, 0);
259
+ });
260
+ it("tracks multiple tasks independently", async () => {
261
+ await bus.emit(makeToolCallEvent({ sessionId: "task-A", seq: 1, toolName: "bash" }));
262
+ await bus.emit(makeToolCallEvent({ sessionId: "task-B", seq: 1, toolName: "view" }));
263
+ await bus.emit(makeToolCallEvent({ sessionId: "task-A", seq: 2, toolName: "edit" }));
264
+ assert.equal(log.getEvents("task-A").length, 2);
265
+ assert.equal(log.getEvents("task-B").length, 1);
266
+ assert.equal(log.getEvents("task-A")[0].toolName, "bash");
267
+ assert.equal(log.getEvents("task-B")[0].toolName, "view");
268
+ assert.equal(log.size(), 2);
269
+ });
270
+ it("events for wrong taskId are not visible to other tasks", async () => {
271
+ await bus.emit(makeToolCallEvent({ sessionId: "task-X", seq: 1 }));
272
+ assert.equal(log.getEvents("task-Y").length, 0);
273
+ });
274
+ });
275
+ //# sourceMappingURL=task-event-log.test.js.map