pi-mega-compact 0.8.21 → 0.8.23
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/LICENSE +6 -2
- package/README.md +1 -1
- package/dist/extensions/dashboard-server/dashboard-client-core.js +201 -0
- package/dist/extensions/dashboard-server/dashboard-client-game.js +241 -0
- package/dist/extensions/dashboard-server/dashboard-client-repos.js +212 -0
- package/dist/extensions/dashboard-server/dashboard-client.js +19 -0
- package/dist/extensions/dashboard-server/html.js +2 -621
- package/dist/extensions/dashboard-server/routes-core.js +62 -0
- package/dist/extensions/dashboard-server/routes-game.js +323 -0
- package/dist/extensions/dashboard-server/routes-repo.js +170 -0
- package/dist/extensions/dashboard-server/routes-sessions.js +159 -0
- package/dist/extensions/dashboard-server/routes.js +10 -0
- package/dist/extensions/dashboard-server/server.js +26 -623
- package/dist/extensions/mega-commands.js +4 -3
- package/dist/extensions/mega-events/agent-handlers.js +2 -1
- package/dist/extensions/mega-events/compact-handlers.js +26 -0
- package/dist/extensions/mega-events/session-handlers.js +2 -1
- package/dist/extensions/mega-pipeline/compact.js +3 -2
- package/dist/extensions/mega-runtime/state.js +7 -7
- package/dist/src/dedup/raptor/multilevel.js +172 -0
- package/dist/src/dedup/raptor/multilevel.test.js +203 -0
- package/dist/src/dedup/raptor/promote.test.js +5 -5
- package/dist/src/dedup/raptor/retrieval.js +1 -1
- package/dist/src/dedup/sprint12.test.js +7 -7
- package/dist/src/dedup-engine.test.js +29 -29
- package/dist/src/e2e.test.js +38 -38
- package/dist/src/engine.js +3 -3
- package/dist/src/engine.test.js +6 -6
- package/dist/src/importance.js +197 -0
- package/dist/src/importance.test.js +372 -0
- package/dist/src/ratio.bench.test.js +18 -18
- package/dist/src/recall.js +6 -5
- package/dist/src/recall.test.js +85 -27
- package/dist/src/sprint14.test.js +2 -2
- package/dist/src/store/migrate.test.js +5 -5
- package/dist/src/store/sprint10.test.js +5 -5
- package/dist/src/store/sqlite/global-index.js +5 -174
- package/dist/src/store/sqlite/global-sessions.js +190 -0
- package/dist/src/vector-read.js +168 -0
- package/dist/src/vector-search.js +191 -0
- package/dist/src/vectorStore.js +10 -297
- package/dist/src/vectorStore.test.js +32 -32
- package/extensions/dashboard-server/dashboard-client-core.ts +202 -0
- package/extensions/dashboard-server/dashboard-client-game.ts +242 -0
- package/extensions/dashboard-server/dashboard-client-repos.ts +213 -0
- package/extensions/dashboard-server/dashboard-client.ts +21 -0
- package/extensions/dashboard-server/html.ts +2 -621
- package/extensions/dashboard-server/routes-core.ts +113 -0
- package/extensions/dashboard-server/routes-game.ts +386 -0
- package/extensions/dashboard-server/routes-repo.ts +212 -0
- package/extensions/dashboard-server/routes-sessions.ts +195 -0
- package/extensions/dashboard-server/routes.ts +13 -0
- package/extensions/dashboard-server/server.ts +37 -700
- package/extensions/mega-commands.ts +4 -3
- package/extensions/mega-events/agent-handlers.ts +2 -1
- package/extensions/mega-events/compact-handlers.ts +28 -0
- package/extensions/mega-events/session-handlers.ts +2 -1
- package/extensions/mega-pipeline/compact.ts +3 -2
- package/extensions/mega-runtime/state.ts +7 -7
- package/extensions/openclaw-mega-compact.ts +2 -2
- package/package.json +2 -2
- package/src/dedup/raptor/multilevel.test.ts +278 -0
- package/src/dedup/raptor/multilevel.ts +246 -0
- package/src/dedup/raptor/promote.test.ts +5 -5
- package/src/dedup/raptor/retrieval.ts +1 -1
- package/src/dedup/sprint12.test.ts +7 -7
- package/src/dedup-engine.test.ts +30 -30
- package/src/e2e.test.ts +38 -38
- package/src/engine.test.ts +6 -6
- package/src/engine.ts +3 -3
- package/src/importance.test.ts +538 -0
- package/src/importance.ts +312 -0
- package/src/ratio.bench.test.ts +18 -18
- package/src/recall.test.ts +101 -29
- package/src/recall.ts +9 -9
- package/src/sprint14.test.ts +2 -2
- package/src/store/migrate.test.ts +5 -5
- package/src/store/sprint10.test.ts +5 -5
- package/src/store/sqlite/global-index.ts +18 -290
- package/src/store/sqlite/global-sessions.ts +291 -0
- package/src/vector-read.ts +237 -0
- package/src/vector-search.ts +231 -0
- package/src/vectorStore.test.ts +32 -32
- package/src/vectorStore.ts +29 -356
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dashboard-server/server.ts — HTTP server creation + launch + CLI entry point.
|
|
3
|
+
*
|
|
4
|
+
* Route handlers are extracted to routes.ts. This file owns:
|
|
5
|
+
* - launchDashboardServer (setup, version detection, port finding, IPv6 mirror, lifecycle)
|
|
6
|
+
* - createServer as a thin dispatcher that builds RouteContext and delegates each route
|
|
7
|
+
* - CORS preflight + OPTIONS handling (per-request middleware, not a route)
|
|
3
8
|
*/
|
|
4
9
|
|
|
5
10
|
import {
|
|
@@ -12,7 +17,6 @@ import {
|
|
|
12
17
|
mkdirSync,
|
|
13
18
|
readFileSync,
|
|
14
19
|
unlinkSync,
|
|
15
|
-
watch,
|
|
16
20
|
writeFileSync,
|
|
17
21
|
} from "node:fs";
|
|
18
22
|
import { join, dirname } from "node:path";
|
|
@@ -20,12 +24,18 @@ import { fileURLToPath } from "node:url";
|
|
|
20
24
|
import { createRequire } from "node:module";
|
|
21
25
|
|
|
22
26
|
import { log, setLogPath, setDashboardServerVersion } from "./state.js";
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
import {
|
|
28
|
+
buildRouteContext,
|
|
29
|
+
handleIndex,
|
|
30
|
+
handleRepoIndex,
|
|
31
|
+
handleEvents,
|
|
32
|
+
handleGameState,
|
|
33
|
+
handleGameScores,
|
|
34
|
+
handlePerf,
|
|
35
|
+
handleAchievements,
|
|
36
|
+
handleSessions,
|
|
37
|
+
handleStatic,
|
|
38
|
+
} from "./routes.js";
|
|
29
39
|
|
|
30
40
|
export async function launchDashboardServer(
|
|
31
41
|
stateDir: string,
|
|
@@ -170,45 +180,15 @@ export async function launchDashboardServer(
|
|
|
170
180
|
// ── New server ────────────────────────────────────────────────────────────
|
|
171
181
|
mkdirSync(stateDir, { recursive: true });
|
|
172
182
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
function overlayCurrentRepo(idx: IndexIndex | null): void {
|
|
183
|
-
if (!idx || !idx.repos.length) return;
|
|
184
|
-
let snap: Snapshot | null = null;
|
|
185
|
-
try {
|
|
186
|
-
snap = readSnapshot(snapshotPath);
|
|
187
|
-
} catch {
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
if (!snap || !snap.repo) return;
|
|
191
|
-
const cur = idx.repos.find((r) => r.stateDir === stateDir);
|
|
192
|
-
if (!cur) return;
|
|
193
|
-
const prevSaved = cur.tokensSaved;
|
|
194
|
-
const prevCp = cur.checkpointCount;
|
|
195
|
-
const prevBytes = cur.compressedOriginalBytes;
|
|
196
|
-
const comp = snap.compression?.repo;
|
|
197
|
-
const liveSaved = comp
|
|
198
|
-
? comp.tokensFreed
|
|
199
|
-
: (snap.repo.tokensSaved ?? prevSaved);
|
|
200
|
-
const liveCp = snap.repo.checkpointCount ?? prevCp;
|
|
201
|
-
const liveBytes = snap.integrity?.compressedOriginalBytes ?? prevBytes;
|
|
202
|
-
cur.tokensSaved = liveSaved;
|
|
203
|
-
cur.checkpointCount = liveCp;
|
|
204
|
-
cur.compressedOriginalBytes = liveBytes;
|
|
205
|
-
if (idx.summary) {
|
|
206
|
-
idx.summary.totalTokensSaved += liveSaved - prevSaved;
|
|
207
|
-
idx.summary.totalCheckpoints += liveCp - prevCp;
|
|
208
|
-
idx.summary.totalCompressedOriginalBytes += liveBytes - prevBytes;
|
|
209
|
-
}
|
|
210
|
-
idx.updatedAt = snap.updatedAt ?? idx.updatedAt;
|
|
211
|
-
}
|
|
183
|
+
// Build RouteContext once; all handlers receive the same ctx object.
|
|
184
|
+
const ctx = buildRouteContext({
|
|
185
|
+
snapshotPath,
|
|
186
|
+
eventsPath,
|
|
187
|
+
stateDir,
|
|
188
|
+
SERVER_VERSION,
|
|
189
|
+
serveClientAsset,
|
|
190
|
+
detectCrossRepoDrift,
|
|
191
|
+
});
|
|
212
192
|
|
|
213
193
|
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
214
194
|
// guardrails-allow PREVENT-PI-004: optional, user-triggered /dashboard localhost server (loopback-only) — CORS restricted to same-origin localhost browsers.
|
|
@@ -230,659 +210,16 @@ export async function launchDashboardServer(
|
|
|
230
210
|
return;
|
|
231
211
|
}
|
|
232
212
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
if (req.url === "/api/snapshot") {
|
|
244
|
-
const snap = readSnapshot(snapshotPath);
|
|
245
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
246
|
-
res.end(JSON.stringify(snap));
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Server version — lets the /dashboard launcher detect a stale server from
|
|
251
|
-
// an older build and replace it on upgrade rather than reuse it.
|
|
252
|
-
if (req.url === "/api/version") {
|
|
253
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
254
|
-
res.end(JSON.stringify({ version: SERVER_VERSION }));
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// Multi-repo aggregate (Phase 5b): the machine-wide repo registry read
|
|
259
|
-
// directly from SQLite (index.sqlite). Lets one dashboard show every repo's
|
|
260
|
-
// checkpoints, tokens saved, and active model. Read-only.
|
|
261
|
-
if (req.url === "/api/index") {
|
|
262
|
-
const idx = readIndex();
|
|
263
|
-
if (idx) overlayCurrentRepo(idx);
|
|
264
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
265
|
-
res.end(
|
|
266
|
-
JSON.stringify(idx ?? { updatedAt: null, summary: null, repos: [] }),
|
|
267
|
-
);
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// /api/repos — registry list. Optional `?active=24h` filters to repos
|
|
272
|
-
// seen within the last N hours (default: all). The dashboard uses this to
|
|
273
|
-
// drive its "active vs archived" badge without refetching /api/index.
|
|
274
|
-
if (req.url?.startsWith("/api/repos")) {
|
|
275
|
-
const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
|
|
276
|
-
const activeParam = url.searchParams.get("active");
|
|
277
|
-
const idx = readIndex();
|
|
278
|
-
if (idx) overlayCurrentRepo(idx);
|
|
279
|
-
let repos = idx?.repos ?? [];
|
|
280
|
-
if (activeParam) {
|
|
281
|
-
const m = /^(\d+)h$/.exec(activeParam);
|
|
282
|
-
if (m) {
|
|
283
|
-
const cutoffSec = Math.floor(Date.now() / 1000) - Number(m[1]) * 3600;
|
|
284
|
-
repos = repos.filter((r) => (r.lastSeen ?? 0) >= cutoffSec);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
288
|
-
res.end(
|
|
289
|
-
JSON.stringify({
|
|
290
|
-
updatedAt: idx?.updatedAt ?? null,
|
|
291
|
-
repos,
|
|
292
|
-
count: repos.length,
|
|
293
|
-
}),
|
|
294
|
-
);
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// /api/summary — header tiles without the full repo list (keeps payload
|
|
299
|
-
// small for embed scenarios). activeRepos mirrors the /api/repos?active=24h
|
|
300
|
-
// count so the dashboard can render the active badge alongside totals.
|
|
301
|
-
if (req.url?.startsWith("/api/summary")) {
|
|
302
|
-
const idx = readIndex();
|
|
303
|
-
if (idx) overlayCurrentRepo(idx);
|
|
304
|
-
const repos = idx?.repos ?? [];
|
|
305
|
-
const cutoffSec = Math.floor(Date.now() / 1000) - 24 * 3600;
|
|
306
|
-
const activeRepos = repos.filter(
|
|
307
|
-
(r) => (r.lastSeen ?? 0) >= cutoffSec,
|
|
308
|
-
).length;
|
|
309
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
310
|
-
res.end(
|
|
311
|
-
JSON.stringify({
|
|
312
|
-
updatedAt: idx?.updatedAt ?? null,
|
|
313
|
-
summary: idx?.summary ?? null,
|
|
314
|
-
activeRepos,
|
|
315
|
-
totalRepos: repos.length,
|
|
316
|
-
}),
|
|
317
|
-
);
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// /api/drift — R4: cross-repo drift report over repo_registry. Flags stale
|
|
322
|
-
// repos (>30d idle), compaction lag (active but >24h since last
|
|
323
|
-
// compaction), and recent model churn. Read-only.
|
|
324
|
-
if (req.url?.startsWith("/api/drift")) {
|
|
325
|
-
const report = detectCrossRepoDrift(getIndexDir());
|
|
326
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
327
|
-
res.end(JSON.stringify(report));
|
|
328
|
-
return;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
if (req.url === "/api/servers") {
|
|
332
|
-
try {
|
|
333
|
-
const idx = readIndex();
|
|
334
|
-
const nowSec = Math.floor(Date.now() / 1000);
|
|
335
|
-
const servers = (idx?.repos ?? [])
|
|
336
|
-
.filter((r) => (r.lastSeen ?? 0) >= nowSec - ACTIVE_WINDOW_SEC)
|
|
337
|
-
.map((r) => {
|
|
338
|
-
const out: Record<string, unknown> = {
|
|
339
|
-
repoRoot: r.repoRoot,
|
|
340
|
-
displayName: r.displayName,
|
|
341
|
-
model: r.modelName,
|
|
342
|
-
provider: r.providerName,
|
|
343
|
-
lastSeen: r.lastSeen,
|
|
344
|
-
lastCompactedAt: r.lastCompactedAt,
|
|
345
|
-
};
|
|
346
|
-
try {
|
|
347
|
-
const p = join(r.stateDir, "dashboard.json");
|
|
348
|
-
if (existsSync(p)) {
|
|
349
|
-
const snap = JSON.parse(
|
|
350
|
-
readFileSync(p, "utf-8"),
|
|
351
|
-
) as LiveSnapshot;
|
|
352
|
-
out.tier = snap.tier ?? null;
|
|
353
|
-
out.contextPct =
|
|
354
|
-
snap.context && snap.context.percent != null
|
|
355
|
-
? snap.context.percent
|
|
356
|
-
: null;
|
|
357
|
-
out.state = (snap.session && snap.session.state) || null;
|
|
358
|
-
out.cacheHits = snap.cacheHits ?? null;
|
|
359
|
-
out.compacts = snap.compacts ?? null;
|
|
360
|
-
out.timeSaved = snap.timeSaved ?? null;
|
|
361
|
-
out.updatedAt = snap.updatedAt ?? null;
|
|
362
|
-
}
|
|
363
|
-
} catch {
|
|
364
|
-
/* best-effort */
|
|
365
|
-
}
|
|
366
|
-
return out;
|
|
367
|
-
})
|
|
368
|
-
.sort((a, b) => (b.lastSeen as number) - (a.lastSeen as number));
|
|
369
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
370
|
-
res.end(
|
|
371
|
-
JSON.stringify({ updatedAt: new Date().toISOString(), servers }),
|
|
372
|
-
);
|
|
373
|
-
} catch {
|
|
374
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
375
|
-
res.end(JSON.stringify({ error: "servers_unavailable" }));
|
|
376
|
-
}
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
if (req.url === "/api/events") {
|
|
381
|
-
res.writeHead(200, {
|
|
382
|
-
"Content-Type": "text/event-stream",
|
|
383
|
-
"Cache-Control": "no-cache",
|
|
384
|
-
Connection: "keep-alive",
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
// Drain existing events so the client starts with history
|
|
388
|
-
const { data: existing, offset: initialOffset } = readFrom(eventsPath, 0);
|
|
389
|
-
eventOffset = initialOffset;
|
|
390
|
-
const lines = existing.split("\n").filter((l: string) => l.trim());
|
|
391
|
-
for (const line of lines) {
|
|
392
|
-
res.write(`data: ${line}\n\n`);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// Tail new events via fs.watch (coalesced with 100ms debounce)
|
|
396
|
-
let watchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
397
|
-
const onWatch = () => {
|
|
398
|
-
if (watchTimer) return;
|
|
399
|
-
watchTimer = setTimeout(() => {
|
|
400
|
-
watchTimer = null;
|
|
401
|
-
const { data, offset } = readFrom(eventsPath, eventOffset);
|
|
402
|
-
eventOffset = offset;
|
|
403
|
-
const newLines = data.split("\n").filter((l: string) => l.trim());
|
|
404
|
-
for (const line of newLines) {
|
|
405
|
-
res.write(`data: ${line}\n\n`);
|
|
406
|
-
}
|
|
407
|
-
}, 100);
|
|
408
|
-
};
|
|
409
|
-
|
|
410
|
-
// Set up file watching: if file exists, watch it directly;
|
|
411
|
-
// otherwise poll for creation every 1s then switch to fs.watch.
|
|
412
|
-
let watcher: ReturnType<typeof watch> | null = null;
|
|
413
|
-
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
|
414
|
-
|
|
415
|
-
function startFileWatch(): void {
|
|
416
|
-
try {
|
|
417
|
-
watcher = watch(eventsPath, onWatch);
|
|
418
|
-
} catch {
|
|
419
|
-
/* give up */
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
if (existsSync(eventsPath)) {
|
|
424
|
-
startFileWatch();
|
|
425
|
-
} else {
|
|
426
|
-
pollInterval = setInterval(() => {
|
|
427
|
-
if (existsSync(eventsPath)) {
|
|
428
|
-
if (pollInterval) {
|
|
429
|
-
clearInterval(pollInterval);
|
|
430
|
-
pollInterval = null;
|
|
431
|
-
}
|
|
432
|
-
startFileWatch();
|
|
433
|
-
}
|
|
434
|
-
}, 1000);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
req.on("close", () => {
|
|
438
|
-
if (watchTimer) clearTimeout(watchTimer);
|
|
439
|
-
if (pollInterval) clearInterval(pollInterval);
|
|
440
|
-
watcher?.close();
|
|
441
|
-
});
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
// /api/game-state — S32 game-mode settings (game_mode_on / theme /
|
|
446
|
-
// tui_display_mode). GET returns the current row; PUT applies a partial
|
|
447
|
-
// patch (validated) and returns the post-write row. The dashboard server is
|
|
448
|
-
// a detached child with no MegaRuntime ref, so it reads/writes the
|
|
449
|
-
// game_state SQLite row directly; the in-process MegaRuntime picks up the
|
|
450
|
-
// change via its fs.watch cache-eviction watcher. PREVENT-PI-004: loopback.
|
|
451
|
-
if (req.url?.startsWith("/api/game-state")) {
|
|
452
|
-
const gsReq = createRequire(import.meta.url);
|
|
453
|
-
const { getGameState, setGameState } = gsReq(
|
|
454
|
-
"../../src/store/sqlite.js",
|
|
455
|
-
) as typeof import("../../src/store/sqlite.js");
|
|
456
|
-
const { isValidTheme } = gsReq(
|
|
457
|
-
"../../src/config/themes.js",
|
|
458
|
-
) as typeof import("../../src/config/themes.js");
|
|
459
|
-
if (req.method === "GET") {
|
|
460
|
-
try {
|
|
461
|
-
const gs = getGameState(stateDir); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
|
|
462
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
463
|
-
res.end(JSON.stringify(gs));
|
|
464
|
-
} catch (e) {
|
|
465
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
466
|
-
res.end(
|
|
467
|
-
JSON.stringify({
|
|
468
|
-
error: "game_state_unavailable",
|
|
469
|
-
detail: String(e),
|
|
470
|
-
}),
|
|
471
|
-
);
|
|
472
|
-
}
|
|
473
|
-
return;
|
|
474
|
-
}
|
|
475
|
-
if (req.method === "PUT") {
|
|
476
|
-
// Read + parse the JSON body (capped — the patch is tiny). The handler
|
|
477
|
-
// is sync, so drain the stream via data/end listeners then continue.
|
|
478
|
-
let body = "";
|
|
479
|
-
let tooBig = false;
|
|
480
|
-
req.on("data", (chunk: Buffer) => {
|
|
481
|
-
// guardrails-allow PREVENT-PI-004: loopback dashboard request body (local)
|
|
482
|
-
if (body.length > 65536) {
|
|
483
|
-
tooBig = true;
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
body += chunk.toString();
|
|
487
|
-
});
|
|
488
|
-
req.on("end", () => {
|
|
489
|
-
if (tooBig) {
|
|
490
|
-
res.writeHead(413, { "Content-Type": "application/json" });
|
|
491
|
-
res.end(JSON.stringify({ error: "body_too_large" }));
|
|
492
|
-
return;
|
|
493
|
-
}
|
|
494
|
-
let patch: Record<string, unknown> = {};
|
|
495
|
-
try {
|
|
496
|
-
patch = body ? JSON.parse(body) : {};
|
|
497
|
-
} catch {
|
|
498
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
499
|
-
res.end(JSON.stringify({ error: "invalid_json" }));
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
// Reject valid-but-non-object JSON (null/[]/42) — dereferencing
|
|
503
|
-
// patch.game_mode_on would throw an unhandled TypeError inside this
|
|
504
|
-
// 'end' listener and crash the detached server (audit P1: loopback DoS).
|
|
505
|
-
if (
|
|
506
|
-
typeof patch !== "object" ||
|
|
507
|
-
patch === null ||
|
|
508
|
-
Array.isArray(patch)
|
|
509
|
-
) {
|
|
510
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
511
|
-
res.end(JSON.stringify({ error: "invalid_patch_object" }));
|
|
512
|
-
return;
|
|
513
|
-
}
|
|
514
|
-
// Validate the patch fields (unknown keys ignored; invalid values -> 400).
|
|
515
|
-
const clean: {
|
|
516
|
-
game_mode_on?: boolean;
|
|
517
|
-
theme?: string;
|
|
518
|
-
tui_display_mode?: "full" | "minimal";
|
|
519
|
-
} = {};
|
|
520
|
-
let bad = false;
|
|
521
|
-
if (patch.game_mode_on != null) {
|
|
522
|
-
if (typeof patch.game_mode_on !== "boolean") bad = true;
|
|
523
|
-
else clean.game_mode_on = patch.game_mode_on;
|
|
524
|
-
}
|
|
525
|
-
if (patch.theme != null) {
|
|
526
|
-
if (typeof patch.theme !== "string" || !isValidTheme(patch.theme))
|
|
527
|
-
bad = true;
|
|
528
|
-
else clean.theme = patch.theme;
|
|
529
|
-
}
|
|
530
|
-
if (patch.tui_display_mode != null) {
|
|
531
|
-
if (
|
|
532
|
-
patch.tui_display_mode !== "full" &&
|
|
533
|
-
patch.tui_display_mode !== "minimal"
|
|
534
|
-
)
|
|
535
|
-
bad = true;
|
|
536
|
-
else clean.tui_display_mode = patch.tui_display_mode;
|
|
537
|
-
}
|
|
538
|
-
if (bad) {
|
|
539
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
540
|
-
res.end(JSON.stringify({ error: "invalid_patch" }));
|
|
541
|
-
return;
|
|
542
|
-
}
|
|
543
|
-
try {
|
|
544
|
-
const gs = setGameState(clean, stateDir); // guardrails-allow PREVENT-PI-004: local SQLite write (loopback dashboard)
|
|
545
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
546
|
-
res.end(JSON.stringify(gs));
|
|
547
|
-
} catch (e) {
|
|
548
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
549
|
-
res.end(
|
|
550
|
-
JSON.stringify({
|
|
551
|
-
error: "game_state_write_failed",
|
|
552
|
-
detail: String(e),
|
|
553
|
-
}),
|
|
554
|
-
);
|
|
555
|
-
}
|
|
556
|
-
});
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
|
-
// Any other method on /api/game-state → 405.
|
|
560
|
-
res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
561
|
-
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
562
|
-
return;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
// /api/game-scores — S34 high-score leaderboards. GET returns the leaderboard
|
|
566
|
-
// for a metric (?metric=<m>&limit=<n>). `metric` is validated against the
|
|
567
|
-
// METRICS allow-list from src/game/scoring (re-exported via the sqlite barrel);
|
|
568
|
-
// default limit 10, clamped to [1,100]. The dashboard server is a detached
|
|
569
|
-
// child with no MegaRuntime ref, so it reads the game_scores SQLite table
|
|
570
|
-
// directly. Unknown metric -> 400, non-GET -> 405. PREVENT-PI-004: loopback.
|
|
571
|
-
if (req.url?.startsWith("/api/game-scores")) {
|
|
572
|
-
const gsReq = createRequire(import.meta.url);
|
|
573
|
-
const { leaderboard, METRICS } = gsReq(
|
|
574
|
-
"../../src/store/sqlite.js",
|
|
575
|
-
) as typeof import("../../src/store/sqlite.js");
|
|
576
|
-
if (req.method !== "GET") {
|
|
577
|
-
res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
578
|
-
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
579
|
-
return;
|
|
580
|
-
}
|
|
581
|
-
try {
|
|
582
|
-
const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
|
|
583
|
-
const metricParam = url.searchParams.get("metric") ?? "cache";
|
|
584
|
-
if (!(METRICS as readonly string[]).includes(metricParam)) {
|
|
585
|
-
res.writeHead(400, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
586
|
-
res.end(
|
|
587
|
-
JSON.stringify({ error: "unknown_metric", metric: metricParam }),
|
|
588
|
-
);
|
|
589
|
-
return;
|
|
590
|
-
}
|
|
591
|
-
const metric = metricParam as GameMetric; // validated against METRICS above
|
|
592
|
-
let limit = Number(url.searchParams.get("limit") ?? "10");
|
|
593
|
-
if (!Number.isFinite(limit) || limit <= 0) limit = 10;
|
|
594
|
-
limit = Math.min(Math.max(limit, 1), 100); // clamp to [1,100]
|
|
595
|
-
const rows = leaderboard(stateDir, metric, { limit }); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
|
|
596
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
597
|
-
res.end(JSON.stringify(rows));
|
|
598
|
-
} catch (e) {
|
|
599
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
600
|
-
res.end(
|
|
601
|
-
JSON.stringify({
|
|
602
|
-
error: "game_scores_unavailable",
|
|
603
|
-
detail: String(e),
|
|
604
|
-
}),
|
|
605
|
-
);
|
|
606
|
-
}
|
|
607
|
-
return;
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
// /api/perf — v0.8.8 Perf dashboard tab. GET returns rolling-window
|
|
611
|
-
// aggregates over perf_samples: per-kind p50/p95 (turn/provider latency,
|
|
612
|
-
// tps avg, db recompute, disk write), latest rss/heap, cpu user/sys delta,
|
|
613
|
-
// cache hit %, plus the diag recompute/skip/replay counts (read from
|
|
614
|
-
// dashboard.json snapshot if available). The dashboard server is a detached
|
|
615
|
-
// child with no MegaRuntime ref, so it reads perf_samples via a require()'d
|
|
616
|
-
// sqlite helper (same pattern as /api/game-scores). Unknown/invalid params
|
|
617
|
-
// are clamped (never throw). Non-GET -> 405. PREVENT-PI-004: loopback.
|
|
618
|
-
if (req.url?.startsWith("/api/perf")) {
|
|
619
|
-
const pfReq = createRequire(import.meta.url);
|
|
620
|
-
const { readPerfSamples } = pfReq(
|
|
621
|
-
"../../src/store/sqlite.js",
|
|
622
|
-
) as typeof import("../../src/store/sqlite.js");
|
|
623
|
-
if (req.method !== "GET") {
|
|
624
|
-
res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
625
|
-
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
626
|
-
return;
|
|
627
|
-
}
|
|
628
|
-
try {
|
|
629
|
-
const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
|
|
630
|
-
let minutes = Number(url.searchParams.get("minutes") ?? "30");
|
|
631
|
-
if (!Number.isFinite(minutes) || minutes <= 0) minutes = 30;
|
|
632
|
-
minutes = Math.min(minutes, 1440); // cap at 24h
|
|
633
|
-
const sinceTs = Date.now() - minutes * 60_000;
|
|
634
|
-
const rows = readPerfSamples(stateDir, sinceTs); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
|
|
635
|
-
const byKind = new Map<string, number[]>();
|
|
636
|
-
for (const r of rows) {
|
|
637
|
-
let arr = byKind.get(r.kind);
|
|
638
|
-
if (!arr) {
|
|
639
|
-
arr = [];
|
|
640
|
-
byKind.set(r.kind, arr);
|
|
641
|
-
}
|
|
642
|
-
arr.push(r.value);
|
|
643
|
-
}
|
|
644
|
-
// Nearest-rank percentile (ceil(p/100*n)-1, clamped). Code-controlled,
|
|
645
|
-
// never user input (PREVENT-002 safe).
|
|
646
|
-
function pct(arr: number[], p: number): number {
|
|
647
|
-
if (!arr.length) return 0;
|
|
648
|
-
const s = [...arr].sort((a, b) => a - b);
|
|
649
|
-
const idx = Math.min(
|
|
650
|
-
s.length - 1,
|
|
651
|
-
Math.max(0, Math.ceil((p / 100) * s.length) - 1),
|
|
652
|
-
);
|
|
653
|
-
return s[idx];
|
|
654
|
-
}
|
|
655
|
-
function avg(arr: number[]): number {
|
|
656
|
-
if (!arr.length) return 0;
|
|
657
|
-
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
|
658
|
-
}
|
|
659
|
-
// rows are ASC by ts, so the last pushed value is the most recent.
|
|
660
|
-
function latest(arr: number[]): number {
|
|
661
|
-
return arr.length ? arr[arr.length - 1] : 0;
|
|
662
|
-
}
|
|
663
|
-
const get = (k: string): number[] => byKind.get(k) ?? [];
|
|
664
|
-
// diag counters live in the runtime-written dashboard.json (the server is
|
|
665
|
-
// a detached child with no MegaRuntime ref). Read defensively — absent
|
|
666
|
-
// until the first snapshot() write (PREVENT-001: assign before access).
|
|
667
|
-
let diag: {
|
|
668
|
-
ctxFastGate: number;
|
|
669
|
-
liveTrimFires: number;
|
|
670
|
-
liveTrimReplays: number;
|
|
671
|
-
} | null = null;
|
|
672
|
-
try {
|
|
673
|
-
const raw = readFileSync(snapshotPath, "utf-8");
|
|
674
|
-
const parsed = JSON.parse(raw) as {
|
|
675
|
-
diag?: {
|
|
676
|
-
ctxFastGate: number;
|
|
677
|
-
liveTrimFires: number;
|
|
678
|
-
liveTrimReplays: number;
|
|
679
|
-
};
|
|
680
|
-
};
|
|
681
|
-
if (parsed && typeof parsed === "object" && parsed.diag)
|
|
682
|
-
diag = parsed.diag;
|
|
683
|
-
} catch {
|
|
684
|
-
/* dashboard.json not written yet */
|
|
685
|
-
}
|
|
686
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
687
|
-
res.end(
|
|
688
|
-
JSON.stringify({
|
|
689
|
-
updatedAt: new Date().toISOString(),
|
|
690
|
-
windowMinutes: minutes,
|
|
691
|
-
sampleCount: rows.length,
|
|
692
|
-
turn_latency_ms: {
|
|
693
|
-
p50: pct(get("turn_latency_ms"), 50),
|
|
694
|
-
p95: pct(get("turn_latency_ms"), 95),
|
|
695
|
-
n: get("turn_latency_ms").length,
|
|
696
|
-
},
|
|
697
|
-
provider_latency_ms: {
|
|
698
|
-
p50: pct(get("provider_latency_ms"), 50),
|
|
699
|
-
p95: pct(get("provider_latency_ms"), 95),
|
|
700
|
-
n: get("provider_latency_ms").length,
|
|
701
|
-
},
|
|
702
|
-
tps: { avg: avg(get("tps")), n: get("tps").length },
|
|
703
|
-
cache_hit_pct: {
|
|
704
|
-
avg: avg(get("cache_hit_pct")),
|
|
705
|
-
latest: latest(get("cache_hit_pct")),
|
|
706
|
-
n: get("cache_hit_pct").length,
|
|
707
|
-
},
|
|
708
|
-
db_recompute_ms: {
|
|
709
|
-
p50: pct(get("db_recompute_ms"), 50),
|
|
710
|
-
p95: pct(get("db_recompute_ms"), 95),
|
|
711
|
-
n: get("db_recompute_ms").length,
|
|
712
|
-
},
|
|
713
|
-
disk_write_ms: {
|
|
714
|
-
p50: pct(get("disk_write_ms"), 50),
|
|
715
|
-
p95: pct(get("disk_write_ms"), 95),
|
|
716
|
-
n: get("disk_write_ms").length,
|
|
717
|
-
},
|
|
718
|
-
rss_mb: { latest: latest(get("rss_mb")), n: get("rss_mb").length },
|
|
719
|
-
heap_mb: {
|
|
720
|
-
latest: latest(get("heap_mb")),
|
|
721
|
-
n: get("heap_mb").length,
|
|
722
|
-
},
|
|
723
|
-
cpu_user_ms: {
|
|
724
|
-
latest: latest(get("cpu_user_ms")),
|
|
725
|
-
n: get("cpu_user_ms").length,
|
|
726
|
-
},
|
|
727
|
-
cpu_sys_ms: {
|
|
728
|
-
latest: latest(get("cpu_sys_ms")),
|
|
729
|
-
n: get("cpu_sys_ms").length,
|
|
730
|
-
},
|
|
731
|
-
diag,
|
|
732
|
-
}),
|
|
733
|
-
);
|
|
734
|
-
} catch (e) {
|
|
735
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
736
|
-
res.end(
|
|
737
|
-
JSON.stringify({ error: "perf_unavailable", detail: String(e) }),
|
|
738
|
-
);
|
|
739
|
-
}
|
|
740
|
-
return;
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
// /api/achievements — S35 achievement tiles. GET returns the 9 seeded rows
|
|
744
|
-
// {id,title,description,icon,hidden,unlocked_at}. The dashboard server is a
|
|
745
|
-
// detached child with no MegaRuntime ref, so it reads game_achievements via
|
|
746
|
-
// listAchievements(stateDir) directly. Non-GET -> 405. PREVENT-PI-004: loopback.
|
|
747
|
-
if (req.url?.startsWith("/api/achievements")) {
|
|
748
|
-
const achReq = createRequire(import.meta.url);
|
|
749
|
-
const { listAchievements } = achReq(
|
|
750
|
-
"../../src/store/sqlite.js",
|
|
751
|
-
) as typeof import("../../src/store/sqlite.js");
|
|
752
|
-
if (req.method !== "GET") {
|
|
753
|
-
res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
754
|
-
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
755
|
-
return;
|
|
756
|
-
}
|
|
757
|
-
try {
|
|
758
|
-
const rows = listAchievements(stateDir); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
|
|
759
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
760
|
-
res.end(JSON.stringify(rows));
|
|
761
|
-
} catch (e) {
|
|
762
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
763
|
-
res.end(
|
|
764
|
-
JSON.stringify({
|
|
765
|
-
error: "achievements_unavailable",
|
|
766
|
-
detail: String(e),
|
|
767
|
-
}),
|
|
768
|
-
);
|
|
769
|
-
}
|
|
770
|
-
return;
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
// /api/sessions — S39: active pi sessions with latest token usage + heartbeat.
|
|
774
|
-
// GET returns {updatedAt, pruned, sessions[]} after pruning stale heartbeats.
|
|
775
|
-
// The dashboard server is a detached child with no MegaRuntime ref, so it
|
|
776
|
-
// reads session_heartbeats via a require()'d sqlite helper (same pattern as
|
|
777
|
-
// /api/achievements, /api/perf). Non-GET -> 405. PREVENT-PI-004: loopback.
|
|
778
|
-
if (req.url?.startsWith("/api/sessions")) {
|
|
779
|
-
// Guard: /api/sessions/timeseries handled separately below.
|
|
780
|
-
if (req.url.startsWith("/api/sessions/timeseries")) {
|
|
781
|
-
// Fall through to the timeseries handler below.
|
|
782
|
-
} else {
|
|
783
|
-
const sReq = createRequire(import.meta.url);
|
|
784
|
-
const {
|
|
785
|
-
readActiveSessions,
|
|
786
|
-
pruneStaleSessions,
|
|
787
|
-
listRepoRegistry,
|
|
788
|
-
} = sReq(
|
|
789
|
-
"../../src/store/sqlite.js",
|
|
790
|
-
) as typeof import("../../src/store/sqlite.js");
|
|
791
|
-
if (req.method !== "GET") {
|
|
792
|
-
res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
793
|
-
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
794
|
-
return;
|
|
795
|
-
}
|
|
796
|
-
try {
|
|
797
|
-
const pruned = pruneStaleSessions(); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
|
|
798
|
-
const active = readActiveSessions();
|
|
799
|
-
const repos = listRepoRegistry();
|
|
800
|
-
const repoMap = new Map(repos.map((r) => [r.repoRoot, r]));
|
|
801
|
-
const sessions = active.map((s) => ({
|
|
802
|
-
pid: s.pid,
|
|
803
|
-
sessionId: s.sessionId,
|
|
804
|
-
repoRoot: s.repoRoot,
|
|
805
|
-
displayName: s.repoRoot
|
|
806
|
-
? (s.repoRoot.split(/[\\/]/).filter(Boolean).pop() ?? s.repoRoot)
|
|
807
|
-
: (s.stateDir?.split(/[\\/]/).filter(Boolean).pop() ?? "unknown"),
|
|
808
|
-
model: s.repoRoot ? (repoMap.get(s.repoRoot)?.modelName ?? null) : null,
|
|
809
|
-
tokens: s.tokens,
|
|
810
|
-
percent: s.percent,
|
|
811
|
-
ctxWindow: s.ctxWindow,
|
|
812
|
-
lastSeen: s.lastSeen,
|
|
813
|
-
stateDir: s.stateDir,
|
|
814
|
-
}));
|
|
815
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
816
|
-
res.end(
|
|
817
|
-
JSON.stringify({ updatedAt: new Date().toISOString(), pruned, sessions }),
|
|
818
|
-
);
|
|
819
|
-
} catch (e) {
|
|
820
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
821
|
-
res.end(
|
|
822
|
-
JSON.stringify({ error: "sessions_unavailable", detail: String(e) }),
|
|
823
|
-
);
|
|
824
|
-
}
|
|
825
|
-
return;
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
// /api/sessions/timeseries — S39: stacked per-session token timeseries for
|
|
830
|
-
// the recharts memory graph. GET ?minutes=N (clamped [1,1440]) returns
|
|
831
|
-
// {updatedAt, windowMinutes, series[], totals[]} in recharts-ready shape.
|
|
832
|
-
// Non-GET -> 405. PREVENT-PI-004: loopback.
|
|
833
|
-
if (req.url?.startsWith("/api/sessions/timeseries")) {
|
|
834
|
-
const tsReq = createRequire(import.meta.url);
|
|
835
|
-
const {
|
|
836
|
-
readSessionTimeseries,
|
|
837
|
-
pruneTokenSamples,
|
|
838
|
-
} = tsReq(
|
|
839
|
-
"../../src/store/sqlite.js",
|
|
840
|
-
) as typeof import("../../src/store/sqlite.js");
|
|
841
|
-
if (req.method !== "GET") {
|
|
842
|
-
res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
843
|
-
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
844
|
-
return;
|
|
845
|
-
}
|
|
846
|
-
try {
|
|
847
|
-
const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
|
|
848
|
-
let minutes = Number(url.searchParams.get("minutes") ?? "30");
|
|
849
|
-
if (!Number.isFinite(minutes) || minutes <= 0) minutes = 30;
|
|
850
|
-
minutes = Math.min(Math.max(minutes, 1), 1440);
|
|
851
|
-
const sinceTs = Date.now() - minutes * 60_000;
|
|
852
|
-
const pruneMs = Math.max(minutes * 60_000, 1_800_000);
|
|
853
|
-
pruneTokenSamples(pruneMs); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
|
|
854
|
-
const result = readSessionTimeseries(sinceTs); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
|
|
855
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
856
|
-
res.end(
|
|
857
|
-
JSON.stringify({
|
|
858
|
-
updatedAt: new Date().toISOString(),
|
|
859
|
-
windowMinutes: minutes,
|
|
860
|
-
series: result.series,
|
|
861
|
-
totals: result.totals,
|
|
862
|
-
}),
|
|
863
|
-
);
|
|
864
|
-
} catch (e) {
|
|
865
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
866
|
-
res.end(
|
|
867
|
-
JSON.stringify({ error: "timeseries_unavailable", detail: String(e) }),
|
|
868
|
-
);
|
|
869
|
-
}
|
|
870
|
-
return;
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
// Fallback — serve the React client build (SPA route) or legacy dashboard.
|
|
874
|
-
// Non-/api/* GETs hit here: serve client assets if built, else inline HTML.
|
|
875
|
-
if (
|
|
876
|
-
req.method === "GET" &&
|
|
877
|
-
req.url &&
|
|
878
|
-
!req.url.startsWith("/api/") &&
|
|
879
|
-
serveClientAsset(req.url, res)
|
|
880
|
-
) {
|
|
881
|
-
return;
|
|
882
|
-
}
|
|
883
|
-
const tier = readSnapshot(snapshotPath).tier;
|
|
884
|
-
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
885
|
-
res.end(dashboardHtml(tier));
|
|
213
|
+
// Dispatch — each handler returns true if it ended the response.
|
|
214
|
+
if (handleIndex(req, res, ctx)) return;
|
|
215
|
+
if (handleRepoIndex(req, res, ctx)) return;
|
|
216
|
+
if (handleEvents(req, res, ctx)) return;
|
|
217
|
+
if (handleGameState(req, res, ctx)) return;
|
|
218
|
+
if (handleGameScores(req, res, ctx)) return;
|
|
219
|
+
if (handlePerf(req, res, ctx)) return;
|
|
220
|
+
if (handleAchievements(req, res, ctx)) return;
|
|
221
|
+
if (handleSessions(req, res, ctx)) return;
|
|
222
|
+
handleStatic(req, res, ctx);
|
|
886
223
|
});
|
|
887
224
|
|
|
888
225
|
// Bind base + range are env-configurable so tests can use a private,
|
|
@@ -983,4 +320,4 @@ if (process.argv[1] && process.argv[1].includes("dashboard-server")) {
|
|
|
983
320
|
console.error("[mega-compact] dashboard server failed:", err);
|
|
984
321
|
process.exit(1);
|
|
985
322
|
});
|
|
986
|
-
}
|
|
323
|
+
}
|