pi-mega-compact 0.7.8 → 0.8.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 +11 -12
- package/dist/extensions/dashboard-server/html.js +1023 -0
- package/dist/extensions/dashboard-server/html.test.js +41 -0
- package/dist/extensions/dashboard-server/index-reader.js +133 -0
- package/dist/extensions/dashboard-server/server.js +530 -0
- package/dist/extensions/dashboard-server/server.test.js +120 -0
- package/dist/extensions/dashboard-server/snapshot.js +43 -0
- package/dist/extensions/dashboard-server/state.js +30 -0
- package/dist/extensions/dashboard-server/types.js +5 -0
- package/dist/extensions/dashboard-server-s32.test.js +181 -0
- package/dist/extensions/dashboard-server.js +7 -1315
- package/dist/extensions/mega-commands.js +162 -134
- package/dist/extensions/mega-compact.js +3 -0
- package/dist/extensions/mega-compact.test.js +90 -21
- package/dist/extensions/mega-conflict-cmds.js +5 -1
- package/dist/extensions/mega-dashboard-cmds.js +29 -22
- package/dist/extensions/mega-db-cmds.js +11 -2
- package/dist/extensions/mega-events/agent-handlers.js +222 -0
- package/dist/extensions/mega-events/compact-handlers.js +162 -0
- package/dist/extensions/mega-events/context-handler.js +249 -0
- package/dist/extensions/mega-events/register.js +21 -0
- package/dist/extensions/mega-events/session-handlers.js +142 -0
- package/dist/extensions/mega-events.js +15 -699
- package/dist/extensions/mega-game-cmds.js +106 -0
- package/dist/extensions/mega-game-cmds.test.js +113 -0
- package/dist/extensions/mega-pipeline/compact.js +324 -0
- package/dist/extensions/mega-pipeline/memory-review.js +38 -0
- package/dist/extensions/mega-pipeline/recall.js +147 -0
- package/dist/extensions/mega-pipeline.js +9 -480
- package/dist/extensions/mega-runtime/helpers.js +40 -0
- package/dist/extensions/mega-runtime/query.js +29 -0
- package/dist/extensions/mega-runtime/state.js +877 -0
- package/dist/extensions/mega-runtime/state.test.js +171 -0
- package/dist/extensions/mega-runtime/widget.js +270 -0
- package/dist/extensions/mega-runtime/widget.test.js +160 -0
- package/dist/extensions/mega-runtime.js +15 -947
- package/dist/src/config/themes.js +84 -0
- package/dist/src/config/themes.test.js +94 -0
- package/dist/src/game/scoring.js +105 -0
- package/dist/src/game/scoring.test.js +98 -0
- package/dist/src/store/sqlite/checkpoints.js +145 -0
- package/dist/src/store/sqlite/dedup-mirror.js +64 -0
- package/dist/src/store/sqlite/foundation.js +38 -0
- package/dist/src/store/sqlite/game-achievements.js +111 -0
- package/dist/src/store/sqlite/game-achievements.test.js +67 -0
- package/dist/src/store/sqlite/game-scores.js +105 -0
- package/dist/src/store/sqlite/game-scores.test.js +106 -0
- package/dist/src/store/sqlite/game-state.js +54 -0
- package/dist/src/store/sqlite/game-state.test.js +76 -0
- package/dist/src/store/sqlite/global-index.js +224 -0
- package/dist/src/store/sqlite/maintenance.js +235 -0
- package/dist/src/store/sqlite/memories.js +164 -0
- package/dist/src/store/sqlite/meta.js +82 -0
- package/dist/src/store/sqlite/model-snapshots.js +47 -0
- package/dist/src/store/sqlite/raptor.js +57 -0
- package/dist/src/store/sqlite/raw-transcript.js +134 -0
- package/dist/src/store/sqlite/schema.js +294 -0
- package/dist/src/store/sqlite/session-state.js +28 -0
- package/dist/src/store/sqlite/stats.js +66 -0
- package/dist/src/store/sqlite/utils.js +120 -0
- package/dist/src/store/sqlite.js +23 -1607
- package/extensions/dashboard-server/html.test.ts +50 -0
- package/extensions/dashboard-server/html.ts +1026 -0
- package/extensions/dashboard-server/index-reader.ts +130 -0
- package/extensions/dashboard-server/server.test.ts +131 -0
- package/extensions/dashboard-server/server.ts +505 -0
- package/extensions/dashboard-server/snapshot.ts +44 -0
- package/extensions/dashboard-server/state.ts +33 -0
- package/extensions/dashboard-server/types.ts +134 -0
- package/extensions/dashboard-server-s32.test.ts +195 -0
- package/extensions/dashboard-server.ts +7 -1431
- package/extensions/mega-commands.ts +33 -10
- package/extensions/mega-compact.test.ts +198 -43
- package/extensions/mega-compact.ts +3 -0
- package/extensions/mega-conflict-cmds.ts +6 -2
- package/extensions/mega-dashboard-cmds.ts +30 -23
- package/extensions/mega-db-cmds.ts +11 -3
- package/extensions/mega-events/agent-handlers.ts +262 -0
- package/extensions/mega-events/compact-handlers.ts +192 -0
- package/extensions/mega-events/context-handler.ts +290 -0
- package/extensions/mega-events/register.ts +37 -0
- package/extensions/mega-events/session-handlers.ts +165 -0
- package/extensions/mega-events.ts +15 -780
- package/extensions/mega-game-cmds.test.ts +137 -0
- package/extensions/mega-game-cmds.ts +122 -0
- package/extensions/mega-pipeline/compact.ts +366 -0
- package/extensions/mega-pipeline/memory-review.ts +46 -0
- package/extensions/mega-pipeline/recall.ts +165 -0
- package/extensions/mega-pipeline.ts +9 -537
- package/extensions/mega-runtime/helpers.ts +68 -0
- package/extensions/mega-runtime/query.ts +29 -0
- package/extensions/mega-runtime/state.test.ts +171 -0
- package/extensions/mega-runtime/state.ts +967 -0
- package/extensions/mega-runtime/widget.test.ts +185 -0
- package/extensions/mega-runtime/widget.ts +359 -0
- package/extensions/mega-runtime.ts +15 -1093
- package/package.json +4 -3
- package/src/config/themes.test.ts +116 -0
- package/src/config/themes.ts +124 -0
- package/src/game/scoring.test.ts +103 -0
- package/src/game/scoring.ts +158 -0
- package/src/store/sqlite/checkpoints.ts +204 -0
- package/src/store/sqlite/dedup-mirror.ts +114 -0
- package/src/store/sqlite/foundation.ts +63 -0
- package/src/store/sqlite/game-achievements.test.ts +80 -0
- package/src/store/sqlite/game-achievements.ts +147 -0
- package/src/store/sqlite/game-scores.test.ts +132 -0
- package/src/store/sqlite/game-scores.ts +168 -0
- package/src/store/sqlite/game-state.test.ts +89 -0
- package/src/store/sqlite/game-state.ts +87 -0
- package/src/store/sqlite/global-index.ts +305 -0
- package/src/store/sqlite/maintenance.ts +294 -0
- package/src/store/sqlite/memories.ts +217 -0
- package/src/store/sqlite/meta.ts +108 -0
- package/src/store/sqlite/model-snapshots.ts +83 -0
- package/src/store/sqlite/raptor.ts +107 -0
- package/src/store/sqlite/raw-transcript.ts +221 -0
- package/src/store/sqlite/schema.ts +305 -0
- package/src/store/sqlite/session-state.ts +38 -0
- package/src/store/sqlite/stats.ts +127 -0
- package/src/store/sqlite/utils.ts +125 -0
- package/src/store/sqlite.ts +23 -2204
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dashboard-server/server.ts — HTTP server creation + launch + CLI entry point.
|
|
3
|
+
*/
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
|
|
6
|
+
import { join, dirname } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { log, setLogPath, setDashboardServerVersion } from "./state.js";
|
|
10
|
+
import { readIndex, getIndexDir } from "./index-reader.js";
|
|
11
|
+
import { readSnapshot, readFrom } from "./snapshot.js";
|
|
12
|
+
import { dashboardHtml } from "./html.js";
|
|
13
|
+
import { ACTIVE_WINDOW_SEC } from "./types.js";
|
|
14
|
+
export async function launchDashboardServer(stateDir) {
|
|
15
|
+
// Our own package version — exposed at /api/version so the launcher can
|
|
16
|
+
// detect a stale server (started by an older build) and replace it on
|
|
17
|
+
// upgrade instead of reuse it.
|
|
18
|
+
let SERVER_VERSION = "0.0.0";
|
|
19
|
+
try {
|
|
20
|
+
// dashboard-server.js lives at <pkg>/dist/extensions/, so package.json is
|
|
21
|
+
// two levels up. Guard each candidate so a dev-checkout layout still works.
|
|
22
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const candidates = [join(here, "..", "..", "package.json"), join(here, "..", "package.json")];
|
|
24
|
+
for (const p of candidates) {
|
|
25
|
+
if (!existsSync(p))
|
|
26
|
+
continue;
|
|
27
|
+
const pkg = JSON.parse(readFileSync(p, "utf-8"));
|
|
28
|
+
if (pkg.version) {
|
|
29
|
+
SERVER_VERSION = pkg.version;
|
|
30
|
+
setDashboardServerVersion(pkg.version);
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch { /* non-fatal */ }
|
|
36
|
+
// Lazy-loaded via require so the dashboard stays cheap to boot and we don't
|
|
37
|
+
// need a top-level await in the handler.
|
|
38
|
+
const driftReq = createRequire(import.meta.url);
|
|
39
|
+
const detectCrossRepoDrift = (idxDir) => driftReq("../../src/driftDetection.js")
|
|
40
|
+
.detectCrossRepoDrift(idxDir);
|
|
41
|
+
const portFile = join(stateDir, "port.pid");
|
|
42
|
+
const snapshotPath = join(stateDir, "dashboard.json");
|
|
43
|
+
const eventsPath = join(stateDir, "events.log");
|
|
44
|
+
setLogPath(join(stateDir, "dashboard.log"));
|
|
45
|
+
log("launch invoked", { stateDir });
|
|
46
|
+
// ── Existing server? ───────────────────────────────────────────────────────
|
|
47
|
+
// A stale port.pid pointing at a dead/competing process is the classic cause
|
|
48
|
+
// of "dashboard failed to start" — we return a port that is NOT actually
|
|
49
|
+
// serving. Probe for a live server on that port first; only reuse the marker
|
|
50
|
+
// when something real answers /api/version. Otherwise drop it and start fresh.
|
|
51
|
+
if (existsSync(portFile)) {
|
|
52
|
+
try {
|
|
53
|
+
const info = JSON.parse(readFileSync(portFile, "utf-8"));
|
|
54
|
+
if (info && info.port) {
|
|
55
|
+
let live = false;
|
|
56
|
+
try {
|
|
57
|
+
const probe = await fetch(`http://localhost:${info.port}/api/version`, { signal: AbortSignal.timeout(800) }); // guardrails-allow PREVENT-PI-004: optional localhost dashboard server probe (loopback-only)
|
|
58
|
+
live = probe.ok;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
live = false;
|
|
62
|
+
}
|
|
63
|
+
if (live) {
|
|
64
|
+
log("reusing live server from port.pid", { port: info.port });
|
|
65
|
+
return { port: info.port, url: `http://localhost:${info.port}` }; // guardrails-allow PREVENT-PI-004: localhost dashboard URL (loopback-only)
|
|
66
|
+
}
|
|
67
|
+
log("port.pid present but no live server — treating as stale", { port: info.port });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
log("port.pid unparseable — treating as stale");
|
|
72
|
+
}
|
|
73
|
+
// stale file, remove so the fresh bind does not collide with a lingering
|
|
74
|
+
// process that still holds the port
|
|
75
|
+
try {
|
|
76
|
+
unlinkSync(portFile);
|
|
77
|
+
}
|
|
78
|
+
catch { /* ignore */ }
|
|
79
|
+
}
|
|
80
|
+
// ── New server ────────────────────────────────────────────────────────────
|
|
81
|
+
mkdirSync(stateDir, { recursive: true });
|
|
82
|
+
let eventOffset = 0;
|
|
83
|
+
// Overlay the live current-repo snapshot (snapshot.json, rewritten every
|
|
84
|
+
// context event) onto its registry row so the All-repos / Summary views stay
|
|
85
|
+
// in sync with the live menu bar + Current-repo card in real time. The
|
|
86
|
+
// registry (index.sqlite) is only written on repo-switch (bindRepo), so
|
|
87
|
+
// without this the current repo's row freezes between switches. Read-only —
|
|
88
|
+
// no extra writes to index.sqlite. Matched by stateDir, which equals the
|
|
89
|
+
// value this server was launched with (runtime.currentStateDir).
|
|
90
|
+
function overlayCurrentRepo(idx) {
|
|
91
|
+
if (!idx || !idx.repos.length)
|
|
92
|
+
return;
|
|
93
|
+
let snap = null;
|
|
94
|
+
try {
|
|
95
|
+
snap = readSnapshot(snapshotPath);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (!snap || !snap.repo)
|
|
101
|
+
return;
|
|
102
|
+
const cur = idx.repos.find((r) => r.stateDir === stateDir);
|
|
103
|
+
if (!cur)
|
|
104
|
+
return;
|
|
105
|
+
const prevSaved = cur.tokensSaved;
|
|
106
|
+
const prevCp = cur.checkpointCount;
|
|
107
|
+
const prevBytes = cur.compressedOriginalBytes;
|
|
108
|
+
const comp = snap.compression?.repo;
|
|
109
|
+
const liveSaved = comp ? comp.tokensFreed : (snap.repo.tokensSaved ?? prevSaved);
|
|
110
|
+
const liveCp = snap.repo.checkpointCount ?? prevCp;
|
|
111
|
+
const liveBytes = snap.integrity?.compressedOriginalBytes ?? prevBytes;
|
|
112
|
+
cur.tokensSaved = liveSaved;
|
|
113
|
+
cur.checkpointCount = liveCp;
|
|
114
|
+
cur.compressedOriginalBytes = liveBytes;
|
|
115
|
+
if (idx.summary) {
|
|
116
|
+
idx.summary.totalTokensSaved += liveSaved - prevSaved;
|
|
117
|
+
idx.summary.totalCheckpoints += liveCp - prevCp;
|
|
118
|
+
idx.summary.totalCompressedOriginalBytes += liveBytes - prevBytes;
|
|
119
|
+
}
|
|
120
|
+
idx.updatedAt = snap.updatedAt ?? idx.updatedAt;
|
|
121
|
+
}
|
|
122
|
+
const server = createServer((req, res) => {
|
|
123
|
+
// guardrails-allow PREVENT-PI-004: optional, user-triggered /dashboard localhost server (loopback-only) — CORS open for local browser access
|
|
124
|
+
// CORS for local access
|
|
125
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
126
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, PUT, OPTIONS");
|
|
127
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
128
|
+
if (req.method === "OPTIONS") {
|
|
129
|
+
res.writeHead(204);
|
|
130
|
+
res.end();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (req.url === "/" || req.url === "/index.html") {
|
|
134
|
+
const tier = readSnapshot(snapshotPath).tier;
|
|
135
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
136
|
+
res.end(dashboardHtml(tier));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (req.url === "/api/snapshot") {
|
|
140
|
+
const snap = readSnapshot(snapshotPath);
|
|
141
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
142
|
+
res.end(JSON.stringify(snap));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// Server version — lets the /dashboard launcher detect a stale server from
|
|
146
|
+
// an older build and replace it on upgrade rather than reuse it.
|
|
147
|
+
if (req.url === "/api/version") {
|
|
148
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
149
|
+
res.end(JSON.stringify({ version: SERVER_VERSION }));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Multi-repo aggregate (Phase 5b): the machine-wide repo registry read
|
|
153
|
+
// directly from SQLite (index.sqlite). Lets one dashboard show every repo's
|
|
154
|
+
// checkpoints, tokens saved, and active model. Read-only.
|
|
155
|
+
if (req.url === "/api/index") {
|
|
156
|
+
const idx = readIndex();
|
|
157
|
+
if (idx)
|
|
158
|
+
overlayCurrentRepo(idx);
|
|
159
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
160
|
+
res.end(JSON.stringify(idx ?? { updatedAt: null, summary: null, repos: [] }));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// /api/repos — registry list. Optional `?active=24h` filters to repos
|
|
164
|
+
// seen within the last N hours (default: all). The dashboard uses this to
|
|
165
|
+
// drive its "active vs archived" badge without refetching /api/index.
|
|
166
|
+
if (req.url?.startsWith("/api/repos")) {
|
|
167
|
+
const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
|
|
168
|
+
const activeParam = url.searchParams.get("active");
|
|
169
|
+
const idx = readIndex();
|
|
170
|
+
if (idx)
|
|
171
|
+
overlayCurrentRepo(idx);
|
|
172
|
+
let repos = idx?.repos ?? [];
|
|
173
|
+
if (activeParam) {
|
|
174
|
+
const m = /^(\d+)h$/.exec(activeParam);
|
|
175
|
+
if (m) {
|
|
176
|
+
const cutoffSec = Math.floor(Date.now() / 1000) - Number(m[1]) * 3600;
|
|
177
|
+
repos = repos.filter((r) => (r.lastSeen ?? 0) >= cutoffSec);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
181
|
+
res.end(JSON.stringify({ updatedAt: idx?.updatedAt ?? null, repos, count: repos.length }));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// /api/summary — header tiles without the full repo list (keeps payload
|
|
185
|
+
// small for embed scenarios). activeRepos mirrors the /api/repos?active=24h
|
|
186
|
+
// count so the dashboard can render the active badge alongside totals.
|
|
187
|
+
if (req.url?.startsWith("/api/summary")) {
|
|
188
|
+
const idx = readIndex();
|
|
189
|
+
if (idx)
|
|
190
|
+
overlayCurrentRepo(idx);
|
|
191
|
+
const repos = idx?.repos ?? [];
|
|
192
|
+
const cutoffSec = Math.floor(Date.now() / 1000) - 24 * 3600;
|
|
193
|
+
const activeRepos = repos.filter((r) => (r.lastSeen ?? 0) >= cutoffSec).length;
|
|
194
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
195
|
+
res.end(JSON.stringify({
|
|
196
|
+
updatedAt: idx?.updatedAt ?? null,
|
|
197
|
+
summary: idx?.summary ?? null,
|
|
198
|
+
activeRepos,
|
|
199
|
+
totalRepos: repos.length,
|
|
200
|
+
}));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// /api/drift — R4: cross-repo drift report over repo_registry. Flags stale
|
|
204
|
+
// repos (>30d idle), compaction lag (active but >24h since last
|
|
205
|
+
// compaction), and recent model churn. Read-only.
|
|
206
|
+
if (req.url?.startsWith("/api/drift")) {
|
|
207
|
+
const report = detectCrossRepoDrift(getIndexDir());
|
|
208
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
209
|
+
res.end(JSON.stringify(report));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (req.url === "/api/servers") {
|
|
213
|
+
try {
|
|
214
|
+
const idx = readIndex();
|
|
215
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
216
|
+
const servers = (idx?.repos ?? []).filter((r) => (r.lastSeen ?? 0) >= nowSec - ACTIVE_WINDOW_SEC).map((r) => {
|
|
217
|
+
const out = { repoRoot: r.repoRoot, displayName: r.displayName, model: r.modelName, provider: r.providerName, lastSeen: r.lastSeen, lastCompactedAt: r.lastCompactedAt };
|
|
218
|
+
try {
|
|
219
|
+
const p = join(r.stateDir, "dashboard.json");
|
|
220
|
+
if (existsSync(p)) {
|
|
221
|
+
const snap = JSON.parse(readFileSync(p, "utf-8"));
|
|
222
|
+
out.tier = snap.tier ?? null;
|
|
223
|
+
out.contextPct = (snap.context && snap.context.percent != null) ? snap.context.percent : null;
|
|
224
|
+
out.state = (snap.session && snap.session.state) || null;
|
|
225
|
+
out.cacheHits = snap.cacheHits ?? null;
|
|
226
|
+
out.compacts = snap.compacts ?? null;
|
|
227
|
+
out.timeSaved = snap.timeSaved ?? null;
|
|
228
|
+
out.updatedAt = snap.updatedAt ?? null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch { /* best-effort */ }
|
|
232
|
+
return out;
|
|
233
|
+
}).sort((a, b) => b.lastSeen - a.lastSeen);
|
|
234
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
235
|
+
res.end(JSON.stringify({ updatedAt: new Date().toISOString(), servers }));
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
239
|
+
res.end(JSON.stringify({ error: "servers_unavailable" }));
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (req.url === "/api/events") {
|
|
244
|
+
res.writeHead(200, {
|
|
245
|
+
"Content-Type": "text/event-stream",
|
|
246
|
+
"Cache-Control": "no-cache",
|
|
247
|
+
"Connection": "keep-alive",
|
|
248
|
+
});
|
|
249
|
+
// Drain existing events so the client starts with history
|
|
250
|
+
const { data: existing, offset: initialOffset } = readFrom(eventsPath, 0);
|
|
251
|
+
eventOffset = initialOffset;
|
|
252
|
+
const lines = existing.split("\n").filter((l) => l.trim());
|
|
253
|
+
for (const line of lines) {
|
|
254
|
+
res.write(`data: ${line}\n\n`);
|
|
255
|
+
}
|
|
256
|
+
// Tail new events via fs.watch (coalesced with 100ms debounce)
|
|
257
|
+
let watchTimer = null;
|
|
258
|
+
const onWatch = () => {
|
|
259
|
+
if (watchTimer)
|
|
260
|
+
return;
|
|
261
|
+
watchTimer = setTimeout(() => {
|
|
262
|
+
watchTimer = null;
|
|
263
|
+
const { data, offset } = readFrom(eventsPath, eventOffset);
|
|
264
|
+
eventOffset = offset;
|
|
265
|
+
const newLines = data.split("\n").filter((l) => l.trim());
|
|
266
|
+
for (const line of newLines) {
|
|
267
|
+
res.write(`data: ${line}\n\n`);
|
|
268
|
+
}
|
|
269
|
+
}, 100);
|
|
270
|
+
};
|
|
271
|
+
// Set up file watching: if file exists, watch it directly;
|
|
272
|
+
// otherwise poll for creation every 1s then switch to fs.watch.
|
|
273
|
+
let watcher = null;
|
|
274
|
+
let pollInterval = null;
|
|
275
|
+
function startFileWatch() {
|
|
276
|
+
try {
|
|
277
|
+
watcher = watch(eventsPath, onWatch);
|
|
278
|
+
}
|
|
279
|
+
catch { /* give up */ }
|
|
280
|
+
}
|
|
281
|
+
if (existsSync(eventsPath)) {
|
|
282
|
+
startFileWatch();
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
pollInterval = setInterval(() => {
|
|
286
|
+
if (existsSync(eventsPath)) {
|
|
287
|
+
if (pollInterval) {
|
|
288
|
+
clearInterval(pollInterval);
|
|
289
|
+
pollInterval = null;
|
|
290
|
+
}
|
|
291
|
+
startFileWatch();
|
|
292
|
+
}
|
|
293
|
+
}, 1000);
|
|
294
|
+
}
|
|
295
|
+
req.on("close", () => {
|
|
296
|
+
if (watchTimer)
|
|
297
|
+
clearTimeout(watchTimer);
|
|
298
|
+
if (pollInterval)
|
|
299
|
+
clearInterval(pollInterval);
|
|
300
|
+
watcher?.close();
|
|
301
|
+
});
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
// /api/game-state — S32 game-mode settings (game_mode_on / theme /
|
|
305
|
+
// tui_display_mode). GET returns the current row; PUT applies a partial
|
|
306
|
+
// patch (validated) and returns the post-write row. The dashboard server is
|
|
307
|
+
// a detached child with no MegaRuntime ref, so it reads/writes the
|
|
308
|
+
// game_state SQLite row directly; the in-process MegaRuntime picks up the
|
|
309
|
+
// change via its fs.watch cache-eviction watcher. PREVENT-PI-004: loopback.
|
|
310
|
+
if (req.url?.startsWith("/api/game-state")) {
|
|
311
|
+
const gsReq = createRequire(import.meta.url);
|
|
312
|
+
const { getGameState, setGameState } = gsReq("../../src/store/sqlite.js");
|
|
313
|
+
const { isValidTheme } = gsReq("../../src/config/themes.js");
|
|
314
|
+
if (req.method === "GET") {
|
|
315
|
+
try {
|
|
316
|
+
const gs = getGameState(stateDir); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
|
|
317
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
318
|
+
res.end(JSON.stringify(gs));
|
|
319
|
+
}
|
|
320
|
+
catch (e) {
|
|
321
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
322
|
+
res.end(JSON.stringify({ error: "game_state_unavailable", detail: String(e) }));
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (req.method === "PUT") {
|
|
327
|
+
// Read + parse the JSON body (capped — the patch is tiny). The handler
|
|
328
|
+
// is sync, so drain the stream via data/end listeners then continue.
|
|
329
|
+
let body = "";
|
|
330
|
+
let tooBig = false;
|
|
331
|
+
req.on("data", (chunk) => {
|
|
332
|
+
if (body.length > 65536) {
|
|
333
|
+
tooBig = true;
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
body += chunk.toString();
|
|
337
|
+
});
|
|
338
|
+
req.on("end", () => {
|
|
339
|
+
if (tooBig) {
|
|
340
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
341
|
+
res.end(JSON.stringify({ error: "body_too_large" }));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
let patch = {};
|
|
345
|
+
try {
|
|
346
|
+
patch = body ? JSON.parse(body) : {};
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
350
|
+
res.end(JSON.stringify({ error: "invalid_json" }));
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
// Reject valid-but-non-object JSON (null/[]/42) — dereferencing
|
|
354
|
+
// patch.game_mode_on would throw an unhandled TypeError inside this
|
|
355
|
+
// 'end' listener and crash the detached server (audit P1: loopback DoS).
|
|
356
|
+
if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
|
|
357
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
358
|
+
res.end(JSON.stringify({ error: "invalid_patch_object" }));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
// Validate the patch fields (unknown keys ignored; invalid values -> 400).
|
|
362
|
+
const clean = {};
|
|
363
|
+
let bad = false;
|
|
364
|
+
if (patch.game_mode_on != null) {
|
|
365
|
+
if (typeof patch.game_mode_on !== "boolean")
|
|
366
|
+
bad = true;
|
|
367
|
+
else
|
|
368
|
+
clean.game_mode_on = patch.game_mode_on;
|
|
369
|
+
}
|
|
370
|
+
if (patch.theme != null) {
|
|
371
|
+
if (typeof patch.theme !== "string" || !isValidTheme(patch.theme))
|
|
372
|
+
bad = true;
|
|
373
|
+
else
|
|
374
|
+
clean.theme = patch.theme;
|
|
375
|
+
}
|
|
376
|
+
if (patch.tui_display_mode != null) {
|
|
377
|
+
if (patch.tui_display_mode !== "full" && patch.tui_display_mode !== "minimal")
|
|
378
|
+
bad = true;
|
|
379
|
+
else
|
|
380
|
+
clean.tui_display_mode = patch.tui_display_mode;
|
|
381
|
+
}
|
|
382
|
+
if (bad) {
|
|
383
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
384
|
+
res.end(JSON.stringify({ error: "invalid_patch" }));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
const gs = setGameState(clean, stateDir); // guardrails-allow PREVENT-PI-004: local SQLite write (loopback dashboard)
|
|
389
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
390
|
+
res.end(JSON.stringify(gs));
|
|
391
|
+
}
|
|
392
|
+
catch (e) {
|
|
393
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
394
|
+
res.end(JSON.stringify({ error: "game_state_write_failed", detail: String(e) }));
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
// Any other method on /api/game-state → 405.
|
|
400
|
+
res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
401
|
+
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
// /api/game-scores — S34 high-score leaderboards. GET returns the leaderboard
|
|
405
|
+
// for a metric (?metric=<m>&limit=<n>). `metric` is validated against the
|
|
406
|
+
// METRICS allow-list from src/game/scoring (re-exported via the sqlite barrel);
|
|
407
|
+
// default limit 10, clamped to [1,100]. The dashboard server is a detached
|
|
408
|
+
// child with no MegaRuntime ref, so it reads the game_scores SQLite table
|
|
409
|
+
// directly. Unknown metric -> 400, non-GET -> 405. PREVENT-PI-004: loopback.
|
|
410
|
+
if (req.url?.startsWith("/api/game-scores")) {
|
|
411
|
+
const gsReq = createRequire(import.meta.url);
|
|
412
|
+
const { leaderboard, METRICS } = gsReq("../../src/store/sqlite.js");
|
|
413
|
+
if (req.method !== "GET") {
|
|
414
|
+
res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
415
|
+
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
|
|
420
|
+
const metricParam = url.searchParams.get("metric") ?? "cache";
|
|
421
|
+
if (!METRICS.includes(metricParam)) {
|
|
422
|
+
res.writeHead(400, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
423
|
+
res.end(JSON.stringify({ error: "unknown_metric", metric: metricParam }));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const metric = metricParam; // validated against METRICS above
|
|
427
|
+
let limit = Number(url.searchParams.get("limit") ?? "10");
|
|
428
|
+
if (!Number.isFinite(limit) || limit <= 0)
|
|
429
|
+
limit = 10;
|
|
430
|
+
limit = Math.min(Math.max(limit, 1), 100); // clamp to [1,100]
|
|
431
|
+
const rows = leaderboard(stateDir, metric, { limit }); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
|
|
432
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
433
|
+
res.end(JSON.stringify(rows));
|
|
434
|
+
}
|
|
435
|
+
catch (e) {
|
|
436
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
437
|
+
res.end(JSON.stringify({ error: "game_scores_unavailable", detail: String(e) }));
|
|
438
|
+
}
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
// /api/achievements — S35 achievement tiles. GET returns the 9 seeded rows
|
|
442
|
+
// {id,title,description,icon,hidden,unlocked_at}. The dashboard server is a
|
|
443
|
+
// detached child with no MegaRuntime ref, so it reads game_achievements via
|
|
444
|
+
// listAchievements(stateDir) directly. Non-GET -> 405. PREVENT-PI-004: loopback.
|
|
445
|
+
if (req.url?.startsWith("/api/achievements")) {
|
|
446
|
+
const achReq = createRequire(import.meta.url);
|
|
447
|
+
const { listAchievements } = achReq("../../src/store/sqlite.js");
|
|
448
|
+
if (req.method !== "GET") {
|
|
449
|
+
res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
450
|
+
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
try {
|
|
454
|
+
const rows = listAchievements(stateDir); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
|
|
455
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
456
|
+
res.end(JSON.stringify(rows));
|
|
457
|
+
}
|
|
458
|
+
catch (e) {
|
|
459
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
460
|
+
res.end(JSON.stringify({ error: "achievements_unavailable", detail: String(e) }));
|
|
461
|
+
}
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
// Fallback — serve the dashboard
|
|
465
|
+
const tier = readSnapshot(snapshotPath).tier;
|
|
466
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
467
|
+
res.end(dashboardHtml(tier));
|
|
468
|
+
});
|
|
469
|
+
// Bind base + range are env-configurable so tests can use a private,
|
|
470
|
+
// non-colliding range (parallel runs / leftover servers from killed runs
|
|
471
|
+
// would otherwise EADDRINUSE on the machine-global 9320 range). Default
|
|
472
|
+
// MEGACOMPACT_DASHBOARD_PORT=9320 (10-port range 9320–9329) preserves the
|
|
473
|
+
// production behavior.
|
|
474
|
+
const TARGET_PORT = Number(process.env.MEGACOMPACT_DASHBOARD_PORT ?? "9320");
|
|
475
|
+
const PORT_RANGE = 10; // TARGET_PORT..TARGET_PORT+9
|
|
476
|
+
return new Promise((resolve, reject) => {
|
|
477
|
+
function tryPort(port) {
|
|
478
|
+
server.once("error", (err) => {
|
|
479
|
+
if (err.code === "EADDRINUSE" && port < TARGET_PORT + PORT_RANGE - 1) {
|
|
480
|
+
log("port in use, trying next", { port });
|
|
481
|
+
tryPort(port + 1);
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
log("listen failed", { port, code: err.code, message: err.message });
|
|
485
|
+
reject(err);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
server.listen(port, "127.0.0.1", () => {
|
|
489
|
+
const url = `http://localhost:${port}`; // guardrails-allow PREVENT-PI-004: localhost dashboard URL (loopback-only)
|
|
490
|
+
log("server running", { url });
|
|
491
|
+
// eslint-disable-next-line no-console
|
|
492
|
+
console.log(`[mega-compact] dashboard server running: ${url}`);
|
|
493
|
+
// Write port.pid
|
|
494
|
+
try {
|
|
495
|
+
writeFileSync(portFile, JSON.stringify({ port, pid: process.pid }));
|
|
496
|
+
}
|
|
497
|
+
catch (e) {
|
|
498
|
+
log("could not write port.pid", { error: String(e) });
|
|
499
|
+
}
|
|
500
|
+
// Graceful cleanup
|
|
501
|
+
const cleanup = () => {
|
|
502
|
+
try {
|
|
503
|
+
unlinkSync(portFile);
|
|
504
|
+
}
|
|
505
|
+
catch { /* already gone */ }
|
|
506
|
+
server.close();
|
|
507
|
+
process.exit(0);
|
|
508
|
+
};
|
|
509
|
+
process.on("SIGTERM", cleanup);
|
|
510
|
+
process.on("SIGINT", cleanup);
|
|
511
|
+
resolve({ port, url });
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
tryPort(TARGET_PORT);
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
// CLI entry point — when run directly as `node dashboard-server.js <stateDir>`
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
if (process.argv[1] && process.argv[1].includes("dashboard-server")) {
|
|
521
|
+
const stateDir = process.argv[2];
|
|
522
|
+
if (!stateDir) {
|
|
523
|
+
console.error("Usage: node dashboard-server.js <stateDir>");
|
|
524
|
+
process.exit(1);
|
|
525
|
+
}
|
|
526
|
+
launchDashboardServer(stateDir).catch((err) => {
|
|
527
|
+
console.error("[mega-compact] dashboard server failed:", err);
|
|
528
|
+
process.exit(1);
|
|
529
|
+
});
|
|
530
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server.test.ts — S34 /api/game-scores endpoint (GET leaderboard, metric
|
|
3
|
+
* validation, method + limit handling). Mirrors the S32 /api/game-state
|
|
4
|
+
* spawn-and-fetch harness.
|
|
5
|
+
*/
|
|
6
|
+
import { test, describe } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { recordScore } from "../../src/store/sqlite.js";
|
|
13
|
+
const SERVER_ENTRY = new URL("./server.js", import.meta.url).pathname;
|
|
14
|
+
function waitFor(cond, timeoutMs = 6000) {
|
|
15
|
+
const start = Date.now();
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const tick = async () => {
|
|
18
|
+
if (await cond())
|
|
19
|
+
return resolve();
|
|
20
|
+
if (Date.now() - start > timeoutMs)
|
|
21
|
+
return reject(new Error("timeout"));
|
|
22
|
+
setTimeout(tick, 50);
|
|
23
|
+
};
|
|
24
|
+
tick();
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function freshDir(prefix) {
|
|
28
|
+
return mkdtempSync(join(tmpdir(), prefix));
|
|
29
|
+
}
|
|
30
|
+
async function withServer(port, dir, fn) {
|
|
31
|
+
process.env.MEGACOMPACT_DASHBOARD_PORT = port;
|
|
32
|
+
const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
|
|
33
|
+
try {
|
|
34
|
+
await waitFor(async () => {
|
|
35
|
+
try {
|
|
36
|
+
const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
|
|
37
|
+
const res = await fetch(`http://localhost:${raw.port}/api/version`);
|
|
38
|
+
return res.ok;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
|
|
45
|
+
return await fn(raw.port);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
child.kill("SIGTERM");
|
|
49
|
+
delete process.env.MEGACOMPACT_DASHBOARD_PORT;
|
|
50
|
+
rmSync(dir, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
describe("S34 /api/game-scores", () => {
|
|
54
|
+
test("GET ?metric=cache returns recorded rows as JSON array", async () => {
|
|
55
|
+
const dir = freshDir("dash-gs-cache-");
|
|
56
|
+
recordScore(dir, { repo_root: "/repo/a", metric: "cache", value: 42 });
|
|
57
|
+
await withServer("19430", dir, async (port) => {
|
|
58
|
+
const res = await fetch(`http://localhost:${port}/api/game-scores?metric=cache`);
|
|
59
|
+
assert.equal(res.status, 200);
|
|
60
|
+
const rows = (await res.json());
|
|
61
|
+
assert.ok(Array.isArray(rows));
|
|
62
|
+
assert.equal(rows.length, 1);
|
|
63
|
+
assert.equal(rows[0].repo_root, "/repo/a");
|
|
64
|
+
assert.equal(rows[0].value, 42);
|
|
65
|
+
assert.ok(typeof rows[0].ts === "number");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
test("unknown metric -> 400", async () => {
|
|
69
|
+
const dir = freshDir("dash-gs-bad-");
|
|
70
|
+
await withServer("19431", dir, async (port) => {
|
|
71
|
+
const res = await fetch(`http://localhost:${port}/api/game-scores?metric=bogus`);
|
|
72
|
+
assert.equal(res.status, 400);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
test("non-GET (POST) -> 405", async () => {
|
|
76
|
+
const dir = freshDir("dash-gs-meth-");
|
|
77
|
+
await withServer("19432", dir, async (port) => {
|
|
78
|
+
const res = await fetch(`http://localhost:${port}/api/game-scores`, { method: "POST" });
|
|
79
|
+
assert.equal(res.status, 405);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
test("limit clamp: 3 rows -> default 10 returns 3, limit=2 caps to 2, limit<=0 clamps to >=1", async () => {
|
|
83
|
+
const dir = freshDir("dash-gs-lim-");
|
|
84
|
+
recordScore(dir, { repo_root: "/repo/a", metric: "cache", value: 1 });
|
|
85
|
+
recordScore(dir, { repo_root: "/repo/b", metric: "cache", value: 2 });
|
|
86
|
+
recordScore(dir, { repo_root: "/repo/c", metric: "cache", value: 3 });
|
|
87
|
+
await withServer("19433", dir, async (port) => {
|
|
88
|
+
const all = (await fetch(`http://localhost:${port}/api/game-scores?metric=cache`).then((r) => r.json()));
|
|
89
|
+
assert.equal(all.length, 3);
|
|
90
|
+
const capped = (await fetch(`http://localhost:${port}/api/game-scores?metric=cache&limit=2`).then((r) => r.json()));
|
|
91
|
+
assert.equal(capped.length, 2);
|
|
92
|
+
const zero = (await fetch(`http://localhost:${port}/api/game-scores?metric=cache&limit=0`).then((r) => r.json()));
|
|
93
|
+
assert.ok(zero.length >= 1, "limit<=0 clamps to >=1 row");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
describe("S35 /api/achievements", () => {
|
|
98
|
+
test("GET returns the 9 seeded achievement rows", async () => {
|
|
99
|
+
const dir = freshDir("dash-ach-");
|
|
100
|
+
await withServer("19434", dir, async (port) => {
|
|
101
|
+
const res = await fetch(`http://localhost:${port}/api/achievements`);
|
|
102
|
+
assert.equal(res.status, 200);
|
|
103
|
+
const rows = (await res.json());
|
|
104
|
+
assert.ok(Array.isArray(rows));
|
|
105
|
+
assert.equal(rows.length, 9);
|
|
106
|
+
const opie = rows.find((r) => r.id === "opie_wild_ride");
|
|
107
|
+
assert.ok(opie, "opie_wild_ride seeded");
|
|
108
|
+
assert.equal(opie.hidden, 1);
|
|
109
|
+
assert.equal(opie.unlocked_at, null);
|
|
110
|
+
assert.equal(rows.find((r) => r.id === "first_compact").title, "First Compact");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
test("non-GET (POST) -> 405", async () => {
|
|
114
|
+
const dir = freshDir("dash-ach-meth-");
|
|
115
|
+
await withServer("19435", dir, async (port) => {
|
|
116
|
+
const res = await fetch(`http://localhost:${port}/api/achievements`, { method: "POST" });
|
|
117
|
+
assert.equal(res.status, 405);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|