@kontourai/survey 0.7.2 → 1.0.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 CHANGED
@@ -154,6 +154,17 @@ Server code persisting browser-submitted review events should use `persistReview
154
154
 
155
155
  The [Consumer Integration Guide](docs/consumer-integration-guide.md) covers the full path from `ReviewItem` construction through persisted review events, exported results, Surface projection, and the full `--k-*` theming token list. Test-covered examples are under [`examples/review-workbench/`](examples/review-workbench/). To run the standalone demo locally, see [Review Workbench Prototype](docs/review-workbench-prototype.md).
156
156
 
157
+
158
+ ## Standalone review console
159
+
160
+ Spawn a loopback browser dashboard backed directly by a session file:
161
+
162
+ ```sh
163
+ npx survey-review-console --session path/to/session.json [--port 4243]
164
+ ```
165
+
166
+ Opens the full Review Workbench in your browser. Every decision you make is persisted back to the session file atomically via the same `deriveServerReviewSessionApplyResult` validation the MCP server uses. An SSE stream watches the file for changes, so the browser and any concurrent MCP agent converge live on the same event queue — no page refresh required. See [docs/review-console.md](docs/review-console.md).
167
+
157
168
  ## Where Survey fits
158
169
 
159
170
  Kontour AI shows the work behind AI. Survey is the producer-side primitive:
@@ -178,6 +189,8 @@ Survey feeds Surface; Surface-shaped evidence feeds Flow gates; Flow's adversari
178
189
  | [Review Resource Contract](docs/review-resource-contract.md) | the Kontour Resource shapes for review sessions and events |
179
190
  | [Source-Authority Review Pattern](docs/source-authority-review-pattern.md) | record discipline for sources the producer treats as authoritative |
180
191
  | [Review Workbench Prototype](docs/review-workbench-prototype.md) | running the example-backed standalone demo locally |
192
+ | [Review Console](docs/review-console.md) | standalone local dashboard: browser + MCP agent share the session file |
193
+ | [Review MCP](docs/review-mcp.md) | MCP server for agent-driven review-queue decisions |
181
194
  | [Releasing](docs/RELEASING.md) | release prep and publish flow |
182
195
 
183
196
  ## Product boundary
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { runReviewConsole } from "../dist/src/console/review-console-server.js";
3
+
4
+ runReviewConsole(process.argv.slice(2)).catch((error) => {
5
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
6
+ process.exitCode = 1;
7
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Standalone Survey Review Console — loopback HTTP server that serves the
3
+ * existing Review Workbench UI wired to a session JSON file for real-time review.
4
+ *
5
+ * Routes:
6
+ * GET / HTML shell that mounts the workbench
7
+ * GET /api/session Current session state (snapshot + replayed events)
8
+ * POST /api/events Append review session events (same contract as MCP server)
9
+ * GET /api/stream SSE stream: emits "update" events when the session file changes
10
+ * GET /api/health Health check
11
+ * GET /dist/* Compiled assets served from the dist tree (traversal-safe)
12
+ */
13
+ export interface ReviewConsoleServerOptions {
14
+ /** Absolute path to the session JSON file. */
15
+ readonly sessionPath: string;
16
+ /** TCP port. Defaults to 0 (OS-assigned). */
17
+ readonly port?: number;
18
+ /** Host to bind on. Must be a loopback address. Defaults to 127.0.0.1. */
19
+ readonly host?: string;
20
+ }
21
+ export interface ReviewConsoleServerHandle {
22
+ readonly url: string;
23
+ readonly port: number;
24
+ readonly host: string;
25
+ close(): Promise<void>;
26
+ }
27
+ export declare function startReviewConsoleServer(options: ReviewConsoleServerOptions): Promise<ReviewConsoleServerHandle>;
28
+ export declare function runReviewConsole(args: string[]): Promise<void>;
@@ -0,0 +1,616 @@
1
+ /**
2
+ * Standalone Survey Review Console — loopback HTTP server that serves the
3
+ * existing Review Workbench UI wired to a session JSON file for real-time review.
4
+ *
5
+ * Routes:
6
+ * GET / HTML shell that mounts the workbench
7
+ * GET /api/session Current session state (snapshot + replayed events)
8
+ * POST /api/events Append review session events (same contract as MCP server)
9
+ * GET /api/stream SSE stream: emits "update" events when the session file changes
10
+ * GET /api/health Health check
11
+ * GET /dist/* Compiled assets served from the dist tree (traversal-safe)
12
+ */
13
+ import { createServer } from "node:http";
14
+ import { watch } from "node:fs";
15
+ import { readFile as readFileAsync, writeFile as writeFileAsync, rename as renameAsync } from "node:fs/promises";
16
+ import { resolve, join, dirname, extname, normalize, sep } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { replayReviewSessionEvents, defaultReviewSessionName, } from "../review-workbench/review-workbench.js";
19
+ import { createServerReviewSessionRecord, deriveServerReviewSessionApplyResult, } from "../review-workbench/server-review-session.js";
20
+ // ---------------------------------------------------------------------------
21
+ // Constants
22
+ // ---------------------------------------------------------------------------
23
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
24
+ const SSE_DEBOUNCE_MS = 120;
25
+ const MIME = {
26
+ ".css": "text/css; charset=utf-8",
27
+ ".html": "text/html; charset=utf-8",
28
+ ".js": "text/javascript; charset=utf-8",
29
+ ".json": "application/json; charset=utf-8",
30
+ ".ts": "text/plain; charset=utf-8",
31
+ };
32
+ // ---------------------------------------------------------------------------
33
+ // Asset paths — resolved relative to this compiled module at runtime
34
+ // ---------------------------------------------------------------------------
35
+ /**
36
+ * Resolve the dist/ directory that contains the compiled workbench assets.
37
+ *
38
+ * - When running from compiled output (dist/src/console/*.js): go up 2 levels
39
+ * to reach dist/.
40
+ * - When running from TypeScript source (Playwright/ts-node, src/console/*.ts):
41
+ * go up 2 levels reaches repo root; add "dist" to find the compiled assets.
42
+ *
43
+ * We check by looking at the source-file extension: .ts = source context.
44
+ */
45
+ function distRoot() {
46
+ const thisFile = fileURLToPath(import.meta.url);
47
+ const twoUp = resolve(dirname(thisFile), "..", "..");
48
+ // If we're running from a TypeScript source file, add the "dist" segment.
49
+ if (thisFile.endsWith(".ts")) {
50
+ return join(twoUp, "dist");
51
+ }
52
+ return twoUp;
53
+ }
54
+ // ---------------------------------------------------------------------------
55
+ // Session file helpers
56
+ // ---------------------------------------------------------------------------
57
+ async function readSession(path) {
58
+ const raw = await readFileAsync(path, "utf8");
59
+ return JSON.parse(raw);
60
+ }
61
+ async function writeSessionAtomic(path, content) {
62
+ const tmp = `${path}.tmp`;
63
+ await writeFileAsync(tmp, JSON.stringify(content, null, 2), "utf8");
64
+ await renameAsync(tmp, path);
65
+ }
66
+ function currentState(content) {
67
+ const { snapshot, events } = content;
68
+ return events.length > 0 ? replayReviewSessionEvents(snapshot, events) : snapshot;
69
+ }
70
+ // ---------------------------------------------------------------------------
71
+ // SSE broadcaster
72
+ // ---------------------------------------------------------------------------
73
+ function createSseBroadcaster() {
74
+ const clients = new Set();
75
+ function add(res) {
76
+ clients.add(res);
77
+ res.on("close", () => clients.delete(res));
78
+ }
79
+ function broadcast(event, data) {
80
+ const payload = `event: ${event}\ndata: ${data}\n\n`;
81
+ for (const res of clients) {
82
+ try {
83
+ res.write(payload);
84
+ }
85
+ catch {
86
+ clients.delete(res);
87
+ }
88
+ }
89
+ }
90
+ return { add, broadcast };
91
+ }
92
+ // ---------------------------------------------------------------------------
93
+ // File watcher with debounce + polling fallback
94
+ // ---------------------------------------------------------------------------
95
+ function watchSessionFile(sessionPath, onChange) {
96
+ let debounceTimer = null;
97
+ const schedule = () => {
98
+ if (debounceTimer)
99
+ clearTimeout(debounceTimer);
100
+ debounceTimer = setTimeout(onChange, SSE_DEBOUNCE_MS);
101
+ };
102
+ try {
103
+ const watcher = watch(sessionPath, schedule);
104
+ return () => {
105
+ if (debounceTimer)
106
+ clearTimeout(debounceTimer);
107
+ watcher.close();
108
+ };
109
+ }
110
+ catch {
111
+ // Polling fallback: check mtime every 500ms
112
+ let lastMtime = 0;
113
+ const interval = setInterval(() => {
114
+ import("node:fs").then(({ stat }) => {
115
+ stat(sessionPath, (_err, stats) => {
116
+ if (!stats)
117
+ return;
118
+ const mtime = stats.mtimeMs;
119
+ if (mtime !== lastMtime) {
120
+ lastMtime = mtime;
121
+ schedule();
122
+ }
123
+ });
124
+ }).catch(() => undefined);
125
+ }, 500);
126
+ return () => {
127
+ if (debounceTimer)
128
+ clearTimeout(debounceTimer);
129
+ clearInterval(interval);
130
+ };
131
+ }
132
+ }
133
+ // ---------------------------------------------------------------------------
134
+ // Static file serving (traversal-safe)
135
+ // ---------------------------------------------------------------------------
136
+ /**
137
+ * Serve a file from the dist directory tree.
138
+ * The path is validated to prevent traversal out of distRoot.
139
+ */
140
+ async function serveDistFile(distDir, pathname, res) {
141
+ // Strip the /dist/ prefix
142
+ const relative = pathname.startsWith("/dist/") ? pathname.slice("/dist/".length) : null;
143
+ if (!relative)
144
+ return false;
145
+ // Reject obviously dangerous segments before resolving
146
+ const decoded = decodeURIComponent(relative);
147
+ const parts = normalize(decoded).split(sep);
148
+ if (parts.some((p) => p === ".." || p === ".")) {
149
+ send(res, 404, "not found", "text/plain; charset=utf-8");
150
+ return true;
151
+ }
152
+ const filePath = join(distDir, decoded);
153
+ const resolvedFile = resolve(filePath);
154
+ const resolvedRoot = resolve(distDir);
155
+ // Confirm the resolved path is under distDir
156
+ if (!resolvedFile.startsWith(resolvedRoot + sep) && resolvedFile !== resolvedRoot) {
157
+ send(res, 404, "not found", "text/plain; charset=utf-8");
158
+ return true;
159
+ }
160
+ try {
161
+ const content = await readFileAsync(resolvedFile);
162
+ const ct = MIME[extname(resolvedFile)] ?? "application/octet-stream";
163
+ send(res, 200, content, ct);
164
+ }
165
+ catch {
166
+ send(res, 404, "not found", "text/plain; charset=utf-8");
167
+ }
168
+ return true;
169
+ }
170
+ // ---------------------------------------------------------------------------
171
+ // HTML shell
172
+ // ---------------------------------------------------------------------------
173
+ function buildConsoleHtml(sessionPath) {
174
+ const escapedPath = sessionPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/'/g, "\\'");
175
+ // workbench.js lives at dist/src/review-workbench/review-workbench.js relative to distRoot
176
+ const workbenchJsPath = "/dist/src/review-workbench/review-workbench.js";
177
+ const workbenchCssPath = "/dist/src/review-workbench/review-workbench.css";
178
+ const tokensIndexPath = "/dist/src/review-workbench/vendor/console-kit/tokens/index.css";
179
+ return `<!doctype html>
180
+ <html lang="en" class="theme-survey">
181
+ <head>
182
+ <meta charset="utf-8">
183
+ <meta name="viewport" content="width=device-width, initial-scale=1">
184
+ <title>Survey Review Console</title>
185
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%2319202a'/%3E%3Cpath d='M4 8.2 6.7 11 12 5' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
186
+ <link rel="stylesheet" href="${tokensIndexPath}">
187
+ <link rel="stylesheet" href="${workbenchCssPath}">
188
+ <style>
189
+ *, *::before, *::after { box-sizing: border-box; }
190
+ html, body { margin: 0; height: 100%; }
191
+ body {
192
+ font-family: var(--k-font-ui, "Hanken Grotesk", system-ui, sans-serif);
193
+ background: var(--k-bg, #0a0e13);
194
+ color: var(--k-text, #eef3f8);
195
+ display: flex;
196
+ flex-direction: column;
197
+ min-height: 100vh;
198
+ }
199
+ .console-topbar {
200
+ display: flex;
201
+ align-items: center;
202
+ gap: 12px;
203
+ padding: 0 16px;
204
+ height: 48px;
205
+ border-bottom: 1px solid var(--k-line, rgba(150,180,210,0.12));
206
+ background: var(--k-panel, #111824);
207
+ flex-shrink: 0;
208
+ position: sticky;
209
+ top: 0;
210
+ z-index: 50;
211
+ }
212
+ .console-topbar-logo {
213
+ font-family: var(--k-font-mono, "IBM Plex Mono", monospace);
214
+ font-size: 11px;
215
+ color: var(--k-brand, #5ce0c6);
216
+ text-transform: uppercase;
217
+ letter-spacing: 0.08em;
218
+ font-weight: 700;
219
+ display: flex;
220
+ align-items: center;
221
+ gap: 6px;
222
+ }
223
+ .console-topbar-logo svg { flex-shrink: 0; }
224
+ .console-topbar-path {
225
+ font-family: var(--k-font-mono, monospace);
226
+ font-size: 11px;
227
+ color: var(--k-text-muted, #aebccb);
228
+ overflow: hidden;
229
+ text-overflow: ellipsis;
230
+ white-space: nowrap;
231
+ flex: 1;
232
+ min-width: 0;
233
+ }
234
+ .console-topbar-actions {
235
+ display: flex;
236
+ align-items: center;
237
+ gap: 8px;
238
+ flex-shrink: 0;
239
+ }
240
+ .theme-toggle {
241
+ padding: 4px 10px;
242
+ border: 1px solid var(--k-line-strong, rgba(150,180,210,0.22));
243
+ border-radius: 6px;
244
+ background: var(--k-panel-raised, #16202d);
245
+ color: var(--k-text-muted, #aebccb);
246
+ font: inherit;
247
+ font-size: 11px;
248
+ cursor: pointer;
249
+ display: flex;
250
+ align-items: center;
251
+ gap: 5px;
252
+ }
253
+ .theme-toggle:hover {
254
+ border-color: var(--k-brand, #5ce0c6);
255
+ color: var(--k-text, #eef3f8);
256
+ }
257
+ .connection-indicator {
258
+ display: flex;
259
+ align-items: center;
260
+ gap: 5px;
261
+ font-family: var(--k-font-mono, monospace);
262
+ font-size: 10px;
263
+ color: var(--k-text-faint, #72869b);
264
+ padding: 3px 8px;
265
+ border: 1px solid var(--k-line, rgba(150,180,210,0.12));
266
+ border-radius: 999px;
267
+ }
268
+ .connection-dot {
269
+ width: 6px;
270
+ height: 6px;
271
+ border-radius: 50%;
272
+ background: var(--k-positive, #34d399);
273
+ transition: background 0.3s;
274
+ }
275
+ .connection-dot.disconnected {
276
+ background: var(--k-negative, #ff6f6f);
277
+ }
278
+ #review-workbench {
279
+ flex: 1;
280
+ min-height: 0;
281
+ }
282
+ </style>
283
+ <!-- Apply persisted theme before first paint -->
284
+ <script>
285
+ (function () {
286
+ try {
287
+ var saved = localStorage.getItem("survey-console-color-scheme");
288
+ if (saved === "light") document.documentElement.setAttribute("data-theme", "light");
289
+ } catch (_) {}
290
+ }());
291
+ </script>
292
+ </head>
293
+ <body>
294
+ <header class="console-topbar" role="banner">
295
+ <div class="console-topbar-logo">
296
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
297
+ <rect width="14" height="14" rx="3" fill="#5ce0c6" fill-opacity="0.15"/>
298
+ <path d="M3.5 7.2L5.9 9.8L10.5 4.5" stroke="#5ce0c6" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
299
+ </svg>
300
+ Survey Review Console
301
+ </div>
302
+ <div class="console-topbar-path" title="${escapedPath}">${escapedPath}</div>
303
+ <div class="console-topbar-actions">
304
+ <div class="connection-indicator" id="connection-indicator" role="status" aria-live="polite">
305
+ <span class="connection-dot" id="connection-dot" aria-hidden="true"></span>
306
+ <span id="connection-label">live</span>
307
+ </div>
308
+ <button class="theme-toggle" type="button" id="theme-toggle" aria-label="Toggle light/dark theme" data-testid="theme-toggle">
309
+ <span class="theme-toggle-icon" aria-hidden="true">&#x2600;</span>
310
+ <span class="theme-toggle-label">Light</span>
311
+ </button>
312
+ </div>
313
+ </header>
314
+ <main id="review-workbench" class="workbench" data-testid="review-workbench"></main>
315
+ <script type="module">
316
+ import { mountReviewWorkbench, replayReviewSessionEvents, defaultReviewSessionName, buildReviewSessionEvents } from "${workbenchJsPath}";
317
+
318
+ // ---- persistence adapter: POST events to /api/events ----
319
+ function createConsoleEventStore() {
320
+ let _events = [];
321
+ return {
322
+ load: () => _events.length > 0 ? [..._events] : undefined,
323
+ save: async (_session, events) => {
324
+ _events = [...events];
325
+ try {
326
+ await fetch("/api/events", {
327
+ method: "POST",
328
+ headers: { "Content-Type": "application/json" },
329
+ body: JSON.stringify({ events }),
330
+ });
331
+ } catch (err) {
332
+ console.error("[console] Failed to persist events:", err);
333
+ }
334
+ },
335
+ };
336
+ }
337
+
338
+ const eventStore = createConsoleEventStore();
339
+ let activeItemName = null;
340
+
341
+ async function fetchAndMount() {
342
+ try {
343
+ const res = await fetch("/api/session");
344
+ if (!res.ok) throw new Error("Session fetch failed: " + res.status);
345
+ const data = await res.json();
346
+ const { snapshot, events } = data;
347
+ const state = events && events.length > 0
348
+ ? replayReviewSessionEvents(snapshot, events)
349
+ : snapshot;
350
+
351
+ // Preserve active item selection across reloads
352
+ if (activeItemName && state.items && state.items.some(i => i.metadata.name === activeItemName)) {
353
+ state.activeItemName = activeItemName;
354
+ }
355
+
356
+ const root = document.getElementById("review-workbench");
357
+ if (!root) return;
358
+
359
+ mountReviewWorkbench(root, state, { eventStore });
360
+ } catch (err) {
361
+ console.error("[console] Mount error:", err);
362
+ }
363
+ }
364
+
365
+ // Track active item to restore after SSE refresh
366
+ document.addEventListener("click", (e) => {
367
+ const row = e.target.closest("[data-item-name]");
368
+ if (row) activeItemName = row.dataset.itemName;
369
+ });
370
+
371
+ // ---- SSE: refetch + re-render on change ----
372
+ function connectSse() {
373
+ const sse = new EventSource("/api/stream");
374
+ const dot = document.getElementById("connection-dot");
375
+ const label = document.getElementById("connection-label");
376
+
377
+ sse.addEventListener("open", () => {
378
+ if (dot) dot.classList.remove("disconnected");
379
+ if (label) label.textContent = "live";
380
+ });
381
+ sse.addEventListener("update", () => {
382
+ fetchAndMount();
383
+ });
384
+ sse.addEventListener("error", () => {
385
+ if (dot) dot.classList.add("disconnected");
386
+ if (label) label.textContent = "disconnected";
387
+ });
388
+
389
+ return sse;
390
+ }
391
+
392
+ // ---- Theme toggle ----
393
+ (function () {
394
+ var toggle = document.getElementById("theme-toggle");
395
+ if (!toggle) return;
396
+
397
+ function currentScheme() {
398
+ return document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark";
399
+ }
400
+
401
+ function applyScheme(scheme) {
402
+ if (scheme === "light") {
403
+ document.documentElement.setAttribute("data-theme", "light");
404
+ toggle.querySelector(".theme-toggle-label").textContent = "Dark";
405
+ toggle.querySelector(".theme-toggle-icon").textContent = "☽";
406
+ toggle.setAttribute("aria-label", "Switch to dark theme");
407
+ } else {
408
+ document.documentElement.removeAttribute("data-theme");
409
+ toggle.querySelector(".theme-toggle-label").textContent = "Light";
410
+ toggle.querySelector(".theme-toggle-icon").textContent = "☀";
411
+ toggle.setAttribute("aria-label", "Switch to light theme");
412
+ }
413
+ try { localStorage.setItem("survey-console-color-scheme", scheme); } catch (_) {}
414
+ }
415
+
416
+ applyScheme(currentScheme());
417
+ toggle.addEventListener("click", function () {
418
+ applyScheme(currentScheme() === "light" ? "dark" : "light");
419
+ });
420
+ }());
421
+
422
+ // Boot
423
+ await fetchAndMount();
424
+ connectSse();
425
+ </script>
426
+ </body>
427
+ </html>`;
428
+ }
429
+ // ---------------------------------------------------------------------------
430
+ // Request helpers
431
+ // ---------------------------------------------------------------------------
432
+ function send(res, status, body, contentType) {
433
+ res.writeHead(status, { "content-type": contentType, "cache-control": "no-store" });
434
+ res.end(body);
435
+ }
436
+ function sendJson(res, status, value) {
437
+ send(res, status, JSON.stringify(value, null, 2), "application/json; charset=utf-8");
438
+ }
439
+ async function readJsonBody(req) {
440
+ const chunks = [];
441
+ let size = 0;
442
+ for await (const chunk of req) {
443
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
444
+ size += buffer.length;
445
+ if (size > 2 * 1024 * 1024)
446
+ throw new Error("Request body too large");
447
+ chunks.push(buffer);
448
+ }
449
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
450
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
451
+ throw new Error("Request body must be a JSON object");
452
+ }
453
+ return parsed;
454
+ }
455
+ // ---------------------------------------------------------------------------
456
+ // Public API
457
+ // ---------------------------------------------------------------------------
458
+ export async function startReviewConsoleServer(options) {
459
+ const host = options.host ?? "127.0.0.1";
460
+ if (!LOOPBACK_HOSTS.has(host)) {
461
+ throw new Error("survey-review-console only serves loopback addresses");
462
+ }
463
+ const sessionPath = resolve(options.sessionPath);
464
+ const distDir = distRoot();
465
+ const broadcaster = createSseBroadcaster();
466
+ const stopWatcher = watchSessionFile(sessionPath, () => {
467
+ broadcaster.broadcast("update", JSON.stringify({ ts: Date.now() }));
468
+ });
469
+ const server = createServer(async (req, res) => {
470
+ try {
471
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
472
+ const pathname = url.pathname;
473
+ // ---- Static dist assets ----
474
+ if (pathname.startsWith("/dist/")) {
475
+ await serveDistFile(distDir, pathname, res);
476
+ return;
477
+ }
478
+ // ---- SSE ----
479
+ if (pathname === "/api/stream" && req.method === "GET") {
480
+ res.writeHead(200, {
481
+ "content-type": "text/event-stream; charset=utf-8",
482
+ "cache-control": "no-cache",
483
+ "connection": "keep-alive",
484
+ "x-accel-buffering": "no",
485
+ });
486
+ res.write("retry: 3000\n\n");
487
+ broadcaster.add(res);
488
+ return;
489
+ }
490
+ // ---- Session read ----
491
+ if (pathname === "/api/session" && req.method === "GET") {
492
+ const content = await readSession(sessionPath);
493
+ sendJson(res, 200, {
494
+ session: content.session,
495
+ snapshot: content.snapshot,
496
+ events: content.events,
497
+ state: currentState(content),
498
+ });
499
+ return;
500
+ }
501
+ // ---- Events write (append) ----
502
+ if (pathname === "/api/events" && req.method === "POST") {
503
+ const body = await readJsonBody(req);
504
+ const incomingEvents = body.events;
505
+ if (!Array.isArray(incomingEvents)) {
506
+ sendJson(res, 400, { error: "events must be an array" });
507
+ return;
508
+ }
509
+ const content = await readSession(sessionPath);
510
+ const { snapshot, events: existingEvents } = content;
511
+ const record = createServerReviewSessionRecord({
512
+ sessionName: defaultReviewSessionName,
513
+ snapshot,
514
+ eventCount: existingEvents.length,
515
+ updatedAt: new Date(),
516
+ });
517
+ const applyResult = deriveServerReviewSessionApplyResult({
518
+ record,
519
+ events: incomingEvents,
520
+ requiredResolvedItems: "none",
521
+ });
522
+ if (!applyResult.ok) {
523
+ const issueMessages = applyResult.issues.map((issue) => "message" in issue ? issue.message : String(issue));
524
+ sendJson(res, 422, { error: `Validation failed: ${issueMessages.join("; ")}` });
525
+ return;
526
+ }
527
+ await writeSessionAtomic(sessionPath, {
528
+ session: content.session,
529
+ snapshot,
530
+ events: incomingEvents,
531
+ });
532
+ sendJson(res, 200, { ok: true, eventCount: incomingEvents.length });
533
+ return;
534
+ }
535
+ // ---- Health ----
536
+ if (pathname === "/health" && req.method === "GET") {
537
+ sendJson(res, 200, { ok: true });
538
+ return;
539
+ }
540
+ // ---- HTML shell (catch-all for GET) ----
541
+ if (req.method === "GET" || req.method === "HEAD") {
542
+ const html = buildConsoleHtml(sessionPath);
543
+ send(res, 200, html, "text/html; charset=utf-8");
544
+ return;
545
+ }
546
+ send(res, 405, "method not allowed", "text/plain; charset=utf-8");
547
+ }
548
+ catch (error) {
549
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
550
+ }
551
+ });
552
+ await new Promise((resolveListen, reject) => {
553
+ server.once("error", reject);
554
+ server.listen(options.port ?? 0, host, () => {
555
+ server.off("error", reject);
556
+ resolveListen();
557
+ });
558
+ });
559
+ const address = server.address();
560
+ if (!address || typeof address === "string") {
561
+ throw new Error("Unable to determine server address");
562
+ }
563
+ const normalizedHost = host === "::1" ? "[::1]" : host;
564
+ const url = `http://${normalizedHost}:${address.port}/`;
565
+ return {
566
+ url,
567
+ port: address.port,
568
+ host,
569
+ close: () => new Promise((resolveClosed, reject) => {
570
+ stopWatcher();
571
+ server.close((err) => (err ? reject(err) : resolveClosed()));
572
+ }),
573
+ };
574
+ }
575
+ // ---------------------------------------------------------------------------
576
+ // CLI entry point (called from bin/survey-review-console.mjs)
577
+ // ---------------------------------------------------------------------------
578
+ export async function runReviewConsole(args) {
579
+ let sessionPath;
580
+ let port;
581
+ for (let i = 0; i < args.length; i++) {
582
+ const arg = args[i];
583
+ if (arg === "--session") {
584
+ const next = args[++i];
585
+ if (!next)
586
+ throw new Error("--session requires a path argument");
587
+ sessionPath = next;
588
+ }
589
+ else if (arg === "--port") {
590
+ const next = args[++i];
591
+ if (!next)
592
+ throw new Error("--port requires a number argument");
593
+ const parsed = Number(next);
594
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
595
+ throw new Error(`--port must be an integer between 1 and 65535, got: ${next}`);
596
+ }
597
+ port = parsed;
598
+ }
599
+ else {
600
+ throw new Error(`Unknown survey-review-console argument: ${arg}`);
601
+ }
602
+ }
603
+ if (!sessionPath) {
604
+ throw new Error("--session <path> is required");
605
+ }
606
+ const handle = await startReviewConsoleServer({ sessionPath, port: port ?? 4243 });
607
+ console.log(`Survey Review Console running at ${handle.url}`);
608
+ console.log(`Session file: ${resolve(sessionPath)}`);
609
+ console.log(`Press Ctrl+C to stop.`);
610
+ process.on("SIGINT", () => {
611
+ handle.close().then(() => process.exit(0)).catch(() => process.exit(1));
612
+ });
613
+ process.on("SIGTERM", () => {
614
+ handle.close().then(() => process.exit(0)).catch(() => process.exit(1));
615
+ });
616
+ }