chapterhouse 0.9.1 → 0.10.0
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/README.md +1 -1
- package/agents/korg.agent.md +20 -0
- package/dist/api/auth.js +11 -1
- package/dist/api/auth.test.js +29 -0
- package/dist/api/errors.js +23 -0
- package/dist/api/route-coverage.test.js +61 -21
- package/dist/api/routes/agents.js +472 -0
- package/dist/api/routes/memory.js +299 -0
- package/dist/api/routes/projects.js +170 -0
- package/dist/api/routes/sessions.js +347 -0
- package/dist/api/routes/system.js +82 -0
- package/dist/api/routes/wiki.js +455 -0
- package/dist/api/routes/wiki.test.js +49 -0
- package/dist/api/send-json.js +16 -0
- package/dist/api/send-json.test.js +18 -0
- package/dist/api/server-runtime.js +45 -3
- package/dist/api/server.js +34 -1764
- package/dist/api/server.test.js +239 -8
- package/dist/api/sse-hub.js +37 -0
- package/dist/cli.js +1 -1
- package/dist/config.js +151 -58
- package/dist/config.test.js +29 -0
- package/dist/copilot/okr-mapper.js +2 -11
- package/dist/copilot/orchestrator.js +358 -352
- package/dist/copilot/orchestrator.test.js +139 -4
- package/dist/copilot/prompt-date.js +2 -1
- package/dist/copilot/session-manager.js +25 -23
- package/dist/copilot/session-manager.test.js +35 -1
- package/dist/copilot/standup.js +2 -2
- package/dist/copilot/task-event-log.js +7 -1
- package/dist/copilot/task-event-log.test.js +13 -0
- package/dist/copilot/tools/agent.js +608 -0
- package/dist/copilot/tools/index.js +19 -0
- package/dist/copilot/tools/memory.js +678 -0
- package/dist/copilot/tools/models.js +2 -0
- package/dist/copilot/tools/okr.js +171 -0
- package/dist/copilot/tools/wiki.js +333 -0
- package/dist/copilot/tools-deps.js +4 -0
- package/dist/copilot/tools.agent.test.js +10 -8
- package/dist/copilot/tools.inventory.test.js +76 -0
- package/dist/copilot/tools.js +1 -1725
- package/dist/copilot/tools.okr.test.js +31 -0
- package/dist/copilot/tools.wiki.test.js +358 -6
- package/dist/copilot/turn-event-log.js +31 -4
- package/dist/copilot/turn-event-log.test.js +24 -2
- package/dist/copilot/workiq-installer.test.js +2 -2
- package/dist/daemon-install.js +3 -2
- package/dist/daemon.js +9 -17
- package/dist/integrations/ado-client.js +90 -9
- package/dist/integrations/ado-client.test.js +56 -0
- package/dist/integrations/team-push.js +1 -0
- package/dist/integrations/team-push.test.js +6 -0
- package/dist/integrations/teams-notify.js +1 -0
- package/dist/integrations/teams-notify.test.js +5 -0
- package/dist/memory/active-scope.test.js +0 -1
- package/dist/memory/checkpoint.js +89 -72
- package/dist/memory/checkpoint.test.js +23 -3
- package/dist/memory/eot.js +194 -89
- package/dist/memory/eot.test.js +186 -3
- package/dist/memory/hooks.js +2 -4
- package/dist/memory/housekeeping-scheduler.js +1 -1
- package/dist/memory/housekeeping-scheduler.test.js +1 -2
- package/dist/memory/housekeeping.js +100 -3
- package/dist/memory/housekeeping.test.js +33 -2
- package/dist/memory/reflect.test.js +2 -0
- package/dist/memory/scope-lock.js +26 -0
- package/dist/memory/scope-lock.test.js +118 -0
- package/dist/memory/scopes.test.js +0 -1
- package/dist/mode-context.js +58 -5
- package/dist/mode-context.test.js +68 -0
- package/dist/paths.js +1 -0
- package/dist/setup.js +3 -2
- package/dist/shared/api-schemas.js +48 -5
- package/dist/store/connection.js +96 -0
- package/dist/store/db.js +5 -1498
- package/dist/store/db.test.js +182 -1
- package/dist/store/migrations.js +460 -0
- package/dist/store/repositories/memory.js +281 -0
- package/dist/store/repositories/okr.js +3 -0
- package/dist/store/repositories/projects.js +5 -0
- package/dist/store/repositories/sessions.js +284 -0
- package/dist/store/repositories/wiki.js +60 -0
- package/dist/store/schema.js +501 -0
- package/dist/util/logger.js +3 -2
- package/dist/wiki/consolidation.js +50 -9
- package/dist/wiki/consolidation.test.js +45 -0
- package/dist/wiki/frontmatter.js +45 -14
- package/dist/wiki/frontmatter.test.js +26 -1
- package/dist/wiki/fs.js +16 -4
- package/dist/wiki/fs.test.js +84 -0
- package/dist/wiki/index-manager.js +30 -2
- package/dist/wiki/index-manager.test.js +43 -12
- package/dist/wiki/ingest.js +17 -1
- package/dist/wiki/lock.js +11 -1
- package/dist/wiki/log-manager.js +2 -7
- package/dist/wiki/migrate.js +44 -17
- package/dist/wiki/project-registry.js +10 -5
- package/dist/wiki/project-registry.test.js +14 -0
- package/dist/wiki/scheduler.js +1 -1
- package/dist/wiki/seed-team-wiki.js +2 -1
- package/dist/wiki/team-sync.js +31 -6
- package/dist/wiki/team-sync.test.js +81 -0
- package/package.json +1 -1
- package/web/dist/assets/WikiEdit-BZXAdarz.js +30 -0
- package/web/dist/assets/WikiEdit-BZXAdarz.js.map +1 -0
- package/web/dist/assets/WikiGraph-KrCYco4v.js +2 -0
- package/web/dist/assets/WikiGraph-KrCYco4v.js.map +1 -0
- package/web/dist/assets/index-CUm2Wbuh.js +250 -0
- package/web/dist/assets/index-CUm2Wbuh.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-iQrv3lQN.js +0 -286
- package/web/dist/assets/index-iQrv3lQN.js.map +0 -1
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { cancelCurrentMessage, enqueueForSse, getCurrentSessionKey, getLastRouteResult, interruptCurrentTurn, interruptSessionTurn, sendToOrchestrator, } from "../../copilot/orchestrator.js";
|
|
4
|
+
import { subscribeSession, getSessionEventsFromDb, getSessionMaxSeqFromDb, oldestSessionSeq } from "../../copilot/turn-event-log.js";
|
|
5
|
+
import { config } from "../../config.js";
|
|
6
|
+
import { CancelResponseSchema, SessionMessagesResponseSchema, StatusResponseSchema, SubmitTurnResponseSchema } from "../../shared/api-schemas.js";
|
|
7
|
+
import { getCurrentRunId, getSessionMessages } from "../../store/db.js";
|
|
8
|
+
import { getStatus, onStatusChange } from "../../status.js";
|
|
9
|
+
import { BadRequestError, parseRequest } from "../errors.js";
|
|
10
|
+
import { sendJson } from "../send-json.js";
|
|
11
|
+
import { formatSseData } from "../sse.js";
|
|
12
|
+
import { setupSseCleanup } from "../server-runtime.js";
|
|
13
|
+
import { associateSseClient, broadcastSsePayload, nextSseConnectionId, pendingSseMessages, sseClients, } from "../sse-hub.js";
|
|
14
|
+
const requiredString = (message) => z.string({ error: message }).trim().min(1, message);
|
|
15
|
+
const messageRequestSchema = z.object({
|
|
16
|
+
prompt: requiredString("Missing 'prompt' in request body"),
|
|
17
|
+
connectionId: requiredString("Missing or invalid 'connectionId'. Connect to /stream first."),
|
|
18
|
+
projectPath: z.string().optional(),
|
|
19
|
+
sessionKey: z.string().optional(),
|
|
20
|
+
/** Optional client-generated correlation ID. Echoed back in the `queued` SSE event
|
|
21
|
+
* so the frontend can match the event to the user message bubble it should update. */
|
|
22
|
+
msgId: z.string().optional(),
|
|
23
|
+
});
|
|
24
|
+
const interruptRequestSchema = z.object({
|
|
25
|
+
prompt: requiredString("Missing 'prompt' in request body"),
|
|
26
|
+
connectionId: requiredString("Missing or invalid 'connectionId'. Connect to /stream first."),
|
|
27
|
+
attachments: z.array(z.object({
|
|
28
|
+
type: z.literal("file"),
|
|
29
|
+
path: z.string(),
|
|
30
|
+
displayName: z.string().optional(),
|
|
31
|
+
})).optional(),
|
|
32
|
+
});
|
|
33
|
+
const turnRequestSchema = z.object({
|
|
34
|
+
prompt: z.string().trim().min(1, "Missing prompt"),
|
|
35
|
+
source: z.string().optional(),
|
|
36
|
+
attachments: z
|
|
37
|
+
.array(z.object({
|
|
38
|
+
type: z.literal("file"),
|
|
39
|
+
path: z.string(),
|
|
40
|
+
displayName: z.string().optional(),
|
|
41
|
+
}))
|
|
42
|
+
.optional(),
|
|
43
|
+
interrupt: z.boolean().optional(),
|
|
44
|
+
});
|
|
45
|
+
export function createSessionsRouter() {
|
|
46
|
+
const router = Router();
|
|
47
|
+
router.get("/stream", (req, res) => {
|
|
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
|
+
setupSseCleanup((registerCleanup, cleanupNow) => {
|
|
74
|
+
registerCleanup(() => clearInterval(heartbeat));
|
|
75
|
+
registerCleanup(unsubscribeStatus);
|
|
76
|
+
registerCleanup(() => {
|
|
77
|
+
sseClients.delete(connectionId);
|
|
78
|
+
});
|
|
79
|
+
req.on("close", cleanupNow);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Send a message to the orchestrator
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
router.post("/api/message", (req, res) => {
|
|
86
|
+
const { prompt, connectionId, projectPath, sessionKey: requestedSessionKey, msgId } = parseRequest(messageRequestSchema, req.body);
|
|
87
|
+
const effectiveSessionKey = requestedSessionKey || "default";
|
|
88
|
+
if (!sseClients.has(connectionId)) {
|
|
89
|
+
throw new BadRequestError("Missing or invalid 'connectionId'. Connect to /stream first.");
|
|
90
|
+
}
|
|
91
|
+
associateSseClient(connectionId, effectiveSessionKey);
|
|
92
|
+
sendToOrchestrator(prompt, {
|
|
93
|
+
type: "web",
|
|
94
|
+
connectionId,
|
|
95
|
+
user: req.user,
|
|
96
|
+
authorizationHeader: typeof req.headers.authorization === "string" ? req.headers.authorization : undefined,
|
|
97
|
+
projectPath: projectPath || undefined,
|
|
98
|
+
}, (text, done, turnId) => {
|
|
99
|
+
const sseClient = sseClients.get(connectionId);
|
|
100
|
+
if (sseClient) {
|
|
101
|
+
const event = {
|
|
102
|
+
type: done ? "message" : "delta",
|
|
103
|
+
content: text,
|
|
104
|
+
sessionKey: effectiveSessionKey,
|
|
105
|
+
turnId,
|
|
106
|
+
};
|
|
107
|
+
if (done) {
|
|
108
|
+
const routeResult = getLastRouteResult();
|
|
109
|
+
if (routeResult) {
|
|
110
|
+
event.route = {
|
|
111
|
+
model: routeResult.model,
|
|
112
|
+
routerMode: routeResult.routerMode,
|
|
113
|
+
...(routeResult.tier !== null ? { tier: routeResult.tier } : {}),
|
|
114
|
+
...(routeResult.overrideName ? { overrideName: routeResult.overrideName } : {}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
sseClient.res.write(formatSseData(event));
|
|
119
|
+
}
|
|
120
|
+
}, undefined, (activity, turnId) => {
|
|
121
|
+
const sseClient = sseClients.get(connectionId);
|
|
122
|
+
if (sseClient) {
|
|
123
|
+
sseClient.res.write(formatSseData({ type: "activity", ...activity, sessionKey: effectiveSessionKey, ...(turnId ? { turnId } : {}) }));
|
|
124
|
+
}
|
|
125
|
+
}, (position, turnId) => {
|
|
126
|
+
const sseClient = sseClients.get(connectionId);
|
|
127
|
+
if (sseClient) {
|
|
128
|
+
sseClient.res.write(formatSseData({
|
|
129
|
+
type: "queued",
|
|
130
|
+
position,
|
|
131
|
+
sessionKey: effectiveSessionKey,
|
|
132
|
+
turnId,
|
|
133
|
+
...(msgId ? { msgId } : {}),
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
}, (remainingLength) => {
|
|
137
|
+
const sseClient = sseClients.get(connectionId);
|
|
138
|
+
if (sseClient) {
|
|
139
|
+
sseClient.res.write(formatSseData({
|
|
140
|
+
type: "queue-advance",
|
|
141
|
+
length: remainingLength,
|
|
142
|
+
sessionKey: effectiveSessionKey,
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
sendJson(res, StatusResponseSchema, { status: "queued" });
|
|
147
|
+
});
|
|
148
|
+
// Cancel the current in-flight message
|
|
149
|
+
router.post("/api/cancel", async (_req, res) => {
|
|
150
|
+
const sessionKey = getCurrentSessionKey();
|
|
151
|
+
const cancelled = await cancelCurrentMessage();
|
|
152
|
+
broadcastSsePayload({ type: "cancelled", sessionKey });
|
|
153
|
+
sendJson(res, CancelResponseSchema, { status: "ok", cancelled });
|
|
154
|
+
});
|
|
155
|
+
router.post("/api/session/:sessionKey/interrupt", async (req, res) => {
|
|
156
|
+
const sessionKey = Array.isArray(req.params.sessionKey)
|
|
157
|
+
? req.params.sessionKey[0]
|
|
158
|
+
: req.params.sessionKey;
|
|
159
|
+
if (!sessionKey)
|
|
160
|
+
throw new BadRequestError("Missing sessionKey");
|
|
161
|
+
const cancelled = await interruptSessionTurn(sessionKey);
|
|
162
|
+
sendJson(res, CancelResponseSchema, { status: "ok", cancelled });
|
|
163
|
+
});
|
|
164
|
+
// Interrupt the active turn on a specific session and start a replacement turn.
|
|
165
|
+
// POST /api/sessions/:sessionKey/interrupt
|
|
166
|
+
// Body: { prompt, connectionId, attachments? }
|
|
167
|
+
router.post("/api/sessions/:sessionKey/interrupt", (req, res) => {
|
|
168
|
+
const sessionKey = Array.isArray(req.params.sessionKey)
|
|
169
|
+
? req.params.sessionKey[0]
|
|
170
|
+
: req.params.sessionKey;
|
|
171
|
+
if (!sessionKey)
|
|
172
|
+
throw new BadRequestError("Missing sessionKey");
|
|
173
|
+
const { prompt, connectionId, attachments } = parseRequest(interruptRequestSchema, req.body);
|
|
174
|
+
if (!sseClients.has(connectionId)) {
|
|
175
|
+
throw new BadRequestError("Missing or invalid 'connectionId'. Connect to /stream first.");
|
|
176
|
+
}
|
|
177
|
+
const source = {
|
|
178
|
+
type: "web",
|
|
179
|
+
connectionId,
|
|
180
|
+
user: req.user,
|
|
181
|
+
authorizationHeader: typeof req.headers.authorization === "string" ? req.headers.authorization : undefined,
|
|
182
|
+
};
|
|
183
|
+
interruptCurrentTurn(sessionKey, prompt, source, (text, done, turnId) => {
|
|
184
|
+
const sseClient = sseClients.get(connectionId);
|
|
185
|
+
if (sseClient) {
|
|
186
|
+
const event = {
|
|
187
|
+
type: done ? "message" : "delta",
|
|
188
|
+
content: text,
|
|
189
|
+
sessionKey,
|
|
190
|
+
turnId,
|
|
191
|
+
};
|
|
192
|
+
if (done) {
|
|
193
|
+
const routeResult = getLastRouteResult();
|
|
194
|
+
if (routeResult) {
|
|
195
|
+
event.route = {
|
|
196
|
+
model: routeResult.model,
|
|
197
|
+
routerMode: routeResult.routerMode,
|
|
198
|
+
...(routeResult.tier !== null ? { tier: routeResult.tier } : {}),
|
|
199
|
+
...(routeResult.overrideName ? { overrideName: routeResult.overrideName } : {}),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
sseClient.res.write(formatSseData(event));
|
|
204
|
+
}
|
|
205
|
+
}, attachments, (activity, turnId) => {
|
|
206
|
+
const sseClient = sseClients.get(connectionId);
|
|
207
|
+
if (sseClient) {
|
|
208
|
+
sseClient.res.write(formatSseData({ type: "activity", ...activity, sessionKey, ...(turnId ? { turnId } : {}) }));
|
|
209
|
+
}
|
|
210
|
+
}, (abortedTurnId) => {
|
|
211
|
+
const sseClient = sseClients.get(connectionId);
|
|
212
|
+
if (sseClient) {
|
|
213
|
+
sseClient.res.write(formatSseData({ type: "turn-interrupted", abortedTurnId, sessionKey }));
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
sendJson(res, StatusResponseSchema, { status: "interrupting" });
|
|
217
|
+
});
|
|
218
|
+
if (config.chatSseEnabled) {
|
|
219
|
+
/**
|
|
220
|
+
* POST /api/sessions/:key/turn
|
|
221
|
+
*
|
|
222
|
+
* Submit a new turn to the given session. Returns `{ turnId }` immediately;
|
|
223
|
+
* the turn runs asynchronously and emits events on the per-session SSE stream.
|
|
224
|
+
*
|
|
225
|
+
* Body: `{ prompt, source?, attachments?, interrupt? }`
|
|
226
|
+
* Response: `{ turnId: string }`
|
|
227
|
+
*/
|
|
228
|
+
router.post("/api/sessions/:key/turn", (req, res) => {
|
|
229
|
+
const sessionKey = Array.isArray(req.params.key) ? req.params.key[0] : req.params.key;
|
|
230
|
+
if (!sessionKey)
|
|
231
|
+
throw new BadRequestError("Missing session key");
|
|
232
|
+
const { prompt, attachments, interrupt } = parseRequest(turnRequestSchema, req.body);
|
|
233
|
+
const authUser = req.user;
|
|
234
|
+
const authHeader = req.headers.authorization ?? undefined;
|
|
235
|
+
const turnId = enqueueForSse({
|
|
236
|
+
sessionKey,
|
|
237
|
+
prompt,
|
|
238
|
+
attachments,
|
|
239
|
+
authUser,
|
|
240
|
+
authHeader,
|
|
241
|
+
interrupt: interrupt ?? false,
|
|
242
|
+
});
|
|
243
|
+
sendJson(res, SubmitTurnResponseSchema, { turnId });
|
|
244
|
+
});
|
|
245
|
+
/**
|
|
246
|
+
* GET /api/sessions/:key/stream
|
|
247
|
+
*
|
|
248
|
+
* Per-session SSE channel. Delivers turn events:
|
|
249
|
+
* `turn:started`, `turn:delta`, `turn:queued`, `turn:interrupted`,
|
|
250
|
+
* `turn:complete`, `turn:error`
|
|
251
|
+
*
|
|
252
|
+
* Supports `Last-Event-ID` for reconnect replay:
|
|
253
|
+
* - If the session ring buffer covers the requested range, replays from memory.
|
|
254
|
+
* - Otherwise falls back to SQLite (covers completed-turn replay).
|
|
255
|
+
*
|
|
256
|
+
* Keep-alive comments are sent every 15 s.
|
|
257
|
+
* Multiple simultaneous subscribers (tabs) are supported.
|
|
258
|
+
*/
|
|
259
|
+
router.get("/api/sessions/:key/stream", (req, res) => {
|
|
260
|
+
const sessionKey = Array.isArray(req.params.key) ? req.params.key[0] : req.params.key;
|
|
261
|
+
if (!sessionKey)
|
|
262
|
+
throw new BadRequestError("Missing session key");
|
|
263
|
+
const includeHistorical = req.query.include === "all";
|
|
264
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
265
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
266
|
+
res.setHeader("Connection", "keep-alive");
|
|
267
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
268
|
+
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
|
+
// Helper: send a named SSE event with an id: field
|
|
279
|
+
const sendEvent = (event, seq) => {
|
|
280
|
+
const payload = JSON.stringify(event);
|
|
281
|
+
res.write(`id: ${seq}\ndata: ${payload}\n\n`);
|
|
282
|
+
};
|
|
283
|
+
// If Last-Event-ID is present and the session ring buffer doesn't cover it,
|
|
284
|
+
// fall back to SQLite for replay of completed turns.
|
|
285
|
+
let replayHighSeq = effectiveLastSeq;
|
|
286
|
+
if (effectiveLastSeq !== undefined) {
|
|
287
|
+
const oldestBuf = oldestSessionSeq(sessionKey);
|
|
288
|
+
const bufferMissesRange = oldestBuf === undefined || oldestBuf > effectiveLastSeq + 1;
|
|
289
|
+
if (bufferMissesRange) {
|
|
290
|
+
// Replay from SQLite (completed turns)
|
|
291
|
+
const dbEvents = getSessionEventsFromDb(sessionKey, effectiveLastSeq, { includeHistorical });
|
|
292
|
+
for (const e of dbEvents) {
|
|
293
|
+
sendEvent(e, e._seq);
|
|
294
|
+
if (replayHighSeq === undefined || e._seq > replayHighSeq)
|
|
295
|
+
replayHighSeq = e._seq;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Subscribe to session events (replays ring buffer for afterSeq, then live).
|
|
300
|
+
// Use replayHighSeq (not lastSeq) so ring-buffer replay starts after any DB
|
|
301
|
+
// events we already sent — avoids double-replay overlap (Fix 5).
|
|
302
|
+
setupSseCleanup((registerCleanup, cleanupNow) => {
|
|
303
|
+
registerCleanup(subscribeSession(sessionKey, (e) => {
|
|
304
|
+
sendEvent(e, e._seq);
|
|
305
|
+
}, replayHighSeq));
|
|
306
|
+
// Send connected event
|
|
307
|
+
res.write(`: connected session=${sessionKey} run=${getCurrentRunId()}\n\n`);
|
|
308
|
+
// Keep-alive every 15 s
|
|
309
|
+
const keepAlive = setInterval(() => {
|
|
310
|
+
res.write(`: keep-alive\n\n`);
|
|
311
|
+
}, 15_000);
|
|
312
|
+
registerCleanup(() => clearInterval(keepAlive));
|
|
313
|
+
req.on("close", cleanupNow);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
// Feature flag off — return 404 for both endpoints
|
|
319
|
+
router.post("/api/sessions/:key/turn", (_req, res) => {
|
|
320
|
+
res.status(404).json({ error: "Chat SSE not enabled. Set CHAPTERHOUSE_CHAT_SSE=1." });
|
|
321
|
+
});
|
|
322
|
+
router.get("/api/sessions/:key/stream", (_req, res) => {
|
|
323
|
+
res.status(404).json({ error: "Chat SSE not enabled. Set CHAPTERHOUSE_CHAT_SSE=1." });
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
router.get("/api/session/:sessionKey/messages", (req, res) => {
|
|
327
|
+
const sessionKey = Array.isArray(req.params.sessionKey)
|
|
328
|
+
? req.params.sessionKey[0]
|
|
329
|
+
: req.params.sessionKey;
|
|
330
|
+
if (!sessionKey) {
|
|
331
|
+
throw new BadRequestError("Missing sessionKey");
|
|
332
|
+
}
|
|
333
|
+
const rawLimit = req.query.limit;
|
|
334
|
+
const limit = rawLimit !== undefined ? parseInt(String(rawLimit), 10) : undefined;
|
|
335
|
+
if (limit !== undefined && (!Number.isFinite(limit) || limit < 1)) {
|
|
336
|
+
throw new BadRequestError("'limit' must be a positive integer");
|
|
337
|
+
}
|
|
338
|
+
const includeParam = Array.isArray(req.query.include)
|
|
339
|
+
? req.query.include[0]
|
|
340
|
+
: req.query.include;
|
|
341
|
+
const includeHistorical = includeParam !== "current";
|
|
342
|
+
const messages = getSessionMessages(sessionKey, limit, { includeHistorical });
|
|
343
|
+
sendJson(res, SessionMessagesResponseSchema, { sessionKey, messages });
|
|
344
|
+
});
|
|
345
|
+
return router;
|
|
346
|
+
}
|
|
347
|
+
//# sourceMappingURL=sessions.js.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { config } from "../../config.js";
|
|
3
|
+
import { restartDaemon } from "../../daemon.js";
|
|
4
|
+
import { BootstrapResponseSchema, HealthResponseSchema, OkResponseSchema, PublicConfigResponseSchema } from "../../shared/api-schemas.js";
|
|
5
|
+
import { isFts5Available, isFts5Ready } from "../../store/db.js";
|
|
6
|
+
import { childLogger } from "../../util/logger.js";
|
|
7
|
+
import { ForbiddenError } from "../errors.js";
|
|
8
|
+
import { getBootstrapAuthResponse } from "../auth.js";
|
|
9
|
+
import { sendJson } from "../send-json.js";
|
|
10
|
+
import { createHealthPayload, createPublicConfigPayload } from "../server-runtime.js";
|
|
11
|
+
const log = childLogger("server");
|
|
12
|
+
let restartInFlight = false;
|
|
13
|
+
function isLoopbackHostname(hostname) {
|
|
14
|
+
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
|
|
15
|
+
}
|
|
16
|
+
function isLoopbackOrigin(req) {
|
|
17
|
+
const origin = req.headers.origin || req.headers.referer;
|
|
18
|
+
if (!origin) {
|
|
19
|
+
const remote = req.socket.remoteAddress || "";
|
|
20
|
+
return remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
return isLoopbackHostname(new URL(origin).hostname);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function createSystemRouter(options) {
|
|
30
|
+
const { apiToken } = options;
|
|
31
|
+
const router = Router();
|
|
32
|
+
router.get("/api/bootstrap", (req, res) => {
|
|
33
|
+
if (!isLoopbackOrigin(req)) {
|
|
34
|
+
throw new ForbiddenError("Bootstrap is loopback-only");
|
|
35
|
+
}
|
|
36
|
+
sendJson(res, BootstrapResponseSchema, getBootstrapAuthResponse(apiToken, {
|
|
37
|
+
entraAuthEnabled: config.entraAuthEnabled,
|
|
38
|
+
standaloneMode: config.standaloneMode,
|
|
39
|
+
entraTenantId: config.entraTenantId,
|
|
40
|
+
entraClientId: config.entraClientId,
|
|
41
|
+
entraRequiredRole: config.entraRequiredRole,
|
|
42
|
+
entraTeamLeadId: config.entraTeamLeadId,
|
|
43
|
+
}));
|
|
44
|
+
});
|
|
45
|
+
router.get("/api/config/public", (_req, res) => {
|
|
46
|
+
sendJson(res, PublicConfigResponseSchema, createPublicConfigPayload({
|
|
47
|
+
entraAuthEnabled: config.entraAuthEnabled,
|
|
48
|
+
standaloneMode: config.standaloneMode,
|
|
49
|
+
entraClientId: config.entraClientId,
|
|
50
|
+
entraTenantId: config.entraTenantId,
|
|
51
|
+
chatSseEnabled: config.chatSseEnabled,
|
|
52
|
+
}));
|
|
53
|
+
});
|
|
54
|
+
const handleHealth = (_req, res) => {
|
|
55
|
+
const fts5Ready = isFts5Ready();
|
|
56
|
+
if (!fts5Ready) {
|
|
57
|
+
res.status(503);
|
|
58
|
+
}
|
|
59
|
+
sendJson(res, HealthResponseSchema, {
|
|
60
|
+
...createHealthPayload(new Date(), { fts5Ready }),
|
|
61
|
+
fts5Available: isFts5Available(),
|
|
62
|
+
fts5Ready,
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
router.get("/api/status", handleHealth);
|
|
66
|
+
router.get("/status", handleHealth);
|
|
67
|
+
router.get("/health", handleHealth);
|
|
68
|
+
router.post("/api/restart", (_req, res) => {
|
|
69
|
+
if (restartInFlight) {
|
|
70
|
+
res.status(503).json({ error: "Restart already in progress" });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
restartInFlight = true;
|
|
74
|
+
sendJson(res, OkResponseSchema, { ok: true });
|
|
75
|
+
restartDaemon().catch((err) => {
|
|
76
|
+
log.error({ err: err instanceof Error ? err.message : err }, "Restart failed");
|
|
77
|
+
restartInFlight = false;
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
return router;
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=system.js.map
|