chapterhouse 0.9.2 → 0.11.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.
Files changed (121) hide show
  1. package/README.md +1 -1
  2. package/dist/api/auth.js +11 -1
  3. package/dist/api/auth.test.js +29 -0
  4. package/dist/api/errors.js +23 -0
  5. package/dist/api/route-coverage.test.js +61 -21
  6. package/dist/api/routes/agents.js +472 -0
  7. package/dist/api/routes/memory.js +299 -0
  8. package/dist/api/routes/projects.js +170 -0
  9. package/dist/api/routes/sessions.js +347 -0
  10. package/dist/api/routes/system.js +82 -0
  11. package/dist/api/routes/wiki.js +455 -0
  12. package/dist/api/routes/wiki.test.js +49 -0
  13. package/dist/api/send-json.js +16 -0
  14. package/dist/api/send-json.test.js +18 -0
  15. package/dist/api/server-runtime.js +45 -3
  16. package/dist/api/server.js +34 -1764
  17. package/dist/api/server.test.js +239 -8
  18. package/dist/api/sse-hub.js +37 -0
  19. package/dist/cli.js +1 -1
  20. package/dist/config.js +151 -58
  21. package/dist/config.test.js +29 -0
  22. package/dist/copilot/okr-mapper.js +2 -11
  23. package/dist/copilot/orchestrator.js +358 -352
  24. package/dist/copilot/orchestrator.test.js +139 -4
  25. package/dist/copilot/prompt-date.js +2 -1
  26. package/dist/copilot/session-manager.js +25 -23
  27. package/dist/copilot/session-manager.test.js +35 -1
  28. package/dist/copilot/standup.js +2 -2
  29. package/dist/copilot/task-event-log.js +7 -1
  30. package/dist/copilot/task-event-log.test.js +13 -0
  31. package/dist/copilot/tools/agent.js +608 -0
  32. package/dist/copilot/tools/index.js +19 -0
  33. package/dist/copilot/tools/memory.js +678 -0
  34. package/dist/copilot/tools/models.js +2 -0
  35. package/dist/copilot/tools/okr.js +171 -0
  36. package/dist/copilot/tools/wiki.js +333 -0
  37. package/dist/copilot/tools-deps.js +4 -0
  38. package/dist/copilot/tools.agent.test.js +10 -8
  39. package/dist/copilot/tools.inventory.test.js +76 -0
  40. package/dist/copilot/tools.js +1 -1780
  41. package/dist/copilot/tools.okr.test.js +31 -0
  42. package/dist/copilot/tools.wiki.test.js +6 -3
  43. package/dist/copilot/turn-event-log.js +31 -4
  44. package/dist/copilot/turn-event-log.test.js +24 -2
  45. package/dist/copilot/workiq-installer.test.js +2 -2
  46. package/dist/daemon-install.js +3 -2
  47. package/dist/daemon.js +9 -17
  48. package/dist/integrations/ado-client.js +90 -9
  49. package/dist/integrations/ado-client.test.js +56 -0
  50. package/dist/integrations/team-push.js +1 -0
  51. package/dist/integrations/team-push.test.js +6 -0
  52. package/dist/integrations/teams-notify.js +1 -0
  53. package/dist/integrations/teams-notify.test.js +5 -0
  54. package/dist/memory/active-scope.test.js +0 -1
  55. package/dist/memory/checkpoint.js +89 -72
  56. package/dist/memory/checkpoint.test.js +23 -3
  57. package/dist/memory/eot.js +87 -85
  58. package/dist/memory/eot.test.js +71 -3
  59. package/dist/memory/hooks.js +2 -4
  60. package/dist/memory/housekeeping-scheduler.js +1 -1
  61. package/dist/memory/housekeeping-scheduler.test.js +1 -2
  62. package/dist/memory/housekeeping.js +100 -3
  63. package/dist/memory/housekeeping.test.js +33 -2
  64. package/dist/memory/reflect.test.js +2 -0
  65. package/dist/memory/scope-lock.js +26 -0
  66. package/dist/memory/scope-lock.test.js +118 -0
  67. package/dist/memory/scopes.test.js +0 -1
  68. package/dist/mode-context.js +58 -5
  69. package/dist/mode-context.test.js +68 -0
  70. package/dist/paths.js +1 -0
  71. package/dist/setup.js +3 -2
  72. package/dist/shared/api-schemas.js +48 -5
  73. package/dist/store/connection.js +96 -0
  74. package/dist/store/db.js +5 -1498
  75. package/dist/store/db.test.js +182 -1
  76. package/dist/store/migrations.js +460 -0
  77. package/dist/store/repositories/memory.js +281 -0
  78. package/dist/store/repositories/okr.js +3 -0
  79. package/dist/store/repositories/projects.js +5 -0
  80. package/dist/store/repositories/sessions.js +284 -0
  81. package/dist/store/repositories/wiki.js +60 -0
  82. package/dist/store/schema.js +501 -0
  83. package/dist/util/logger.js +3 -2
  84. package/dist/wiki/consolidation.js +50 -9
  85. package/dist/wiki/consolidation.test.js +45 -0
  86. package/dist/wiki/frontmatter.js +43 -13
  87. package/dist/wiki/frontmatter.test.js +24 -0
  88. package/dist/wiki/fs.js +16 -4
  89. package/dist/wiki/fs.test.js +84 -0
  90. package/dist/wiki/index-manager.js +30 -2
  91. package/dist/wiki/index-manager.test.js +43 -12
  92. package/dist/wiki/ingest.js +1 -1
  93. package/dist/wiki/lock.js +11 -1
  94. package/dist/wiki/log-manager.js +2 -7
  95. package/dist/wiki/migrate.js +44 -17
  96. package/dist/wiki/project-registry.js +10 -5
  97. package/dist/wiki/project-registry.test.js +14 -0
  98. package/dist/wiki/scheduler.js +1 -1
  99. package/dist/wiki/seed-team-wiki.js +2 -1
  100. package/dist/wiki/team-sync.js +31 -6
  101. package/dist/wiki/team-sync.test.js +81 -0
  102. package/package.json +1 -1
  103. package/web/dist/assets/WikiEdit-EBVoY1Pk.js +30 -0
  104. package/web/dist/assets/WikiEdit-EBVoY1Pk.js.map +1 -0
  105. package/web/dist/assets/WikiGraph-BUbbABq-.js +2 -0
  106. package/web/dist/assets/WikiGraph-BUbbABq-.js.map +1 -0
  107. package/web/dist/assets/icon-acolyte-cream.svg +10 -0
  108. package/web/dist/assets/icon-acolyte-dark.svg +10 -0
  109. package/web/dist/assets/icon-acolyte-gold.svg +10 -0
  110. package/web/dist/assets/icon-acolyte-ibad.svg +10 -0
  111. package/web/dist/assets/icon-acolyte-lit.svg +10 -0
  112. package/web/dist/assets/icon-acolyte-mono.svg +10 -0
  113. package/web/dist/assets/icon-acolyte.png +0 -0
  114. package/web/dist/assets/icon-acolyte.svg +10 -0
  115. package/web/dist/assets/index-BGLL9pgM.css +10 -0
  116. package/web/dist/assets/index-KFX8UmOb.js +250 -0
  117. package/web/dist/assets/index-KFX8UmOb.js.map +1 -0
  118. package/web/dist/index.html +6 -4
  119. package/web/dist/assets/index-5kz9aRU9.css +0 -10
  120. package/web/dist/assets/index-iQrv3lQN.js +0 -286
  121. 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