chapterhouse 0.11.1 → 0.11.3
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/routes/agents.js +45 -41
- package/dist/api/routes/sessions.js +56 -56
- package/dist/api/sse-hub.js +46 -7
- package/dist/api/sse-hub.test.js +75 -0
- package/dist/status.js +14 -6
- package/dist/status.test.js +18 -0
- package/package.json +1 -1
- package/web/dist/assets/{WikiEdit-DFlLEsI9.js → WikiEdit-CXNLuJUo.js} +2 -2
- package/web/dist/assets/{WikiEdit-DFlLEsI9.js.map → WikiEdit-CXNLuJUo.js.map} +1 -1
- package/web/dist/assets/{WikiGraph-CkfFmXih.js → WikiGraph-SWPuU0-f.js} +2 -2
- package/web/dist/assets/{WikiGraph-CkfFmXih.js.map → WikiGraph-SWPuU0-f.js.map} +1 -1
- package/web/dist/assets/index-D7CVlJKJ.js +250 -0
- package/web/dist/assets/index-D7CVlJKJ.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-DxcT-ERi.js +0 -250
- package/web/dist/assets/index-DxcT-ERi.js.map +0 -1
|
@@ -268,10 +268,6 @@ export function createAgentsRouter(options) {
|
|
|
268
268
|
res.setHeader("Connection", "keep-alive");
|
|
269
269
|
res.setHeader("X-Accel-Buffering", "no");
|
|
270
270
|
res.flushHeaders();
|
|
271
|
-
const rawLastId = req.headers["last-event-id"];
|
|
272
|
-
const lastSeq = rawLastId && !Array.isArray(rawLastId) && /^\d+$/.test(rawLastId.trim())
|
|
273
|
-
? parseInt(rawLastId.trim(), 10)
|
|
274
|
-
: undefined;
|
|
275
271
|
const sendEvent = (event) => {
|
|
276
272
|
let payload;
|
|
277
273
|
if (event.kind === "output_delta") {
|
|
@@ -303,41 +299,48 @@ export function createAgentsRouter(options) {
|
|
|
303
299
|
}
|
|
304
300
|
res.write(`id: ${event.seq}\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
305
301
|
};
|
|
306
|
-
let replayHighSeq = lastSeq;
|
|
307
|
-
if (lastSeq !== undefined) {
|
|
308
|
-
const bufferedEvents = getTaskLogEvents(taskId);
|
|
309
|
-
const oldestBufferedSeq = bufferedEvents[0]?.seq;
|
|
310
|
-
const bufferMissesRange = oldestBufferedSeq === undefined || oldestBufferedSeq > lastSeq + 1;
|
|
311
|
-
if (bufferMissesRange) {
|
|
312
|
-
const dbEvents = getTaskEvents(taskId, lastSeq);
|
|
313
|
-
for (const event of dbEvents) {
|
|
314
|
-
sendEvent(event);
|
|
315
|
-
if (replayHighSeq === undefined || event.seq > replayHighSeq) {
|
|
316
|
-
replayHighSeq = event.seq;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
const replayEvents = getTaskLogEvents(taskId, replayHighSeq ?? 0);
|
|
322
|
-
const backlog = replayEvents.length > 0 ? replayEvents : getTaskEvents(taskId, replayHighSeq ?? 0);
|
|
323
|
-
for (const event of backlog) {
|
|
324
|
-
sendEvent(event);
|
|
325
|
-
}
|
|
326
302
|
const isTerminal = () => {
|
|
327
303
|
const row = getDb()
|
|
328
304
|
.prepare(`SELECT status FROM agent_tasks WHERE task_id = ?`)
|
|
329
305
|
.get(taskId);
|
|
330
306
|
return row ? TERMINAL_TASK_STATUSES.has(row.status) : true;
|
|
331
307
|
};
|
|
332
|
-
res.write(`: connected task=${taskId}\n\n`);
|
|
333
|
-
if (isTerminal()) {
|
|
334
|
-
res.end();
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
const heartbeat = setInterval(() => {
|
|
338
|
-
res.write(`: keep-alive\n\n`);
|
|
339
|
-
}, 15_000);
|
|
340
308
|
setupSseCleanup((registerCleanup, cleanupNow) => {
|
|
309
|
+
req.on("close", cleanupNow);
|
|
310
|
+
res.on("close", cleanupNow);
|
|
311
|
+
res.on("error", cleanupNow);
|
|
312
|
+
const rawLastId = req.headers["last-event-id"];
|
|
313
|
+
const lastSeq = rawLastId && !Array.isArray(rawLastId) && /^\d+$/.test(rawLastId.trim())
|
|
314
|
+
? parseInt(rawLastId.trim(), 10)
|
|
315
|
+
: undefined;
|
|
316
|
+
let replayHighSeq = lastSeq;
|
|
317
|
+
if (lastSeq !== undefined) {
|
|
318
|
+
const bufferedEvents = getTaskLogEvents(taskId);
|
|
319
|
+
const oldestBufferedSeq = bufferedEvents[0]?.seq;
|
|
320
|
+
const bufferMissesRange = oldestBufferedSeq === undefined || oldestBufferedSeq > lastSeq + 1;
|
|
321
|
+
if (bufferMissesRange) {
|
|
322
|
+
const dbEvents = getTaskEvents(taskId, lastSeq);
|
|
323
|
+
for (const event of dbEvents) {
|
|
324
|
+
sendEvent(event);
|
|
325
|
+
if (replayHighSeq === undefined || event.seq > replayHighSeq) {
|
|
326
|
+
replayHighSeq = event.seq;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const replayEvents = getTaskLogEvents(taskId, replayHighSeq ?? 0);
|
|
332
|
+
const backlog = replayEvents.length > 0 ? replayEvents : getTaskEvents(taskId, replayHighSeq ?? 0);
|
|
333
|
+
for (const event of backlog) {
|
|
334
|
+
sendEvent(event);
|
|
335
|
+
}
|
|
336
|
+
res.write(`: connected task=${taskId}\n\n`);
|
|
337
|
+
if (isTerminal()) {
|
|
338
|
+
res.end();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const heartbeat = setInterval(() => {
|
|
342
|
+
res.write(`: keep-alive\n\n`);
|
|
343
|
+
}, 15_000);
|
|
341
344
|
registerCleanup(() => clearInterval(heartbeat));
|
|
342
345
|
registerCleanup(subscribeTaskLog(taskId, (event) => {
|
|
343
346
|
sendEvent(event);
|
|
@@ -359,7 +362,6 @@ export function createAgentsRouter(options) {
|
|
|
359
362
|
res.end();
|
|
360
363
|
}
|
|
361
364
|
}));
|
|
362
|
-
req.on("close", cleanupNow);
|
|
363
365
|
});
|
|
364
366
|
});
|
|
365
367
|
// ---------------------------------------------------------------------------
|
|
@@ -370,19 +372,21 @@ export function createAgentsRouter(options) {
|
|
|
370
372
|
// Chat-specific events (delta, message, queued) are NOT emitted here.
|
|
371
373
|
// ---------------------------------------------------------------------------
|
|
372
374
|
router.get("/api/agents/stream", (req, res) => {
|
|
373
|
-
res.writeHead(200, {
|
|
374
|
-
"Content-Type": "text/event-stream",
|
|
375
|
-
"Cache-Control": "no-cache",
|
|
376
|
-
Connection: "keep-alive",
|
|
377
|
-
});
|
|
378
|
-
res.write(formatSseData({ type: "connected" }));
|
|
379
|
-
const heartbeat = setInterval(() => { res.write(`:ping\n\n`); }, 20_000);
|
|
380
375
|
setupSseCleanup((registerCleanup, cleanupNow) => {
|
|
376
|
+
req.on("close", cleanupNow);
|
|
377
|
+
res.on("close", cleanupNow);
|
|
378
|
+
res.on("error", cleanupNow);
|
|
379
|
+
res.writeHead(200, {
|
|
380
|
+
"Content-Type": "text/event-stream",
|
|
381
|
+
"Cache-Control": "no-cache",
|
|
382
|
+
Connection: "keep-alive",
|
|
383
|
+
});
|
|
384
|
+
res.write(formatSseData({ type: "connected" }));
|
|
385
|
+
const heartbeat = setInterval(() => { res.write(`:ping\n\n`); }, 20_000);
|
|
381
386
|
registerCleanup(() => clearInterval(heartbeat));
|
|
382
387
|
registerCleanup(agentEventBus.subscribeAll((event) => {
|
|
383
388
|
res.write(formatSseData({ type: "agent_event", agentEvent: event }));
|
|
384
389
|
}));
|
|
385
|
-
req.on("close", cleanupNow);
|
|
386
390
|
});
|
|
387
391
|
});
|
|
388
392
|
router.post("/api/agents/:slug/reload-confirm", async (req, res) => {
|
|
@@ -10,7 +10,7 @@ import { BadRequestError, parseRequest } from "../errors.js";
|
|
|
10
10
|
import { sendJson } from "../send-json.js";
|
|
11
11
|
import { formatSseData } from "../sse.js";
|
|
12
12
|
import { setupSseCleanup } from "../server-runtime.js";
|
|
13
|
-
import { associateSseClient, broadcastSsePayload,
|
|
13
|
+
import { associateSseClient, broadcastSsePayload, drainPendingSseMessages, nextSseConnectionId, sseClients, } from "../sse-hub.js";
|
|
14
14
|
const requiredString = (message) => z.string({ error: message }).trim().min(1, message);
|
|
15
15
|
const messageRequestSchema = z.object({
|
|
16
16
|
prompt: requiredString("Missing 'prompt' in request body"),
|
|
@@ -46,37 +46,35 @@ export function createSessionsRouter() {
|
|
|
46
46
|
const router = Router();
|
|
47
47
|
router.get("/stream", (req, res) => {
|
|
48
48
|
const connectionId = nextSseConnectionId();
|
|
49
|
-
res.writeHead(200, {
|
|
50
|
-
"Content-Type": "text/event-stream",
|
|
51
|
-
"Cache-Control": "no-cache",
|
|
52
|
-
Connection: "keep-alive",
|
|
53
|
-
});
|
|
54
|
-
res.write(formatSseData({ type: "connected", connectionId }));
|
|
55
|
-
while (pendingSseMessages.length > 0) {
|
|
56
|
-
const queued = pendingSseMessages.shift();
|
|
57
|
-
if (!queued) {
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
res.write(formatSseData({ type: "message", content: queued }));
|
|
61
|
-
}
|
|
62
|
-
sseClients.set(connectionId, { res, sessionKey: null });
|
|
63
|
-
const unsubscribeStatus = onStatusChange((status, message) => {
|
|
64
|
-
res.write(formatSseData({ type: "status", status, message }));
|
|
65
|
-
});
|
|
66
|
-
const currentStatus = getStatus();
|
|
67
|
-
if (currentStatus.status !== "idle") {
|
|
68
|
-
res.write(formatSseData({ type: "status", ...currentStatus }));
|
|
69
|
-
}
|
|
70
|
-
const heartbeat = setInterval(() => {
|
|
71
|
-
res.write(`:ping\n\n`);
|
|
72
|
-
}, 20_000);
|
|
73
49
|
setupSseCleanup((registerCleanup, cleanupNow) => {
|
|
74
|
-
|
|
75
|
-
|
|
50
|
+
req.on("close", cleanupNow);
|
|
51
|
+
res.on("close", cleanupNow);
|
|
52
|
+
res.on("error", cleanupNow);
|
|
53
|
+
res.writeHead(200, {
|
|
54
|
+
"Content-Type": "text/event-stream",
|
|
55
|
+
"Cache-Control": "no-cache",
|
|
56
|
+
Connection: "keep-alive",
|
|
57
|
+
});
|
|
58
|
+
sseClients.set(connectionId, { res, sessionKey: null });
|
|
76
59
|
registerCleanup(() => {
|
|
77
60
|
sseClients.delete(connectionId);
|
|
78
61
|
});
|
|
79
|
-
|
|
62
|
+
const unsubscribeStatus = onStatusChange((status, message) => {
|
|
63
|
+
res.write(formatSseData({ type: "status", status, message }));
|
|
64
|
+
});
|
|
65
|
+
registerCleanup(unsubscribeStatus);
|
|
66
|
+
const heartbeat = setInterval(() => {
|
|
67
|
+
res.write(`:ping\n\n`);
|
|
68
|
+
}, 20_000);
|
|
69
|
+
registerCleanup(() => clearInterval(heartbeat));
|
|
70
|
+
res.write(formatSseData({ type: "connected", connectionId }));
|
|
71
|
+
for (const queued of drainPendingSseMessages()) {
|
|
72
|
+
res.write(formatSseData({ type: "message", content: queued.content }));
|
|
73
|
+
}
|
|
74
|
+
const currentStatus = getStatus();
|
|
75
|
+
if (currentStatus.status !== "idle") {
|
|
76
|
+
res.write(formatSseData({ type: "status", ...currentStatus }));
|
|
77
|
+
}
|
|
80
78
|
});
|
|
81
79
|
});
|
|
82
80
|
// ---------------------------------------------------------------------------
|
|
@@ -266,40 +264,43 @@ export function createSessionsRouter() {
|
|
|
266
264
|
res.setHeader("Connection", "keep-alive");
|
|
267
265
|
res.setHeader("X-Accel-Buffering", "no");
|
|
268
266
|
res.flushHeaders();
|
|
269
|
-
// Parse Last-Event-ID for reconnect replay
|
|
270
|
-
const rawLastId = req.headers["last-event-id"];
|
|
271
|
-
const lastSeq = rawLastId && !Array.isArray(rawLastId) && /^\d+$/.test(rawLastId.trim())
|
|
272
|
-
? parseInt(rawLastId.trim(), 10)
|
|
273
|
-
: undefined;
|
|
274
|
-
const maxCurrentSeq = getSessionMaxSeqFromDb(sessionKey, { includeHistorical });
|
|
275
|
-
const effectiveLastSeq = maxCurrentSeq !== undefined && lastSeq !== undefined && lastSeq > maxCurrentSeq
|
|
276
|
-
? 0
|
|
277
|
-
: lastSeq;
|
|
278
267
|
// Helper: send a named SSE event with an id: field
|
|
279
268
|
const sendEvent = (event, seq) => {
|
|
280
269
|
const payload = JSON.stringify(event);
|
|
281
270
|
res.write(`id: ${seq}\ndata: ${payload}\n\n`);
|
|
282
271
|
};
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
272
|
+
setupSseCleanup((registerCleanup, cleanupNow) => {
|
|
273
|
+
req.on("close", cleanupNow);
|
|
274
|
+
res.on("close", cleanupNow);
|
|
275
|
+
res.on("error", cleanupNow);
|
|
276
|
+
// Parse Last-Event-ID for reconnect replay
|
|
277
|
+
const rawLastId = req.headers["last-event-id"];
|
|
278
|
+
const lastSeq = rawLastId && !Array.isArray(rawLastId) && /^\d+$/.test(rawLastId.trim())
|
|
279
|
+
? parseInt(rawLastId.trim(), 10)
|
|
280
|
+
: undefined;
|
|
281
|
+
const maxCurrentSeq = getSessionMaxSeqFromDb(sessionKey, { includeHistorical });
|
|
282
|
+
const effectiveLastSeq = maxCurrentSeq !== undefined && lastSeq !== undefined && lastSeq > maxCurrentSeq
|
|
283
|
+
? 0
|
|
284
|
+
: lastSeq;
|
|
285
|
+
// If Last-Event-ID is present and the session ring buffer doesn't cover it,
|
|
286
|
+
// fall back to SQLite for replay of completed turns.
|
|
287
|
+
let replayHighSeq = effectiveLastSeq;
|
|
288
|
+
if (effectiveLastSeq !== undefined) {
|
|
289
|
+
const oldestBuf = oldestSessionSeq(sessionKey);
|
|
290
|
+
const bufferMissesRange = oldestBuf === undefined || oldestBuf > effectiveLastSeq + 1;
|
|
291
|
+
if (bufferMissesRange) {
|
|
292
|
+
// Replay from SQLite (completed turns)
|
|
293
|
+
const dbEvents = getSessionEventsFromDb(sessionKey, effectiveLastSeq, { includeHistorical });
|
|
294
|
+
for (const e of dbEvents) {
|
|
295
|
+
sendEvent(e, e._seq);
|
|
296
|
+
if (replayHighSeq === undefined || e._seq > replayHighSeq)
|
|
297
|
+
replayHighSeq = e._seq;
|
|
298
|
+
}
|
|
296
299
|
}
|
|
297
300
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
// events we already sent — avoids double-replay overlap (Fix 5).
|
|
302
|
-
setupSseCleanup((registerCleanup, cleanupNow) => {
|
|
301
|
+
// Subscribe to session events (replays ring buffer for afterSeq, then live).
|
|
302
|
+
// Use replayHighSeq (not lastSeq) so ring-buffer replay starts after any DB
|
|
303
|
+
// events we already sent — avoids double-replay overlap (Fix 5).
|
|
303
304
|
registerCleanup(subscribeSession(sessionKey, (e) => {
|
|
304
305
|
sendEvent(e, e._seq);
|
|
305
306
|
}, replayHighSeq));
|
|
@@ -310,7 +311,6 @@ export function createSessionsRouter() {
|
|
|
310
311
|
res.write(`: keep-alive\n\n`);
|
|
311
312
|
}, 15_000);
|
|
312
313
|
registerCleanup(() => clearInterval(keepAlive));
|
|
313
|
-
req.on("close", cleanupNow);
|
|
314
314
|
});
|
|
315
315
|
});
|
|
316
316
|
}
|
package/dist/api/sse-hub.js
CHANGED
|
@@ -1,14 +1,51 @@
|
|
|
1
1
|
import { formatSseData } from "./sse.js";
|
|
2
|
+
import { childLogger } from "../util/logger.js";
|
|
3
|
+
export const MAX_PENDING_SSE_MESSAGES = 256;
|
|
4
|
+
export const PENDING_SSE_MESSAGE_MAX_AGE_MS = 10 * 60 * 1_000;
|
|
2
5
|
export const sseClients = new Map();
|
|
3
6
|
export const pendingSseMessages = [];
|
|
7
|
+
const log = childLogger("sse-hub");
|
|
4
8
|
let connectionCounter = 0;
|
|
5
9
|
export function nextSseConnectionId() {
|
|
6
10
|
connectionCounter += 1;
|
|
7
11
|
return `web-${connectionCounter}`;
|
|
8
12
|
}
|
|
13
|
+
function removeClient(connectionId, err) {
|
|
14
|
+
log.warn({ err, connectionId }, "SSE client write failed; removing client");
|
|
15
|
+
sseClients.delete(connectionId);
|
|
16
|
+
}
|
|
17
|
+
function writeClient(connectionId, client, payload) {
|
|
18
|
+
try {
|
|
19
|
+
client.res.write(payload);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
removeClient(connectionId, err);
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function prunePendingSseMessages(now = Date.now()) {
|
|
28
|
+
while (pendingSseMessages.length > 0 &&
|
|
29
|
+
now - (pendingSseMessages[0]?.queuedAt ?? now) > PENDING_SSE_MESSAGE_MAX_AGE_MS) {
|
|
30
|
+
pendingSseMessages.shift();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function enqueuePendingSseMessage(content) {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
prunePendingSseMessages(now);
|
|
36
|
+
while (pendingSseMessages.length >= MAX_PENDING_SSE_MESSAGES) {
|
|
37
|
+
pendingSseMessages.shift();
|
|
38
|
+
}
|
|
39
|
+
pendingSseMessages.push({ content, queuedAt: now });
|
|
40
|
+
}
|
|
41
|
+
export function drainPendingSseMessages() {
|
|
42
|
+
prunePendingSseMessages();
|
|
43
|
+
return pendingSseMessages.splice(0, pendingSseMessages.length);
|
|
44
|
+
}
|
|
9
45
|
export function broadcastSsePayload(payload) {
|
|
10
|
-
|
|
11
|
-
|
|
46
|
+
const frame = formatSseData(payload);
|
|
47
|
+
for (const [connectionId, client] of sseClients) {
|
|
48
|
+
writeClient(connectionId, client, frame);
|
|
12
49
|
}
|
|
13
50
|
}
|
|
14
51
|
export function associateSseClient(connectionId, sessionKey) {
|
|
@@ -18,20 +55,22 @@ export function associateSseClient(connectionId, sessionKey) {
|
|
|
18
55
|
}
|
|
19
56
|
}
|
|
20
57
|
export function broadcastSsePayloadToSession(sessionKey, payload) {
|
|
21
|
-
|
|
58
|
+
const frame = formatSseData(payload);
|
|
59
|
+
for (const [connectionId, client] of sseClients) {
|
|
22
60
|
if (client.sessionKey === sessionKey) {
|
|
23
|
-
client
|
|
61
|
+
writeClient(connectionId, client, frame);
|
|
24
62
|
}
|
|
25
63
|
}
|
|
26
64
|
}
|
|
27
65
|
/** Broadcast a proactive message to all connected SSE clients (for background task completions). */
|
|
28
66
|
export function broadcastToSSE(text) {
|
|
29
67
|
if (sseClients.size === 0) {
|
|
30
|
-
|
|
68
|
+
enqueuePendingSseMessage(text);
|
|
31
69
|
return;
|
|
32
70
|
}
|
|
33
|
-
|
|
34
|
-
|
|
71
|
+
const frame = formatSseData({ type: "message", content: text });
|
|
72
|
+
for (const [connectionId, client] of sseClients) {
|
|
73
|
+
writeClient(connectionId, client, frame);
|
|
35
74
|
}
|
|
36
75
|
}
|
|
37
76
|
//# sourceMappingURL=sse-hub.js.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, test } from "node:test";
|
|
3
|
+
import { broadcastSsePayload, broadcastSsePayloadToSession, broadcastToSSE, pendingSseMessages, sseClients, } from "./sse-hub.js";
|
|
4
|
+
function fakeResponse(write) {
|
|
5
|
+
return { write };
|
|
6
|
+
}
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
sseClients.clear();
|
|
9
|
+
pendingSseMessages.length = 0;
|
|
10
|
+
Date.now = originalDateNow;
|
|
11
|
+
});
|
|
12
|
+
const originalDateNow = Date.now;
|
|
13
|
+
test("broadcastSsePayload continues fan-out when a client write throws and removes bad clients", () => {
|
|
14
|
+
const delivered = [];
|
|
15
|
+
sseClients.set("bad", {
|
|
16
|
+
sessionKey: null,
|
|
17
|
+
res: fakeResponse(() => {
|
|
18
|
+
throw new Error("socket closed");
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
21
|
+
sseClients.set("good", {
|
|
22
|
+
sessionKey: null,
|
|
23
|
+
res: fakeResponse((chunk) => {
|
|
24
|
+
delivered.push(chunk);
|
|
25
|
+
return true;
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
assert.doesNotThrow(() => broadcastSsePayload({ type: "cancelled" }));
|
|
29
|
+
assert.equal(delivered.length, 1);
|
|
30
|
+
assert.equal(sseClients.has("bad"), false);
|
|
31
|
+
assert.equal(sseClients.has("good"), true);
|
|
32
|
+
});
|
|
33
|
+
test("broadcastSsePayloadToSession removes a throwing matching client and still delivers to other matching clients", () => {
|
|
34
|
+
const delivered = [];
|
|
35
|
+
sseClients.set("bad", {
|
|
36
|
+
sessionKey: "agent:one",
|
|
37
|
+
res: fakeResponse(() => {
|
|
38
|
+
throw new Error("socket closed");
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
sseClients.set("other-session", {
|
|
42
|
+
sessionKey: "agent:two",
|
|
43
|
+
res: fakeResponse(() => {
|
|
44
|
+
throw new Error("wrong session should not be written");
|
|
45
|
+
}),
|
|
46
|
+
});
|
|
47
|
+
sseClients.set("good", {
|
|
48
|
+
sessionKey: "agent:one",
|
|
49
|
+
res: fakeResponse((chunk) => {
|
|
50
|
+
delivered.push(chunk);
|
|
51
|
+
return true;
|
|
52
|
+
}),
|
|
53
|
+
});
|
|
54
|
+
assert.doesNotThrow(() => broadcastSsePayloadToSession("agent:one", { type: "agent_reloaded" }));
|
|
55
|
+
assert.equal(delivered.length, 1);
|
|
56
|
+
assert.equal(sseClients.has("bad"), false);
|
|
57
|
+
assert.equal(sseClients.has("good"), true);
|
|
58
|
+
assert.equal(sseClients.has("other-session"), true);
|
|
59
|
+
});
|
|
60
|
+
test("pending SSE messages are capped at 256 entries", () => {
|
|
61
|
+
for (let i = 0; i < 300; i++) {
|
|
62
|
+
broadcastToSSE(`message ${i}`);
|
|
63
|
+
}
|
|
64
|
+
assert.equal(pendingSseMessages.length, 256);
|
|
65
|
+
assert.equal(pendingSseMessages[0]?.content, "message 44");
|
|
66
|
+
assert.equal(pendingSseMessages[255]?.content, "message 299");
|
|
67
|
+
});
|
|
68
|
+
test("pending SSE messages older than 10 minutes are dropped when enqueueing", () => {
|
|
69
|
+
Date.now = () => 1_000;
|
|
70
|
+
broadcastToSSE("old");
|
|
71
|
+
Date.now = () => 1_000 + 10 * 60 * 1_000 + 1;
|
|
72
|
+
broadcastToSSE("fresh");
|
|
73
|
+
assert.deepEqual(pendingSseMessages.map((message) => message.content), ["fresh"]);
|
|
74
|
+
});
|
|
75
|
+
//# sourceMappingURL=sse-hub.test.js.map
|
package/dist/status.js
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
|
+
import { childLogger } from "./util/logger.js";
|
|
1
2
|
let currentStatus = "idle";
|
|
2
3
|
let statusMessage = "";
|
|
3
4
|
const listeners = new Set();
|
|
5
|
+
const log = childLogger("status");
|
|
6
|
+
function notifyListeners() {
|
|
7
|
+
for (const listener of listeners) {
|
|
8
|
+
try {
|
|
9
|
+
listener(currentStatus, statusMessage);
|
|
10
|
+
}
|
|
11
|
+
catch (err) {
|
|
12
|
+
log.warn({ err }, "status listener threw");
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
4
16
|
export function setBusy(reason, message) {
|
|
5
17
|
currentStatus = reason;
|
|
6
18
|
statusMessage = message;
|
|
7
|
-
|
|
8
|
-
listener(currentStatus, statusMessage);
|
|
9
|
-
}
|
|
19
|
+
notifyListeners();
|
|
10
20
|
}
|
|
11
21
|
export function setIdle() {
|
|
12
22
|
currentStatus = "idle";
|
|
13
23
|
statusMessage = "";
|
|
14
|
-
|
|
15
|
-
listener(currentStatus, statusMessage);
|
|
16
|
-
}
|
|
24
|
+
notifyListeners();
|
|
17
25
|
}
|
|
18
26
|
export function getStatus() {
|
|
19
27
|
return { status: currentStatus, message: statusMessage };
|
package/dist/status.test.js
CHANGED
|
@@ -19,4 +19,22 @@ test("tracks dreaming status and notifies listeners", () => {
|
|
|
19
19
|
{ status: "idle", message: "" },
|
|
20
20
|
]);
|
|
21
21
|
});
|
|
22
|
+
test("continues notifying status listeners when one listener throws", () => {
|
|
23
|
+
const seen = [];
|
|
24
|
+
const unsubscribeThrowing = onStatusChange(() => {
|
|
25
|
+
throw new Error("write failed");
|
|
26
|
+
});
|
|
27
|
+
const unsubscribeHealthy = onStatusChange((status, message) => {
|
|
28
|
+
seen.push({ status, message });
|
|
29
|
+
});
|
|
30
|
+
try {
|
|
31
|
+
assert.doesNotThrow(() => setBusy("dreaming", "Still notifying"));
|
|
32
|
+
assert.deepEqual(seen, [{ status: "dreaming", message: "Still notifying" }]);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
unsubscribeHealthy();
|
|
36
|
+
unsubscribeThrowing();
|
|
37
|
+
setIdle();
|
|
38
|
+
}
|
|
39
|
+
});
|
|
22
40
|
//# sourceMappingURL=status.test.js.map
|
package/package.json
CHANGED