@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 +13 -0
- package/bin/survey-review-console.mjs +7 -0
- package/dist/src/console/review-console-server.d.ts +28 -0
- package/dist/src/console/review-console-server.js +616 -0
- package/dist/src/review-workbench/review-workbench-css.generated.js +274 -63
- package/dist/src/review-workbench/review-workbench-element.js +25 -0
- package/dist/src/review-workbench/review-workbench.css +274 -63
- package/dist/src/review-workbench/review-workbench.js +114 -32
- package/dist/src/review-workbench/review-workbench.standalone.css +274 -63
- package/package.json +4 -3
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">☀</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
|
+
}
|