pi-mega-compact 0.8.7 → 0.8.10
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/dist/extensions/dashboard-server/api-contracts/core.js +7 -0
- package/dist/extensions/dashboard-server/api-contracts/endpoints.js +104 -0
- package/dist/extensions/dashboard-server/api-contracts/game.js +9 -0
- package/dist/extensions/dashboard-server/api-contracts/index.js +9 -0
- package/dist/extensions/dashboard-server/api-contracts/infrastructure.js +10 -0
- package/dist/extensions/dashboard-server/api-contracts/multi-repo.js +9 -0
- package/dist/extensions/dashboard-server/api-contracts/snapshot.js +8 -0
- package/dist/extensions/dashboard-server/api-contracts.js +8 -0
- package/dist/extensions/dashboard-server/api-contracts.test.js +869 -0
- package/dist/extensions/dashboard-server/auth.js +18 -0
- package/dist/extensions/dashboard-server/helpers.js +37 -0
- package/dist/extensions/dashboard-server/html/all-repos-tab.js +26 -0
- package/dist/extensions/dashboard-server/html/body-open.js +23 -0
- package/dist/extensions/dashboard-server/html/current-repo-tab.js +130 -0
- package/dist/extensions/dashboard-server/html/head-open.js +16 -0
- package/dist/extensions/dashboard-server/html/high-score-tab.js +25 -0
- package/dist/extensions/dashboard-server/html/repo-detail-modal.js +26 -0
- package/dist/extensions/dashboard-server/html/script.js +259 -0
- package/dist/extensions/dashboard-server/html/styles.js +103 -0
- package/dist/extensions/dashboard-server/html/summary-tab.js +19 -0
- package/dist/extensions/dashboard-server/html-template.js +41 -0
- package/dist/extensions/dashboard-server/html.js +46 -1
- package/dist/extensions/dashboard-server/perf-server.test.js +80 -0
- package/dist/extensions/dashboard-server/server.js +269 -30
- package/dist/extensions/dashboard-server/tailscale.js +8 -0
- package/dist/extensions/dashboard-server/types.js +4 -0
- package/dist/extensions/mega-dashboard.js +9 -0
- package/dist/extensions/mega-events/perf-handler.js +71 -0
- package/dist/extensions/mega-events/register.js +2 -0
- package/dist/extensions/mega-events.js +1 -0
- package/dist/extensions/mega-runtime/state.js +59 -1
- package/dist/src/store/sqlite/connection.js +35 -0
- package/dist/src/store/sqlite/index-store.js +167 -0
- package/dist/src/store/sqlite/memory.js +54 -0
- package/dist/src/store/sqlite/minhash-lsh.js +47 -0
- package/dist/src/store/sqlite/perf-samples.js +81 -0
- package/dist/src/store/sqlite/perf-samples.test.js +54 -0
- package/dist/src/store/sqlite/schema.js +14 -0
- package/dist/src/store/sqlite/sessions.js +39 -0
- package/dist/src/store/sqlite/transaction.js +19 -0
- package/dist/src/store/sqlite.js +1 -0
- package/dist/src/vectorStore/add.js +260 -0
- package/dist/src/vectorStore/dedup.js +52 -0
- package/dist/src/vectorStore/index.js +10 -0
- package/dist/src/vectorStore/queries.js +83 -0
- package/dist/src/vectorStore/search.js +95 -0
- package/dist/src/vectorStore/session.js +19 -0
- package/dist/src/vectorStore/store.js +105 -0
- package/dist/src/vectorStore/types.js +6 -0
- package/dist/src/vectorStore/utils.js +23 -0
- package/extensions/dashboard-client/package-lock.json +1771 -0
- package/extensions/dashboard-client/package.json +27 -0
- package/extensions/dashboard-client/src/App.tsx +82 -0
- package/extensions/dashboard-client/src/api/client.ts +145 -0
- package/extensions/dashboard-client/src/components/CacheStatusPerModel.tsx +44 -0
- package/extensions/dashboard-client/src/components/CompressionCard.tsx +61 -0
- package/extensions/dashboard-client/src/components/ContextGauge.tsx +59 -0
- package/extensions/dashboard-client/src/components/DataSafetyCard.tsx +49 -0
- package/extensions/dashboard-client/src/components/ErrorBoundary.tsx +53 -0
- package/extensions/dashboard-client/src/components/EventCategoryFilter.tsx +16 -0
- package/extensions/dashboard-client/src/components/EventStream.tsx +152 -0
- package/extensions/dashboard-client/src/components/LoadingSpinner.tsx +7 -0
- package/extensions/dashboard-client/src/components/MemoryStatusCard.tsx +24 -0
- package/extensions/dashboard-client/src/components/ModelBadge.tsx +41 -0
- package/extensions/dashboard-client/src/components/PerfChart.tsx +160 -0
- package/extensions/dashboard-client/src/components/RepoDetailModal.tsx +126 -0
- package/extensions/dashboard-client/src/components/RepoTable.tsx +150 -0
- package/extensions/dashboard-client/src/components/SessionInfo.tsx +65 -0
- package/extensions/dashboard-client/src/components/SummaryTiles.tsx +52 -0
- package/extensions/dashboard-client/src/components/TabBar.tsx +34 -0
- package/extensions/dashboard-client/src/components/TriggerStatus.tsx +62 -0
- package/extensions/dashboard-client/src/hooks/useApi.ts +77 -0
- package/extensions/dashboard-client/src/hooks/useSSE.ts +87 -0
- package/extensions/dashboard-client/src/index.html +12 -0
- package/extensions/dashboard-client/src/main.tsx +23 -0
- package/extensions/dashboard-client/src/styles/base.css +121 -0
- package/extensions/dashboard-client/src/styles/overview-events.css +318 -0
- package/extensions/dashboard-client/src/styles/repos-metrics.css +307 -0
- package/extensions/dashboard-client/src/tabs/ConfigTab.tsx +16 -0
- package/extensions/dashboard-client/src/tabs/EventsTab.tsx +37 -0
- package/extensions/dashboard-client/src/tabs/MetricsTab.tsx +49 -0
- package/extensions/dashboard-client/src/tabs/OverviewTab.tsx +80 -0
- package/extensions/dashboard-client/src/tabs/ReposTab.tsx +59 -0
- package/extensions/dashboard-client/tsconfig.json +28 -0
- package/extensions/dashboard-client/vite.config.ts +38 -0
- package/extensions/dashboard-server/api-contracts/core.ts +302 -0
- package/extensions/dashboard-server/api-contracts/endpoints.ts +476 -0
- package/extensions/dashboard-server/api-contracts/game.ts +145 -0
- package/extensions/dashboard-server/api-contracts/index.ts +159 -0
- package/extensions/dashboard-server/api-contracts/infrastructure.ts +465 -0
- package/extensions/dashboard-server/api-contracts/multi-repo.ts +328 -0
- package/extensions/dashboard-server/api-contracts/snapshot.ts +386 -0
- package/extensions/dashboard-server/api-contracts.test.ts +1050 -0
- package/extensions/dashboard-server/api-contracts.ts +96 -0
- package/extensions/dashboard-server/auth.ts +20 -0
- package/extensions/dashboard-server/html.ts +46 -1
- package/extensions/dashboard-server/perf-server.test.ts +101 -0
- package/extensions/dashboard-server/server.ts +862 -503
- package/extensions/dashboard-server/tailscale.ts +7 -0
- package/extensions/dashboard-server/types.ts +23 -114
- package/extensions/mega-dashboard.ts +17 -0
- package/extensions/mega-events/perf-handler.ts +113 -0
- package/extensions/mega-events/register.ts +2 -0
- package/extensions/mega-events.ts +1 -0
- package/extensions/mega-runtime/state.ts +57 -0
- package/package.json +2 -1
- package/src/store/sqlite/perf-samples.test.ts +65 -0
- package/src/store/sqlite/perf-samples.ts +125 -0
- package/src/store/sqlite/schema.ts +14 -0
- package/src/store/sqlite.ts +1 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dashboard-server/server.ts — HTTP server creation + launch + CLI entry point.
|
|
3
3
|
*/
|
|
4
|
-
import { createServer } from "node:http";
|
|
5
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
|
|
4
|
+
import { createServer, } from "node:http";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync, } from "node:fs";
|
|
6
6
|
import { join, dirname } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { createRequire } from "node:module";
|
|
@@ -16,12 +16,14 @@ export async function launchDashboardServer(stateDir) {
|
|
|
16
16
|
// detect a stale server (started by an older build) and replace it on
|
|
17
17
|
// upgrade instead of reuse it.
|
|
18
18
|
let SERVER_VERSION = "0.0.0";
|
|
19
|
+
// `here` is hoisted out of the version-detection try block so the
|
|
20
|
+
// dashboard-client dist path (Sprint B1) can reuse it without recompute.
|
|
21
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
22
|
try {
|
|
20
23
|
// Since v0.7.9 (8821ef3) dashboard-server.js lives at
|
|
21
24
|
// <pkg>/dist/extensions/dashboard-server/, so package.json is THREE levels
|
|
22
25
|
// up. Keep the two- and one-level-up candidates as fallbacks for flatter
|
|
23
26
|
// dev-checkout layouts. Guard each candidate so a missing file is skipped.
|
|
24
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
25
27
|
const candidates = [
|
|
26
28
|
join(here, "..", "..", "..", "package.json"),
|
|
27
29
|
join(here, "..", "..", "package.json"),
|
|
@@ -38,16 +40,68 @@ export async function launchDashboardServer(stateDir) {
|
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
|
-
catch {
|
|
43
|
+
catch {
|
|
44
|
+
/* non-fatal */
|
|
45
|
+
}
|
|
42
46
|
// Lazy-loaded via require so the dashboard stays cheap to boot and we don't
|
|
43
47
|
// need a top-level await in the handler.
|
|
44
48
|
const driftReq = createRequire(import.meta.url);
|
|
45
|
-
const detectCrossRepoDrift = (idxDir) => driftReq("../../src/driftDetection.js")
|
|
46
|
-
.detectCrossRepoDrift(idxDir);
|
|
49
|
+
const detectCrossRepoDrift = (idxDir) => driftReq("../../src/driftDetection.js").detectCrossRepoDrift(idxDir);
|
|
47
50
|
const portFile = join(stateDir, "port.pid");
|
|
48
51
|
const snapshotPath = join(stateDir, "dashboard.json");
|
|
49
52
|
const eventsPath = join(stateDir, "events.log");
|
|
50
53
|
setLogPath(join(stateDir, "dashboard.log"));
|
|
54
|
+
// ── React client build (Sprint B1) ────────────────────────────────────
|
|
55
|
+
// If the Vite-built dashboard-client bundle is present, serve it as the
|
|
56
|
+
// dashboard UI (SPA fallback for all non-/api/* routes). If absent, fall
|
|
57
|
+
// back to the legacy inline html.ts template. Candidate paths cover both
|
|
58
|
+
// the dist/ build layout and a flat dev checkout (mirrors the package.json
|
|
59
|
+
// candidate pattern above).
|
|
60
|
+
const clientDistCandidates = [
|
|
61
|
+
join(here, "..", "dashboard-client", "dist"), // dist/extensions/dashboard-client/dist
|
|
62
|
+
join(here, "..", "..", "dashboard-client", "dist"), // dist/dashboard-client/dist (flat)
|
|
63
|
+
join(here, "..", "..", "..", "extensions", "dashboard-client", "dist"), // repo-root extensions/dashboard-client/dist (dist build)
|
|
64
|
+
join(here, "..", "dashboard-client", "dist"), // dev: extensions/dashboard-server/../dashboard-client/dist
|
|
65
|
+
];
|
|
66
|
+
const clientDist = clientDistCandidates.find((p) => existsSync(join(p, "index.html"))) ??
|
|
67
|
+
clientDistCandidates[0];
|
|
68
|
+
const clientIndexHtml = join(clientDist, "index.html");
|
|
69
|
+
const hasClientBuild = existsSync(clientIndexHtml);
|
|
70
|
+
if (hasClientBuild)
|
|
71
|
+
log("client build present", { clientDist });
|
|
72
|
+
// guardrails-allow PREVENT-PI-004: read-only static file serving from the local dashboard-client/dist bundle (loopback-only UI).
|
|
73
|
+
const serveClientAsset = (reqPath, res) => {
|
|
74
|
+
if (!hasClientBuild)
|
|
75
|
+
return false;
|
|
76
|
+
// Normalize: strip query, prevent path traversal, map "/" to index.html.
|
|
77
|
+
const clean = reqPath.split("?")[0];
|
|
78
|
+
if (clean.includes(".."))
|
|
79
|
+
return false;
|
|
80
|
+
const rel = clean === "/" || clean === "" ? "index.html" : clean.replace(/^\//, "");
|
|
81
|
+
const file = join(clientDist, rel);
|
|
82
|
+
if (!file.startsWith(clientDist) || !existsSync(file)) {
|
|
83
|
+
// SPA fallback: unknown non-asset routes serve index.html (client-side routing).
|
|
84
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
85
|
+
res.end(readFileSync(clientIndexHtml));
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
const ext = rel.slice(rel.lastIndexOf(".") + 1);
|
|
89
|
+
const types = {
|
|
90
|
+
html: "text/html; charset=utf-8",
|
|
91
|
+
js: "text/javascript",
|
|
92
|
+
css: "text/css",
|
|
93
|
+
json: "application/json",
|
|
94
|
+
svg: "image/svg+xml",
|
|
95
|
+
png: "image/png",
|
|
96
|
+
ico: "image/x-icon",
|
|
97
|
+
map: "application/json",
|
|
98
|
+
};
|
|
99
|
+
res.writeHead(200, {
|
|
100
|
+
"Content-Type": types[ext] ?? "application/octet-stream",
|
|
101
|
+
});
|
|
102
|
+
res.end(readFileSync(file));
|
|
103
|
+
return true;
|
|
104
|
+
};
|
|
51
105
|
log("launch invoked", { stateDir });
|
|
52
106
|
// ── Existing server? ───────────────────────────────────────────────────────
|
|
53
107
|
// A stale port.pid pointing at a dead/competing process is the classic cause
|
|
@@ -70,7 +124,9 @@ export async function launchDashboardServer(stateDir) {
|
|
|
70
124
|
log("reusing live server from port.pid", { port: info.port });
|
|
71
125
|
return { port: info.port, url: `http://localhost:${info.port}` }; // guardrails-allow PREVENT-PI-004: localhost dashboard URL (loopback-only)
|
|
72
126
|
}
|
|
73
|
-
log("port.pid present but no live server — treating as stale", {
|
|
127
|
+
log("port.pid present but no live server — treating as stale", {
|
|
128
|
+
port: info.port,
|
|
129
|
+
});
|
|
74
130
|
}
|
|
75
131
|
}
|
|
76
132
|
catch {
|
|
@@ -81,7 +137,9 @@ export async function launchDashboardServer(stateDir) {
|
|
|
81
137
|
try {
|
|
82
138
|
unlinkSync(portFile);
|
|
83
139
|
}
|
|
84
|
-
catch {
|
|
140
|
+
catch {
|
|
141
|
+
/* ignore */
|
|
142
|
+
}
|
|
85
143
|
}
|
|
86
144
|
// ── New server ────────────────────────────────────────────────────────────
|
|
87
145
|
mkdirSync(stateDir, { recursive: true });
|
|
@@ -112,7 +170,9 @@ export async function launchDashboardServer(stateDir) {
|
|
|
112
170
|
const prevCp = cur.checkpointCount;
|
|
113
171
|
const prevBytes = cur.compressedOriginalBytes;
|
|
114
172
|
const comp = snap.compression?.repo;
|
|
115
|
-
const liveSaved = comp
|
|
173
|
+
const liveSaved = comp
|
|
174
|
+
? comp.tokensFreed
|
|
175
|
+
: (snap.repo.tokensSaved ?? prevSaved);
|
|
116
176
|
const liveCp = snap.repo.checkpointCount ?? prevCp;
|
|
117
177
|
const liveBytes = snap.integrity?.compressedOriginalBytes ?? prevBytes;
|
|
118
178
|
cur.tokensSaved = liveSaved;
|
|
@@ -126,9 +186,14 @@ export async function launchDashboardServer(stateDir) {
|
|
|
126
186
|
idx.updatedAt = snap.updatedAt ?? idx.updatedAt;
|
|
127
187
|
}
|
|
128
188
|
const server = createServer((req, res) => {
|
|
129
|
-
// guardrails-allow PREVENT-PI-004: optional, user-triggered /dashboard localhost server (loopback-only) — CORS
|
|
130
|
-
// CORS for local access
|
|
131
|
-
|
|
189
|
+
// guardrails-allow PREVENT-PI-004: optional, user-triggered /dashboard localhost server (loopback-only) — CORS restricted to same-origin localhost browsers.
|
|
190
|
+
// CORS for local access — restricted to loopback origins (the dashboard server only binds to localhost).
|
|
191
|
+
const origin = req.headers.origin;
|
|
192
|
+
if (typeof origin === "string" &&
|
|
193
|
+
/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
|
|
194
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
195
|
+
res.setHeader("Vary", "Origin");
|
|
196
|
+
}
|
|
132
197
|
res.setHeader("Access-Control-Allow-Methods", "GET, PUT, OPTIONS");
|
|
133
198
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
134
199
|
if (req.method === "OPTIONS") {
|
|
@@ -137,6 +202,10 @@ export async function launchDashboardServer(stateDir) {
|
|
|
137
202
|
return;
|
|
138
203
|
}
|
|
139
204
|
if (req.url === "/" || req.url === "/index.html") {
|
|
205
|
+
// Sprint B1: prefer the React client build when present; fall back to the
|
|
206
|
+
// legacy inline html.ts template when the client dist is absent.
|
|
207
|
+
if (serveClientAsset("/", res))
|
|
208
|
+
return;
|
|
140
209
|
const tier = readSnapshot(snapshotPath).tier;
|
|
141
210
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
142
211
|
res.end(dashboardHtml(tier));
|
|
@@ -184,7 +253,11 @@ export async function launchDashboardServer(stateDir) {
|
|
|
184
253
|
}
|
|
185
254
|
}
|
|
186
255
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
187
|
-
res.end(JSON.stringify({
|
|
256
|
+
res.end(JSON.stringify({
|
|
257
|
+
updatedAt: idx?.updatedAt ?? null,
|
|
258
|
+
repos,
|
|
259
|
+
count: repos.length,
|
|
260
|
+
}));
|
|
188
261
|
return;
|
|
189
262
|
}
|
|
190
263
|
// /api/summary — header tiles without the full repo list (keeps payload
|
|
@@ -219,14 +292,26 @@ export async function launchDashboardServer(stateDir) {
|
|
|
219
292
|
try {
|
|
220
293
|
const idx = readIndex();
|
|
221
294
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
222
|
-
const servers = (idx?.repos ?? [])
|
|
223
|
-
|
|
295
|
+
const servers = (idx?.repos ?? [])
|
|
296
|
+
.filter((r) => (r.lastSeen ?? 0) >= nowSec - ACTIVE_WINDOW_SEC)
|
|
297
|
+
.map((r) => {
|
|
298
|
+
const out = {
|
|
299
|
+
repoRoot: r.repoRoot,
|
|
300
|
+
displayName: r.displayName,
|
|
301
|
+
model: r.modelName,
|
|
302
|
+
provider: r.providerName,
|
|
303
|
+
lastSeen: r.lastSeen,
|
|
304
|
+
lastCompactedAt: r.lastCompactedAt,
|
|
305
|
+
};
|
|
224
306
|
try {
|
|
225
307
|
const p = join(r.stateDir, "dashboard.json");
|
|
226
308
|
if (existsSync(p)) {
|
|
227
309
|
const snap = JSON.parse(readFileSync(p, "utf-8"));
|
|
228
310
|
out.tier = snap.tier ?? null;
|
|
229
|
-
out.contextPct =
|
|
311
|
+
out.contextPct =
|
|
312
|
+
snap.context && snap.context.percent != null
|
|
313
|
+
? snap.context.percent
|
|
314
|
+
: null;
|
|
230
315
|
out.state = (snap.session && snap.session.state) || null;
|
|
231
316
|
out.cacheHits = snap.cacheHits ?? null;
|
|
232
317
|
out.compacts = snap.compacts ?? null;
|
|
@@ -234,9 +319,12 @@ export async function launchDashboardServer(stateDir) {
|
|
|
234
319
|
out.updatedAt = snap.updatedAt ?? null;
|
|
235
320
|
}
|
|
236
321
|
}
|
|
237
|
-
catch {
|
|
322
|
+
catch {
|
|
323
|
+
/* best-effort */
|
|
324
|
+
}
|
|
238
325
|
return out;
|
|
239
|
-
})
|
|
326
|
+
})
|
|
327
|
+
.sort((a, b) => b.lastSeen - a.lastSeen);
|
|
240
328
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
241
329
|
res.end(JSON.stringify({ updatedAt: new Date().toISOString(), servers }));
|
|
242
330
|
}
|
|
@@ -250,7 +338,7 @@ export async function launchDashboardServer(stateDir) {
|
|
|
250
338
|
res.writeHead(200, {
|
|
251
339
|
"Content-Type": "text/event-stream",
|
|
252
340
|
"Cache-Control": "no-cache",
|
|
253
|
-
|
|
341
|
+
Connection: "keep-alive",
|
|
254
342
|
});
|
|
255
343
|
// Drain existing events so the client starts with history
|
|
256
344
|
const { data: existing, offset: initialOffset } = readFrom(eventsPath, 0);
|
|
@@ -282,7 +370,9 @@ export async function launchDashboardServer(stateDir) {
|
|
|
282
370
|
try {
|
|
283
371
|
watcher = watch(eventsPath, onWatch);
|
|
284
372
|
}
|
|
285
|
-
catch {
|
|
373
|
+
catch {
|
|
374
|
+
/* give up */
|
|
375
|
+
}
|
|
286
376
|
}
|
|
287
377
|
if (existsSync(eventsPath)) {
|
|
288
378
|
startFileWatch();
|
|
@@ -325,7 +415,10 @@ export async function launchDashboardServer(stateDir) {
|
|
|
325
415
|
}
|
|
326
416
|
catch (e) {
|
|
327
417
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
328
|
-
res.end(JSON.stringify({
|
|
418
|
+
res.end(JSON.stringify({
|
|
419
|
+
error: "game_state_unavailable",
|
|
420
|
+
detail: String(e),
|
|
421
|
+
}));
|
|
329
422
|
}
|
|
330
423
|
return;
|
|
331
424
|
}
|
|
@@ -335,6 +428,7 @@ export async function launchDashboardServer(stateDir) {
|
|
|
335
428
|
let body = "";
|
|
336
429
|
let tooBig = false;
|
|
337
430
|
req.on("data", (chunk) => {
|
|
431
|
+
// guardrails-allow PREVENT-PI-004: loopback dashboard request body (local)
|
|
338
432
|
if (body.length > 65536) {
|
|
339
433
|
tooBig = true;
|
|
340
434
|
return;
|
|
@@ -359,7 +453,9 @@ export async function launchDashboardServer(stateDir) {
|
|
|
359
453
|
// Reject valid-but-non-object JSON (null/[]/42) — dereferencing
|
|
360
454
|
// patch.game_mode_on would throw an unhandled TypeError inside this
|
|
361
455
|
// 'end' listener and crash the detached server (audit P1: loopback DoS).
|
|
362
|
-
if (typeof patch !== "object" ||
|
|
456
|
+
if (typeof patch !== "object" ||
|
|
457
|
+
patch === null ||
|
|
458
|
+
Array.isArray(patch)) {
|
|
363
459
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
364
460
|
res.end(JSON.stringify({ error: "invalid_patch_object" }));
|
|
365
461
|
return;
|
|
@@ -380,7 +476,8 @@ export async function launchDashboardServer(stateDir) {
|
|
|
380
476
|
clean.theme = patch.theme;
|
|
381
477
|
}
|
|
382
478
|
if (patch.tui_display_mode != null) {
|
|
383
|
-
if (patch.tui_display_mode !== "full" &&
|
|
479
|
+
if (patch.tui_display_mode !== "full" &&
|
|
480
|
+
patch.tui_display_mode !== "minimal")
|
|
384
481
|
bad = true;
|
|
385
482
|
else
|
|
386
483
|
clean.tui_display_mode = patch.tui_display_mode;
|
|
@@ -397,7 +494,10 @@ export async function launchDashboardServer(stateDir) {
|
|
|
397
494
|
}
|
|
398
495
|
catch (e) {
|
|
399
496
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
400
|
-
res.end(JSON.stringify({
|
|
497
|
+
res.end(JSON.stringify({
|
|
498
|
+
error: "game_state_write_failed",
|
|
499
|
+
detail: String(e),
|
|
500
|
+
}));
|
|
401
501
|
}
|
|
402
502
|
});
|
|
403
503
|
return;
|
|
@@ -440,7 +540,128 @@ export async function launchDashboardServer(stateDir) {
|
|
|
440
540
|
}
|
|
441
541
|
catch (e) {
|
|
442
542
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
443
|
-
res.end(JSON.stringify({
|
|
543
|
+
res.end(JSON.stringify({
|
|
544
|
+
error: "game_scores_unavailable",
|
|
545
|
+
detail: String(e),
|
|
546
|
+
}));
|
|
547
|
+
}
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
// /api/perf — v0.8.8 Perf dashboard tab. GET returns rolling-window
|
|
551
|
+
// aggregates over perf_samples: per-kind p50/p95 (turn/provider latency,
|
|
552
|
+
// tps avg, db recompute, disk write), latest rss/heap, cpu user/sys delta,
|
|
553
|
+
// cache hit %, plus the diag recompute/skip/replay counts (read from
|
|
554
|
+
// dashboard.json snapshot if available). The dashboard server is a detached
|
|
555
|
+
// child with no MegaRuntime ref, so it reads perf_samples via a require()'d
|
|
556
|
+
// sqlite helper (same pattern as /api/game-scores). Unknown/invalid params
|
|
557
|
+
// are clamped (never throw). Non-GET -> 405. PREVENT-PI-004: loopback.
|
|
558
|
+
if (req.url?.startsWith("/api/perf")) {
|
|
559
|
+
const pfReq = createRequire(import.meta.url);
|
|
560
|
+
const { readPerfSamples } = pfReq("../../src/store/sqlite.js");
|
|
561
|
+
if (req.method !== "GET") {
|
|
562
|
+
res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
563
|
+
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
try {
|
|
567
|
+
const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
|
|
568
|
+
let minutes = Number(url.searchParams.get("minutes") ?? "30");
|
|
569
|
+
if (!Number.isFinite(minutes) || minutes <= 0)
|
|
570
|
+
minutes = 30;
|
|
571
|
+
minutes = Math.min(minutes, 1440); // cap at 24h
|
|
572
|
+
const sinceTs = Date.now() - minutes * 60_000;
|
|
573
|
+
const rows = readPerfSamples(stateDir, sinceTs); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
|
|
574
|
+
const byKind = new Map();
|
|
575
|
+
for (const r of rows) {
|
|
576
|
+
let arr = byKind.get(r.kind);
|
|
577
|
+
if (!arr) {
|
|
578
|
+
arr = [];
|
|
579
|
+
byKind.set(r.kind, arr);
|
|
580
|
+
}
|
|
581
|
+
arr.push(r.value);
|
|
582
|
+
}
|
|
583
|
+
// Nearest-rank percentile (ceil(p/100*n)-1, clamped). Code-controlled,
|
|
584
|
+
// never user input (PREVENT-002 safe).
|
|
585
|
+
function pct(arr, p) {
|
|
586
|
+
if (!arr.length)
|
|
587
|
+
return 0;
|
|
588
|
+
const s = [...arr].sort((a, b) => a - b);
|
|
589
|
+
const idx = Math.min(s.length - 1, Math.max(0, Math.ceil((p / 100) * s.length) - 1));
|
|
590
|
+
return s[idx];
|
|
591
|
+
}
|
|
592
|
+
function avg(arr) {
|
|
593
|
+
if (!arr.length)
|
|
594
|
+
return 0;
|
|
595
|
+
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
|
596
|
+
}
|
|
597
|
+
// rows are ASC by ts, so the last pushed value is the most recent.
|
|
598
|
+
function latest(arr) {
|
|
599
|
+
return arr.length ? arr[arr.length - 1] : 0;
|
|
600
|
+
}
|
|
601
|
+
const get = (k) => byKind.get(k) ?? [];
|
|
602
|
+
// diag counters live in the runtime-written dashboard.json (the server is
|
|
603
|
+
// a detached child with no MegaRuntime ref). Read defensively — absent
|
|
604
|
+
// until the first snapshot() write (PREVENT-001: assign before access).
|
|
605
|
+
let diag = null;
|
|
606
|
+
try {
|
|
607
|
+
const raw = readFileSync(snapshotPath, "utf-8");
|
|
608
|
+
const parsed = JSON.parse(raw);
|
|
609
|
+
if (parsed && typeof parsed === "object" && parsed.diag)
|
|
610
|
+
diag = parsed.diag;
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
/* dashboard.json not written yet */
|
|
614
|
+
}
|
|
615
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
616
|
+
res.end(JSON.stringify({
|
|
617
|
+
updatedAt: new Date().toISOString(),
|
|
618
|
+
windowMinutes: minutes,
|
|
619
|
+
sampleCount: rows.length,
|
|
620
|
+
turn_latency_ms: {
|
|
621
|
+
p50: pct(get("turn_latency_ms"), 50),
|
|
622
|
+
p95: pct(get("turn_latency_ms"), 95),
|
|
623
|
+
n: get("turn_latency_ms").length,
|
|
624
|
+
},
|
|
625
|
+
provider_latency_ms: {
|
|
626
|
+
p50: pct(get("provider_latency_ms"), 50),
|
|
627
|
+
p95: pct(get("provider_latency_ms"), 95),
|
|
628
|
+
n: get("provider_latency_ms").length,
|
|
629
|
+
},
|
|
630
|
+
tps: { avg: avg(get("tps")), n: get("tps").length },
|
|
631
|
+
cache_hit_pct: {
|
|
632
|
+
avg: avg(get("cache_hit_pct")),
|
|
633
|
+
latest: latest(get("cache_hit_pct")),
|
|
634
|
+
n: get("cache_hit_pct").length,
|
|
635
|
+
},
|
|
636
|
+
db_recompute_ms: {
|
|
637
|
+
p50: pct(get("db_recompute_ms"), 50),
|
|
638
|
+
p95: pct(get("db_recompute_ms"), 95),
|
|
639
|
+
n: get("db_recompute_ms").length,
|
|
640
|
+
},
|
|
641
|
+
disk_write_ms: {
|
|
642
|
+
p50: pct(get("disk_write_ms"), 50),
|
|
643
|
+
p95: pct(get("disk_write_ms"), 95),
|
|
644
|
+
n: get("disk_write_ms").length,
|
|
645
|
+
},
|
|
646
|
+
rss_mb: { latest: latest(get("rss_mb")), n: get("rss_mb").length },
|
|
647
|
+
heap_mb: {
|
|
648
|
+
latest: latest(get("heap_mb")),
|
|
649
|
+
n: get("heap_mb").length,
|
|
650
|
+
},
|
|
651
|
+
cpu_user_ms: {
|
|
652
|
+
latest: latest(get("cpu_user_ms")),
|
|
653
|
+
n: get("cpu_user_ms").length,
|
|
654
|
+
},
|
|
655
|
+
cpu_sys_ms: {
|
|
656
|
+
latest: latest(get("cpu_sys_ms")),
|
|
657
|
+
n: get("cpu_sys_ms").length,
|
|
658
|
+
},
|
|
659
|
+
diag,
|
|
660
|
+
}));
|
|
661
|
+
}
|
|
662
|
+
catch (e) {
|
|
663
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
664
|
+
res.end(JSON.stringify({ error: "perf_unavailable", detail: String(e) }));
|
|
444
665
|
}
|
|
445
666
|
return;
|
|
446
667
|
}
|
|
@@ -463,11 +684,21 @@ export async function launchDashboardServer(stateDir) {
|
|
|
463
684
|
}
|
|
464
685
|
catch (e) {
|
|
465
686
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
466
|
-
res.end(JSON.stringify({
|
|
687
|
+
res.end(JSON.stringify({
|
|
688
|
+
error: "achievements_unavailable",
|
|
689
|
+
detail: String(e),
|
|
690
|
+
}));
|
|
467
691
|
}
|
|
468
692
|
return;
|
|
469
693
|
}
|
|
470
|
-
// Fallback — serve the dashboard
|
|
694
|
+
// Fallback — serve the React client build (SPA route) or legacy dashboard.
|
|
695
|
+
// Non-/api/* GETs hit here: serve client assets if built, else inline HTML.
|
|
696
|
+
if (req.method === "GET" &&
|
|
697
|
+
req.url &&
|
|
698
|
+
!req.url.startsWith("/api/") &&
|
|
699
|
+
serveClientAsset(req.url, res)) {
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
471
702
|
const tier = readSnapshot(snapshotPath).tier;
|
|
472
703
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
473
704
|
res.end(dashboardHtml(tier));
|
|
@@ -505,7 +736,11 @@ export async function launchDashboardServer(stateDir) {
|
|
|
505
736
|
const v4Handler = server.listeners("request")[0];
|
|
506
737
|
if (v4Handler) {
|
|
507
738
|
v6 = createServer((r, s) => v4Handler.call(server, r, s));
|
|
508
|
-
v6.on("error", (e) => log("ipv6 loopback bind skipped", {
|
|
739
|
+
v6.on("error", (e) => log("ipv6 loopback bind skipped", {
|
|
740
|
+
port,
|
|
741
|
+
code: e.code,
|
|
742
|
+
message: e.message,
|
|
743
|
+
}));
|
|
509
744
|
v6.listen(port, "::1", () => log("ipv6 loopback bound", { port })); // guardrails-allow PREVENT-PI-004: IPv6 loopback (::1) mirror of the localhost dashboard server
|
|
510
745
|
}
|
|
511
746
|
// Write port.pid
|
|
@@ -520,12 +755,16 @@ export async function launchDashboardServer(stateDir) {
|
|
|
520
755
|
try {
|
|
521
756
|
unlinkSync(portFile);
|
|
522
757
|
}
|
|
523
|
-
catch {
|
|
758
|
+
catch {
|
|
759
|
+
/* already gone */
|
|
760
|
+
}
|
|
524
761
|
server.close();
|
|
525
762
|
try {
|
|
526
763
|
v6?.close();
|
|
527
764
|
}
|
|
528
|
-
catch {
|
|
765
|
+
catch {
|
|
766
|
+
/* not bound */
|
|
767
|
+
}
|
|
529
768
|
process.exit(0);
|
|
530
769
|
};
|
|
531
770
|
process.on("SIGTERM", cleanup);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function setupTailscaleServe(port = 3000, httpsPort = 443) {
|
|
2
|
+
// Tailscale serve: exposes localhost dashboard securely.
|
|
3
|
+
// Only activates when TAILSCALE_ENABLED=1 is set.
|
|
4
|
+
if (process.env.TAILSCALE_ENABLED !== "1")
|
|
5
|
+
return false;
|
|
6
|
+
console.log(`[tailscale] Serve enabled on port ${port} (https:${httpsPort})`);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dashboard-server/types.ts — shared types for the dashboard server.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports shared shapes from `api-contracts/` where they overlap with
|
|
5
|
+
* the legacy local types, while preserving 100% backward compatibility for
|
|
6
|
+
* all existing consumers (server.ts, index-reader.ts, snapshot.ts).
|
|
3
7
|
*/
|
|
4
8
|
// Active-window cutoff (seconds) for the /api/servers endpoint.
|
|
5
9
|
export const ACTIVE_WINDOW_SEC = 1800;
|
|
@@ -23,9 +23,18 @@ export class Dashboard {
|
|
|
23
23
|
this.snapshotPath = join(stateDir, "dashboard.json");
|
|
24
24
|
this.eventsPath = join(stateDir, "events.log");
|
|
25
25
|
}
|
|
26
|
+
/** v0.8.8: duration (ms) of the last dashboard.json write — read by
|
|
27
|
+
* MegaRuntime.snapshot() to record a `disk_write_ms` perf sample without
|
|
28
|
+
* wrapping the giant snapshot object literal at the call site. */
|
|
29
|
+
_lastWriteMs = 0;
|
|
30
|
+
get lastWriteMs() {
|
|
31
|
+
return this._lastWriteMs;
|
|
32
|
+
}
|
|
26
33
|
/** Write a full state snapshot (atomically replaces previous). */
|
|
27
34
|
snapshot(data) {
|
|
35
|
+
const t = performance.now();
|
|
28
36
|
writeFileSync(this.snapshotPath, JSON.stringify(data, null, 2) + "\n");
|
|
37
|
+
this._lastWriteMs = performance.now() - t;
|
|
29
38
|
}
|
|
30
39
|
/** Append a timestamped JSONL event line. */
|
|
31
40
|
event(type, data) {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { recordPerfSample } from "../../src/store/sqlite.js";
|
|
2
|
+
/** Narrow a turn_end message to its usage block when it is an assistant msg. */
|
|
3
|
+
function usageOf(msg) {
|
|
4
|
+
if (msg.role !== "assistant" || !msg.usage)
|
|
5
|
+
return null;
|
|
6
|
+
return msg.usage;
|
|
7
|
+
}
|
|
8
|
+
/** Register perf instrumentation handlers + start the 5s cpu/mem interval. */
|
|
9
|
+
export function registerPerfHandler(pi, runtime) {
|
|
10
|
+
// turn_start: record the wall-clock start of the turn. Using Date.now() (not
|
|
11
|
+
// event.timestamp) so the turn_end duration is on ONE clock — mixing pi's
|
|
12
|
+
// timestamp with Date.now() would skew the delta. Also (re)arms the cpu/mem
|
|
13
|
+
// interval so a new session after a dispose() resumes sampling on its first
|
|
14
|
+
// turn (the interval is cleared in runtime.dispose()).
|
|
15
|
+
pi.on("turn_start", async () => {
|
|
16
|
+
try {
|
|
17
|
+
runtime.perfTurnStart = Date.now();
|
|
18
|
+
runtime.ensurePerfInterval();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
/* non-fatal */
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
// turn_end: compute turn latency + TPS + cache hit % from the assistant
|
|
25
|
+
// message's usage block. One perf_samples row per metric per turn.
|
|
26
|
+
pi.on("turn_end", async (event) => {
|
|
27
|
+
try {
|
|
28
|
+
if (runtime.perfTurnStart > 0) {
|
|
29
|
+
const durMs = Date.now() - runtime.perfTurnStart;
|
|
30
|
+
recordPerfSample(runtime.currentStateDir, "turn_latency_ms", durMs, {
|
|
31
|
+
turnIndex: event.turnIndex,
|
|
32
|
+
});
|
|
33
|
+
const u = usageOf(event.message);
|
|
34
|
+
if (u) {
|
|
35
|
+
const durSec = Math.max(durMs / 1000, 0.001);
|
|
36
|
+
recordPerfSample(runtime.currentStateDir, "tps", u.output / durSec, { outputTokens: u.output });
|
|
37
|
+
const denom = u.cacheRead + u.input + u.cacheWrite;
|
|
38
|
+
const hitPct = denom > 0 ? (u.cacheRead / denom) * 100 : 0;
|
|
39
|
+
recordPerfSample(runtime.currentStateDir, "cache_hit_pct", hitPct, { input: u.input, cacheRead: u.cacheRead, cacheWrite: u.cacheWrite });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
/* non-fatal: instrumentation must never break the agent loop */
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
// before_provider_request -> after_provider_response: raw round-trip latency
|
|
48
|
+
// to the model endpoint (HTTP status carried on the response event).
|
|
49
|
+
pi.on("before_provider_request", async () => {
|
|
50
|
+
try {
|
|
51
|
+
runtime.perfProviderStart = Date.now();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
/* non-fatal */
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
pi.on("after_provider_response", async (event) => {
|
|
58
|
+
try {
|
|
59
|
+
if (runtime.perfProviderStart > 0) {
|
|
60
|
+
const lat = Date.now() - runtime.perfProviderStart;
|
|
61
|
+
recordPerfSample(runtime.currentStateDir, "provider_latency_ms", lat, { status: event.status });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
/* non-fatal */
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
// Start the 5s cpu/mem sampling interval (one per MegaRuntime; cleared in
|
|
69
|
+
// runtime.dispose()). Idempotent — safe to call again after a dispose().
|
|
70
|
+
runtime.ensurePerfInterval();
|
|
71
|
+
}
|
|
@@ -2,6 +2,7 @@ import { registerSessionHandlers } from "./session-handlers.js";
|
|
|
2
2
|
import { registerAgentHandlers } from "./agent-handlers.js";
|
|
3
3
|
import { registerContextHandler } from "./context-handler.js";
|
|
4
4
|
import { registerCompactHandlers } from "./compact-handlers.js";
|
|
5
|
+
import { registerPerfHandler } from "./perf-handler.js";
|
|
5
6
|
/**
|
|
6
7
|
* DIAG accessor for the headless test harness: the most recently constructed
|
|
7
8
|
* MegaRuntime, so a test that loads the compiled extension via its default
|
|
@@ -18,4 +19,5 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
18
19
|
registerAgentHandlers(pi, runtime, config);
|
|
19
20
|
registerContextHandler(pi, runtime, config);
|
|
20
21
|
registerCompactHandlers(pi, runtime, config);
|
|
22
|
+
registerPerfHandler(pi, runtime);
|
|
21
23
|
}
|