pi-mega-compact 0.8.21 → 0.8.22
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/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/promote.test.js +5 -5
- 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 +1 -1
- package/src/dedup/raptor/promote.test.ts +5 -5
- 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
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dashboard-server/routes-core.ts — RouteContext interface + shared helpers.
|
|
3
|
+
*
|
|
4
|
+
* The core module defines the shared context interface and the factory that
|
|
5
|
+
* builds it. Route handler files import from here; the barrel (routes.ts)
|
|
6
|
+
* re-exports everything for server.ts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ServerResponse } from "node:http";
|
|
10
|
+
|
|
11
|
+
import { readSnapshot } from "./snapshot.js";
|
|
12
|
+
import type { IndexIndex, Snapshot } from "./types.js";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// RouteContext — every value closed over by the route bodies lives here.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export interface RouteContext {
|
|
19
|
+
/** Path to the per-repo dashboard.json snapshot (read via readSnapshot). */
|
|
20
|
+
snapshotPath: string;
|
|
21
|
+
/** Path to the events.log SSE tail file. */
|
|
22
|
+
eventsPath: string;
|
|
23
|
+
/** Current stateDir — the repo being served by this server instance. */
|
|
24
|
+
stateDir: string;
|
|
25
|
+
/** SERVER_VERSION set at launch (exposed at /api/version). */
|
|
26
|
+
SERVER_VERSION: string;
|
|
27
|
+
/**
|
|
28
|
+
* Serve a file from the React client build directory.
|
|
29
|
+
* Returns true if served; false means fall through to the next handler.
|
|
30
|
+
*/
|
|
31
|
+
serveClientAsset: (reqPath: string, res: ServerResponse) => boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Mutex shared between handleEvents and the SSE write loop.
|
|
34
|
+
* The handler increments this; the outer closure reads it to set the
|
|
35
|
+
* initial SSE tail offset on new connections.
|
|
36
|
+
*/
|
|
37
|
+
eventOffsetRef: { value: number };
|
|
38
|
+
/** Overlay the live current-repo snapshot onto its index.sqlite row. */
|
|
39
|
+
overlayCurrentRepo: (idx: IndexIndex | null) => void;
|
|
40
|
+
/** Cross-repo drift detector (lazy-required to avoid a top-level await). */
|
|
41
|
+
detectCrossRepoDrift: (idxDir: string) => ReturnType<
|
|
42
|
+
typeof import("../../src/driftDetection.js").detectCrossRepoDrift
|
|
43
|
+
>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Helper: overlayCurrentRepo
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
function makeOverlayCurrentRepo(
|
|
51
|
+
snapshotPath: string,
|
|
52
|
+
stateDir: string,
|
|
53
|
+
): (idx: IndexIndex | null) => void {
|
|
54
|
+
return (idx: IndexIndex | null): void => {
|
|
55
|
+
if (!idx || !idx.repos.length) return;
|
|
56
|
+
let snap: Snapshot | null = null;
|
|
57
|
+
try {
|
|
58
|
+
snap = readSnapshot(snapshotPath);
|
|
59
|
+
} catch {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (!snap || !snap.repo) return;
|
|
63
|
+
const cur = idx.repos.find((r) => r.stateDir === stateDir);
|
|
64
|
+
if (!cur) return;
|
|
65
|
+
const prevSaved = cur.tokensSaved;
|
|
66
|
+
const prevCp = cur.checkpointCount;
|
|
67
|
+
const prevBytes = cur.compressedOriginalBytes;
|
|
68
|
+
const comp = snap.compression?.repo;
|
|
69
|
+
const liveSaved = comp
|
|
70
|
+
? comp.tokensFreed
|
|
71
|
+
: (snap.repo.tokensSaved ?? prevSaved);
|
|
72
|
+
const liveCp = snap.repo.checkpointCount ?? prevCp;
|
|
73
|
+
const liveBytes = snap.integrity?.compressedOriginalBytes ?? prevBytes;
|
|
74
|
+
cur.tokensSaved = liveSaved;
|
|
75
|
+
cur.checkpointCount = liveCp;
|
|
76
|
+
cur.compressedOriginalBytes = liveBytes;
|
|
77
|
+
if (idx.summary) {
|
|
78
|
+
idx.summary.totalTokensSaved += liveSaved - prevSaved;
|
|
79
|
+
idx.summary.totalCheckpoints += liveCp - prevCp;
|
|
80
|
+
idx.summary.totalCompressedOriginalBytes += liveBytes - prevBytes;
|
|
81
|
+
}
|
|
82
|
+
idx.updatedAt = snap.updatedAt ?? idx.updatedAt;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Factory — builds a fully-populated RouteContext for server.ts to use.
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
export function buildRouteContext(opts: {
|
|
91
|
+
snapshotPath: string;
|
|
92
|
+
eventsPath: string;
|
|
93
|
+
stateDir: string;
|
|
94
|
+
SERVER_VERSION: string;
|
|
95
|
+
serveClientAsset: (reqPath: string, res: ServerResponse) => boolean;
|
|
96
|
+
detectCrossRepoDrift: (idxDir: string) => ReturnType<
|
|
97
|
+
typeof import("../../src/driftDetection.js").detectCrossRepoDrift
|
|
98
|
+
>;
|
|
99
|
+
}): RouteContext {
|
|
100
|
+
return {
|
|
101
|
+
snapshotPath: opts.snapshotPath,
|
|
102
|
+
eventsPath: opts.eventsPath,
|
|
103
|
+
stateDir: opts.stateDir,
|
|
104
|
+
SERVER_VERSION: opts.SERVER_VERSION,
|
|
105
|
+
serveClientAsset: opts.serveClientAsset,
|
|
106
|
+
eventOffsetRef: { value: 0 },
|
|
107
|
+
overlayCurrentRepo: makeOverlayCurrentRepo(
|
|
108
|
+
opts.snapshotPath,
|
|
109
|
+
opts.stateDir,
|
|
110
|
+
),
|
|
111
|
+
detectCrossRepoDrift: opts.detectCrossRepoDrift,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dashboard-server/routes-game.ts — Game-mode and performance route handlers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
8
|
+
|
|
9
|
+
import type { RouteContext } from "./routes-core.js";
|
|
10
|
+
import type { GameMetric } from "../../src/game/scoring.js";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// handleGameState — "/api/game-state" (GET + PUT)
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export function handleGameState(
|
|
17
|
+
req: IncomingMessage,
|
|
18
|
+
res: ServerResponse,
|
|
19
|
+
ctx: RouteContext,
|
|
20
|
+
): boolean {
|
|
21
|
+
if (!req.url?.startsWith("/api/game-state")) return false;
|
|
22
|
+
|
|
23
|
+
const { stateDir } = ctx;
|
|
24
|
+
|
|
25
|
+
// /api/game-state — S32 game-mode settings (game_mode_on / theme /
|
|
26
|
+
// tui_display_mode). GET returns the current row; PUT applies a partial
|
|
27
|
+
// patch (validated) and returns the post-write row. The dashboard server is
|
|
28
|
+
// a detached child with no MegaRuntime ref, so it reads/writes the
|
|
29
|
+
// game_state SQLite row directly; the in-process MegaRuntime picks up the
|
|
30
|
+
// change via its fs.watch cache-eviction watcher. PREVENT-PI-004: loopback.
|
|
31
|
+
const gsReq = createRequire(import.meta.url);
|
|
32
|
+
const { getGameState, setGameState } = gsReq(
|
|
33
|
+
"../../src/store/sqlite.js",
|
|
34
|
+
) as typeof import("../../src/store/sqlite.js");
|
|
35
|
+
const { isValidTheme } = gsReq(
|
|
36
|
+
"../../src/config/themes.js",
|
|
37
|
+
) as typeof import("../../src/config/themes.js");
|
|
38
|
+
if (req.method === "GET") {
|
|
39
|
+
try {
|
|
40
|
+
const gs = getGameState(stateDir); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
|
|
41
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
42
|
+
res.end(JSON.stringify(gs));
|
|
43
|
+
} catch (e) {
|
|
44
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
45
|
+
res.end(
|
|
46
|
+
JSON.stringify({
|
|
47
|
+
error: "game_state_unavailable",
|
|
48
|
+
detail: String(e),
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
if (req.method === "PUT") {
|
|
55
|
+
// Read + parse the JSON body (capped — the patch is tiny). The handler
|
|
56
|
+
// is sync, so drain the stream via data/end listeners then continue.
|
|
57
|
+
let body = "";
|
|
58
|
+
let tooBig = false;
|
|
59
|
+
req.on("data", (chunk: Buffer) => {
|
|
60
|
+
// guardrails-allow PREVENT-PI-004: loopback dashboard request body (local)
|
|
61
|
+
if (body.length > 65536) {
|
|
62
|
+
tooBig = true;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
body += chunk.toString();
|
|
66
|
+
});
|
|
67
|
+
req.on("end", () => {
|
|
68
|
+
if (tooBig) {
|
|
69
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
70
|
+
res.end(JSON.stringify({ error: "body_too_large" }));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
let patch: Record<string, unknown> = {};
|
|
74
|
+
try {
|
|
75
|
+
patch = body ? JSON.parse(body) : {};
|
|
76
|
+
} catch {
|
|
77
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
78
|
+
res.end(JSON.stringify({ error: "invalid_json" }));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Reject valid-but-non-object JSON (null/[]/42) — dereferencing
|
|
82
|
+
// patch.game_mode_on would throw an unhandled TypeError inside this
|
|
83
|
+
// 'end' listener and crash the detached server (audit P1: loopback DoS).
|
|
84
|
+
if (
|
|
85
|
+
typeof patch !== "object" ||
|
|
86
|
+
patch === null ||
|
|
87
|
+
Array.isArray(patch)
|
|
88
|
+
) {
|
|
89
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
90
|
+
res.end(JSON.stringify({ error: "invalid_patch_object" }));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// Validate the patch fields (unknown keys ignored; invalid values -> 400).
|
|
94
|
+
const clean: {
|
|
95
|
+
game_mode_on?: boolean;
|
|
96
|
+
theme?: string;
|
|
97
|
+
tui_display_mode?: "full" | "minimal";
|
|
98
|
+
} = {};
|
|
99
|
+
let bad = false;
|
|
100
|
+
if (patch.game_mode_on != null) {
|
|
101
|
+
if (typeof patch.game_mode_on !== "boolean") bad = true;
|
|
102
|
+
else clean.game_mode_on = patch.game_mode_on;
|
|
103
|
+
}
|
|
104
|
+
if (patch.theme != null) {
|
|
105
|
+
if (typeof patch.theme !== "string" || !isValidTheme(patch.theme))
|
|
106
|
+
bad = true;
|
|
107
|
+
else clean.theme = patch.theme;
|
|
108
|
+
}
|
|
109
|
+
if (patch.tui_display_mode != null) {
|
|
110
|
+
if (
|
|
111
|
+
patch.tui_display_mode !== "full" &&
|
|
112
|
+
patch.tui_display_mode !== "minimal"
|
|
113
|
+
)
|
|
114
|
+
bad = true;
|
|
115
|
+
else clean.tui_display_mode = patch.tui_display_mode;
|
|
116
|
+
}
|
|
117
|
+
if (bad) {
|
|
118
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
119
|
+
res.end(JSON.stringify({ error: "invalid_patch" }));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const gs = setGameState(clean, stateDir); // guardrails-allow PREVENT-PI-004: local SQLite write (loopback dashboard)
|
|
124
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
125
|
+
res.end(JSON.stringify(gs));
|
|
126
|
+
} catch (e) {
|
|
127
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
128
|
+
res.end(
|
|
129
|
+
JSON.stringify({
|
|
130
|
+
error: "game_state_write_failed",
|
|
131
|
+
detail: String(e),
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
// Any other method on /api/game-state -> 405.
|
|
139
|
+
res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
140
|
+
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// handleGameScores — "/api/game-scores"
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
export function handleGameScores(
|
|
149
|
+
req: IncomingMessage,
|
|
150
|
+
res: ServerResponse,
|
|
151
|
+
ctx: RouteContext,
|
|
152
|
+
): boolean {
|
|
153
|
+
if (!req.url?.startsWith("/api/game-scores")) return false;
|
|
154
|
+
|
|
155
|
+
const { stateDir } = ctx;
|
|
156
|
+
|
|
157
|
+
// /api/game-scores — S34 high-score leaderboards. GET returns the leaderboard
|
|
158
|
+
// for a metric (?metric=<m>&limit=<n>). `metric` is validated against the
|
|
159
|
+
// METRICS allow-list from src/game/scoring (re-exported via the sqlite barrel);
|
|
160
|
+
// default limit 10, clamped to [1,100]. The dashboard server is a detached
|
|
161
|
+
// child with no MegaRuntime ref, so it reads the game_scores SQLite table
|
|
162
|
+
// directly. Unknown metric -> 400, non-GET -> 405. PREVENT-PI-004: loopback.
|
|
163
|
+
const gsReq = createRequire(import.meta.url);
|
|
164
|
+
const { leaderboard, METRICS } = gsReq(
|
|
165
|
+
"../../src/store/sqlite.js",
|
|
166
|
+
) as typeof import("../../src/store/sqlite.js");
|
|
167
|
+
if (req.method !== "GET") {
|
|
168
|
+
res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
169
|
+
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
|
|
174
|
+
const metricParam = url.searchParams.get("metric") ?? "cache";
|
|
175
|
+
if (!(METRICS as readonly string[]).includes(metricParam)) {
|
|
176
|
+
res.writeHead(400, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
177
|
+
res.end(
|
|
178
|
+
JSON.stringify({ error: "unknown_metric", metric: metricParam }),
|
|
179
|
+
);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
const metric = metricParam as GameMetric; // validated against METRICS above
|
|
183
|
+
let limit = Number(url.searchParams.get("limit") ?? "10");
|
|
184
|
+
if (!Number.isFinite(limit) || limit <= 0) limit = 10;
|
|
185
|
+
limit = Math.min(Math.max(limit, 1), 100); // clamp to [1,100]
|
|
186
|
+
const rows = leaderboard(stateDir, metric, { limit }); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
|
|
187
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
188
|
+
res.end(JSON.stringify(rows));
|
|
189
|
+
} catch (e) {
|
|
190
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
191
|
+
res.end(
|
|
192
|
+
JSON.stringify({
|
|
193
|
+
error: "game_scores_unavailable",
|
|
194
|
+
detail: String(e),
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// handlePerf — "/api/perf"
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
export function handlePerf(
|
|
206
|
+
req: IncomingMessage,
|
|
207
|
+
res: ServerResponse,
|
|
208
|
+
ctx: RouteContext,
|
|
209
|
+
): boolean {
|
|
210
|
+
if (!req.url?.startsWith("/api/perf")) return false;
|
|
211
|
+
|
|
212
|
+
const { stateDir, snapshotPath } = ctx;
|
|
213
|
+
|
|
214
|
+
// /api/perf — v0.8.8 Perf dashboard tab. GET returns rolling-window
|
|
215
|
+
// aggregates over perf_samples: per-kind p50/p95 (turn/provider latency,
|
|
216
|
+
// tps avg, db recompute, disk write), latest rss/heap, cpu user/sys delta,
|
|
217
|
+
// cache hit %, plus the diag recompute/skip/replay counts (read from
|
|
218
|
+
// dashboard.json snapshot if available). The dashboard server is a detached
|
|
219
|
+
// child with no MegaRuntime ref, so it reads perf_samples via a require()'d
|
|
220
|
+
// sqlite helper (same pattern as /api/game-scores). Unknown/invalid params
|
|
221
|
+
// are clamped (never throw). Non-GET -> 405. PREVENT-PI-004: loopback.
|
|
222
|
+
const pfReq = createRequire(import.meta.url);
|
|
223
|
+
const { readPerfSamples } = pfReq(
|
|
224
|
+
"../../src/store/sqlite.js",
|
|
225
|
+
) as typeof import("../../src/store/sqlite.js");
|
|
226
|
+
if (req.method !== "GET") {
|
|
227
|
+
res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
228
|
+
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
|
|
233
|
+
let minutes = Number(url.searchParams.get("minutes") ?? "30");
|
|
234
|
+
if (!Number.isFinite(minutes) || minutes <= 0) minutes = 30;
|
|
235
|
+
minutes = Math.min(minutes, 1440); // cap at 24h
|
|
236
|
+
const sinceTs = Date.now() - minutes * 60_000;
|
|
237
|
+
const rows = readPerfSamples(stateDir, sinceTs); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
|
|
238
|
+
const byKind = new Map<string, number[]>();
|
|
239
|
+
for (const r of rows) {
|
|
240
|
+
let arr = byKind.get(r.kind);
|
|
241
|
+
if (!arr) {
|
|
242
|
+
arr = [];
|
|
243
|
+
byKind.set(r.kind, arr);
|
|
244
|
+
}
|
|
245
|
+
arr.push(r.value);
|
|
246
|
+
}
|
|
247
|
+
// Nearest-rank percentile (ceil(p/100*n)-1, clamped). Code-controlled,
|
|
248
|
+
// never user input (PREVENT-002 safe).
|
|
249
|
+
function pct(arr: number[], p: number): number {
|
|
250
|
+
if (!arr.length) return 0;
|
|
251
|
+
const s = [...arr].sort((a, b) => a - b);
|
|
252
|
+
const idx = Math.min(
|
|
253
|
+
s.length - 1,
|
|
254
|
+
Math.max(0, Math.ceil((p / 100) * s.length) - 1),
|
|
255
|
+
);
|
|
256
|
+
return s[idx];
|
|
257
|
+
}
|
|
258
|
+
function avg(arr: number[]): number {
|
|
259
|
+
if (!arr.length) return 0;
|
|
260
|
+
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
|
261
|
+
}
|
|
262
|
+
// rows are ASC by ts, so the last pushed value is the most recent.
|
|
263
|
+
function latest(arr: number[]): number {
|
|
264
|
+
return arr.length ? arr[arr.length - 1] : 0;
|
|
265
|
+
}
|
|
266
|
+
const get = (k: string): number[] => byKind.get(k) ?? [];
|
|
267
|
+
// diag counters live in the runtime-written dashboard.json (the server is
|
|
268
|
+
// a detached child with no MegaRuntime ref). Read defensively — absent
|
|
269
|
+
// until the first snapshot() write (PREVENT-001: assign before access).
|
|
270
|
+
let diag: {
|
|
271
|
+
ctxFastGate: number;
|
|
272
|
+
liveTrimFires: number;
|
|
273
|
+
liveTrimReplays: number;
|
|
274
|
+
} | null = null;
|
|
275
|
+
try {
|
|
276
|
+
const raw = readFileSync(snapshotPath, "utf-8");
|
|
277
|
+
const parsed = JSON.parse(raw) as {
|
|
278
|
+
diag?: {
|
|
279
|
+
ctxFastGate: number;
|
|
280
|
+
liveTrimFires: number;
|
|
281
|
+
liveTrimReplays: number;
|
|
282
|
+
};
|
|
283
|
+
};
|
|
284
|
+
if (parsed && typeof parsed === "object" && parsed.diag)
|
|
285
|
+
diag = parsed.diag;
|
|
286
|
+
} catch {
|
|
287
|
+
/* dashboard.json not written yet */
|
|
288
|
+
}
|
|
289
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
290
|
+
res.end(
|
|
291
|
+
JSON.stringify({
|
|
292
|
+
updatedAt: new Date().toISOString(),
|
|
293
|
+
windowMinutes: minutes,
|
|
294
|
+
sampleCount: rows.length,
|
|
295
|
+
turn_latency_ms: {
|
|
296
|
+
p50: pct(get("turn_latency_ms"), 50),
|
|
297
|
+
p95: pct(get("turn_latency_ms"), 95),
|
|
298
|
+
n: get("turn_latency_ms").length,
|
|
299
|
+
},
|
|
300
|
+
provider_latency_ms: {
|
|
301
|
+
p50: pct(get("provider_latency_ms"), 50),
|
|
302
|
+
p95: pct(get("provider_latency_ms"), 95),
|
|
303
|
+
n: get("provider_latency_ms").length,
|
|
304
|
+
},
|
|
305
|
+
tps: { avg: avg(get("tps")), n: get("tps").length },
|
|
306
|
+
cache_hit_pct: {
|
|
307
|
+
avg: avg(get("cache_hit_pct")),
|
|
308
|
+
latest: latest(get("cache_hit_pct")),
|
|
309
|
+
n: get("cache_hit_pct").length,
|
|
310
|
+
},
|
|
311
|
+
db_recompute_ms: {
|
|
312
|
+
p50: pct(get("db_recompute_ms"), 50),
|
|
313
|
+
p95: pct(get("db_recompute_ms"), 95),
|
|
314
|
+
n: get("db_recompute_ms").length,
|
|
315
|
+
},
|
|
316
|
+
disk_write_ms: {
|
|
317
|
+
p50: pct(get("disk_write_ms"), 50),
|
|
318
|
+
p95: pct(get("disk_write_ms"), 95),
|
|
319
|
+
n: get("disk_write_ms").length,
|
|
320
|
+
},
|
|
321
|
+
rss_mb: { latest: latest(get("rss_mb")), n: get("rss_mb").length },
|
|
322
|
+
heap_mb: {
|
|
323
|
+
latest: latest(get("heap_mb")),
|
|
324
|
+
n: get("heap_mb").length,
|
|
325
|
+
},
|
|
326
|
+
cpu_user_ms: {
|
|
327
|
+
latest: latest(get("cpu_user_ms")),
|
|
328
|
+
n: get("cpu_user_ms").length,
|
|
329
|
+
},
|
|
330
|
+
cpu_sys_ms: {
|
|
331
|
+
latest: latest(get("cpu_sys_ms")),
|
|
332
|
+
n: get("cpu_sys_ms").length,
|
|
333
|
+
},
|
|
334
|
+
diag,
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
337
|
+
} catch (e) {
|
|
338
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
339
|
+
res.end(
|
|
340
|
+
JSON.stringify({ error: "perf_unavailable", detail: String(e) }),
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// handleAchievements — "/api/achievements"
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
export function handleAchievements(
|
|
351
|
+
req: IncomingMessage,
|
|
352
|
+
res: ServerResponse,
|
|
353
|
+
ctx: RouteContext,
|
|
354
|
+
): boolean {
|
|
355
|
+
if (!req.url?.startsWith("/api/achievements")) return false;
|
|
356
|
+
|
|
357
|
+
const { stateDir } = ctx;
|
|
358
|
+
|
|
359
|
+
// /api/achievements — S35 achievement tiles. GET returns the 9 seeded rows
|
|
360
|
+
// {id,title,description,icon,hidden,unlocked_at}. The dashboard server is a
|
|
361
|
+
// detached child with no MegaRuntime ref, so it reads game_achievements via
|
|
362
|
+
// listAchievements(stateDir) directly. Non-GET -> 405. PREVENT-PI-004: loopback.
|
|
363
|
+
const achReq = createRequire(import.meta.url);
|
|
364
|
+
const { listAchievements } = achReq(
|
|
365
|
+
"../../src/store/sqlite.js",
|
|
366
|
+
) as typeof import("../../src/store/sqlite.js");
|
|
367
|
+
if (req.method !== "GET") {
|
|
368
|
+
res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
369
|
+
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
const rows = listAchievements(stateDir); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
|
|
374
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
375
|
+
res.end(JSON.stringify(rows));
|
|
376
|
+
} catch (e) {
|
|
377
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
378
|
+
res.end(
|
|
379
|
+
JSON.stringify({
|
|
380
|
+
error: "achievements_unavailable",
|
|
381
|
+
detail: String(e),
|
|
382
|
+
}),
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
return true;
|
|
386
|
+
}
|