pi-mega-compact 0.8.7 → 0.8.8
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/html.js +46 -1
- package/dist/extensions/dashboard-server/perf-server.test.js +80 -0
- package/dist/extensions/dashboard-server/server.js +87 -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/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.js +1 -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 +79 -0
- 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 +1 -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
|
@@ -189,6 +189,7 @@ export function dashboardHtml(tierName: string): string {
|
|
|
189
189
|
<button class="tab" data-tab="active">Active Repos</button>
|
|
190
190
|
<button class="tab" data-tab="summary">Summary</button>
|
|
191
191
|
<button class="tab" data-tab="game">Game Mode</button>
|
|
192
|
+
<button class="tab" data-tab="perf">Perf</button>
|
|
192
193
|
</nav>
|
|
193
194
|
|
|
194
195
|
<!-- Current repo (existing single-repo view) -->
|
|
@@ -443,6 +444,18 @@ export function dashboardHtml(tierName: string): string {
|
|
|
443
444
|
<div id="game-empty">No scores yet — run a session with game mode on.</div>
|
|
444
445
|
</div>
|
|
445
446
|
|
|
447
|
+
<!-- Perf (v0.8.8) — live local instrumentation -->
|
|
448
|
+
<div class="tab-panel" id="panel-perf">
|
|
449
|
+
<div class="grid">
|
|
450
|
+
<div class="card"><h2>Model latency</h2><div class="stat-grid"><span class="label">Turn p50</span><span class="value" id="pf-turn-p50">—</span><span class="label">Turn p95</span><span class="value" id="pf-turn-p95">—</span><span class="label">Provider p50</span><span class="value" id="pf-prov-p50">—</span><span class="label">Provider p95</span><span class="value" id="pf-prov-p95">—</span></div></div>
|
|
451
|
+
<div class="card"><h2>Throughput</h2><div class="stat-grid"><span class="label">TPS (avg)</span><span class="value" id="pf-tps">—</span><span class="label">Cache hit %</span><span class="value" id="pf-cache">—</span></div></div>
|
|
452
|
+
<div class="card"><h2>Process</h2><div class="stat-grid"><span class="label">RSS</span><span class="value" id="pf-rss">—</span><span class="label">Heap</span><span class="value" id="pf-heap">—</span><span class="label">CPU user/sys</span><span class="value" id="pf-cpu">—</span></div></div>
|
|
453
|
+
<div class="card"><h2>Snapshot cost</h2><div class="stat-grid"><span class="label">DB recompute p50</span><span class="value" id="pf-db-p50">—</span><span class="label">DB recompute p95</span><span class="value" id="pf-db-p95">—</span><span class="label">Disk write p50</span><span class="value" id="pf-disk">—</span></div></div>
|
|
454
|
+
<div class="card"><h2>TUI lag proxy</h2><div class="stat-grid"><span class="label">Live-trim fires</span><span class="value" id="pf-recompute">—</span><span class="label">Cache replays</span><span class="value" id="pf-replays">—</span><span class="label">Fast-gate skips</span><span class="value" id="pf-skips">—</span></div><div class="meter-sub">skip vs recompute vs replay cadence</div></div>
|
|
455
|
+
</div>
|
|
456
|
+
<div class="updated" id="perf-updated">waiting for data</div>
|
|
457
|
+
</div>
|
|
458
|
+
|
|
446
459
|
<script>
|
|
447
460
|
(function() {
|
|
448
461
|
var evBox = document.getElementById('events');
|
|
@@ -1000,9 +1013,40 @@ export function dashboardHtml(tierName: string): string {
|
|
|
1000
1013
|
}).catch(function() {});
|
|
1001
1014
|
}
|
|
1002
1015
|
|
|
1016
|
+
// --- Perf tab (v0.8.8) — live local instrumentation ----------------------
|
|
1017
|
+
var perfPollTimer = null;
|
|
1018
|
+
function pollPerf() {
|
|
1019
|
+
fetch('/api/perf?minutes=30').then(function(r) { return r.ok ? r.json() : null; }).then(function(d) { // guardrails-allow PREVENT-PI-004: browser-side fetch in dashboard HTML template (not Node runtime)
|
|
1020
|
+
if (!d) return;
|
|
1021
|
+
var el = document.getElementById('perf-updated');
|
|
1022
|
+
if (el) el.textContent = d.sampleCount + ' samples \u00b7 updated ' + (d.updatedAt || '');
|
|
1023
|
+
function setText(id, txt) { var e = document.getElementById(id); if (e) e.textContent = txt; }
|
|
1024
|
+
function fmtMs(v) { return v == null ? '\u2014' : (v >= 100 ? Math.round(v) + 'ms' : v.toFixed(1) + 'ms'); }
|
|
1025
|
+
function fmtNum(v, dec) { return v == null ? '\u2014' : (typeof v === 'number' ? v.toFixed(dec) : '\u2014'); }
|
|
1026
|
+
setText('pf-turn-p50', fmtMs(d.turn_latency_ms && d.turn_latency_ms.p50));
|
|
1027
|
+
setText('pf-turn-p95', fmtMs(d.turn_latency_ms && d.turn_latency_ms.p95));
|
|
1028
|
+
setText('pf-prov-p50', fmtMs(d.provider_latency_ms && d.provider_latency_ms.p50));
|
|
1029
|
+
setText('pf-prov-p95', fmtMs(d.provider_latency_ms && d.provider_latency_ms.p95));
|
|
1030
|
+
setText('pf-tps', fmtNum(d.tps && d.tps.avg, 1));
|
|
1031
|
+
setText('pf-cache', (d.cache_hit_pct && typeof d.cache_hit_pct.avg === 'number') ? fmtNum(d.cache_hit_pct.avg, 1) + '%' : '\u2014');
|
|
1032
|
+
setText('pf-rss', (d.rss_mb && typeof d.rss_mb.latest === 'number') ? fmtNum(d.rss_mb.latest, 1) + ' MB' : '\u2014');
|
|
1033
|
+
setText('pf-heap', (d.heap_mb && typeof d.heap_mb.latest === 'number') ? fmtNum(d.heap_mb.latest, 1) + ' MB' : '\u2014');
|
|
1034
|
+
setText('pf-cpu', (d.cpu_user_ms && d.cpu_sys_ms) ? (fmtNum(d.cpu_user_ms.latest,1) + ' / ' + fmtNum(d.cpu_sys_ms.latest,1) + ' ms') : '\u2014');
|
|
1035
|
+
setText('pf-db-p50', fmtMs(d.db_recompute_ms && d.db_recompute_ms.p50));
|
|
1036
|
+
setText('pf-db-p95', fmtMs(d.db_recompute_ms && d.db_recompute_ms.p95));
|
|
1037
|
+
setText('pf-disk', fmtMs(d.disk_write_ms && d.disk_write_ms.p50));
|
|
1038
|
+
var diag = d.diag || {};
|
|
1039
|
+
setText('pf-recompute', diag.liveTrimFires != null ? String(diag.liveTrimFires) : '\u2014');
|
|
1040
|
+
setText('pf-replays', diag.liveTrimReplays != null ? String(diag.liveTrimReplays) : '\u2014');
|
|
1041
|
+
setText('pf-skips', diag.ctxFastGate != null ? String(diag.ctxFastGate) : '\u2014');
|
|
1042
|
+
}).catch(function() {});
|
|
1043
|
+
}
|
|
1044
|
+
function startPerfPoll() { if (perfPollTimer) return; pollPerf(); perfPollTimer = setInterval(pollPerf, 2000); }
|
|
1045
|
+
function stopPerfPoll() { if (perfPollTimer) { clearInterval(perfPollTimer); perfPollTimer = null; } }
|
|
1046
|
+
|
|
1003
1047
|
// --- Tab switching ------------------------------------------------------
|
|
1004
1048
|
var tabs = document.querySelectorAll('.tab');
|
|
1005
|
-
var panels = { current: 'panel-current', all: 'panel-all', active: 'panel-active', summary: 'panel-summary', game: 'panel-game' };
|
|
1049
|
+
var panels = { current: 'panel-current', all: 'panel-all', active: 'panel-active', summary: 'panel-summary', game: 'panel-game', perf: 'panel-perf' };
|
|
1006
1050
|
for (var i = 0; i < tabs.length; i++) {
|
|
1007
1051
|
tabs[i].addEventListener('click', function() {
|
|
1008
1052
|
var name = this.getAttribute('data-tab');
|
|
@@ -1017,6 +1061,7 @@ export function dashboardHtml(tierName: string): string {
|
|
|
1017
1061
|
if (name === 'all' || name === 'summary') pollIndex();
|
|
1018
1062
|
if (name === 'active') pollServers();
|
|
1019
1063
|
if (name === 'game') { renderGameScores(); renderAchievements(); }
|
|
1064
|
+
if (name === 'perf') startPerfPoll(); else stopPerfPoll();
|
|
1020
1065
|
});
|
|
1021
1066
|
}
|
|
1022
1067
|
})();
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* perf-server.test.ts — v0.8.8 /api/perf endpoint (GET aggregates + 405).
|
|
3
|
+
* Mirrors the server.test.ts spawn-and-fetch harness (self-contained so the
|
|
4
|
+
* dashboard HTTP-port lane stays isolated).
|
|
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 { recordPerfSample } from "../../src/store/sqlite.js";
|
|
13
|
+
|
|
14
|
+
const SERVER_ENTRY = new URL("./server.js", import.meta.url).pathname;
|
|
15
|
+
|
|
16
|
+
function waitFor(cond: () => boolean | Promise<boolean>, timeoutMs = 6000): Promise<void> {
|
|
17
|
+
const start = Date.now();
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const tick = async () => {
|
|
20
|
+
if (await cond()) return resolve();
|
|
21
|
+
if (Date.now() - start > timeoutMs) return reject(new Error("timeout"));
|
|
22
|
+
setTimeout(tick, 50);
|
|
23
|
+
};
|
|
24
|
+
tick();
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function freshDir(prefix: string): string {
|
|
29
|
+
return mkdtempSync(join(tmpdir(), prefix));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function withServer<T>(
|
|
33
|
+
port: string,
|
|
34
|
+
dir: string,
|
|
35
|
+
fn: (port: number) => Promise<T>,
|
|
36
|
+
): Promise<T> {
|
|
37
|
+
process.env.MEGACOMPACT_DASHBOARD_PORT = port;
|
|
38
|
+
const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
|
|
39
|
+
try {
|
|
40
|
+
await waitFor(async () => {
|
|
41
|
+
try {
|
|
42
|
+
const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
|
|
43
|
+
const res = await fetch(`http://localhost:${raw.port}/api/version`);
|
|
44
|
+
return res.ok;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
|
|
50
|
+
return await fn(raw.port);
|
|
51
|
+
} finally {
|
|
52
|
+
child.kill("SIGTERM");
|
|
53
|
+
delete process.env.MEGACOMPACT_DASHBOARD_PORT;
|
|
54
|
+
rmSync(dir, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface PerfResp {
|
|
59
|
+
sampleCount: number;
|
|
60
|
+
turn_latency_ms: { p50: number; p95: number; n: number };
|
|
61
|
+
provider_latency_ms: { p50: number; p95: number; n: number };
|
|
62
|
+
tps: { avg: number; n: number };
|
|
63
|
+
cache_hit_pct: { avg: number; latest: number; n: number };
|
|
64
|
+
db_recompute_ms: { p50: number; p95: number; n: number };
|
|
65
|
+
disk_write_ms: { p50: number; p95: number; n: number };
|
|
66
|
+
rss_mb: { latest: number; n: number };
|
|
67
|
+
heap_mb: { latest: number; n: number };
|
|
68
|
+
cpu_user_ms: { latest: number; n: number };
|
|
69
|
+
cpu_sys_ms: { latest: number; n: number };
|
|
70
|
+
diag: { ctxFastGate: number; liveTrimFires: number; liveTrimReplays: number } | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("v0.8.8 /api/perf", () => {
|
|
74
|
+
test("GET returns aggregates over recorded perf_samples", async () => {
|
|
75
|
+
const dir = freshDir("dash-perf-agg-");
|
|
76
|
+
recordPerfSample(dir, "turn_latency_ms", 100, { turnIndex: 1 });
|
|
77
|
+
recordPerfSample(dir, "turn_latency_ms", 200);
|
|
78
|
+
recordPerfSample(dir, "tps", 50);
|
|
79
|
+
recordPerfSample(dir, "rss_mb", 256);
|
|
80
|
+
await withServer("19440", dir, async (port) => {
|
|
81
|
+
const res = await fetch(`http://localhost:${port}/api/perf?minutes=30`);
|
|
82
|
+
assert.equal(res.status, 200);
|
|
83
|
+
const d = (await res.json()) as PerfResp;
|
|
84
|
+
assert.equal(d.sampleCount, 4);
|
|
85
|
+
assert.equal(d.turn_latency_ms.n, 2);
|
|
86
|
+
assert.equal(d.turn_latency_ms.p50, 100); // nearest-rank p50 of [100,200]
|
|
87
|
+
assert.equal(d.turn_latency_ms.p95, 200); // nearest-rank p95 of [100,200]
|
|
88
|
+
assert.equal(d.tps.avg, 50);
|
|
89
|
+
assert.equal(d.rss_mb.latest, 256);
|
|
90
|
+
assert.equal(d.diag, null); // no runtime wrote dashboard.json in this dir
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("non-GET (POST) -> 405", async () => {
|
|
95
|
+
const dir = freshDir("dash-perf-meth-");
|
|
96
|
+
await withServer("19441", dir, async (port) => {
|
|
97
|
+
const res = await fetch(`http://localhost:${port}/api/perf`, { method: "POST" });
|
|
98
|
+
assert.equal(res.status, 405);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -415,6 +415,85 @@ export async function launchDashboardServer(stateDir: string): Promise<{ port: n
|
|
|
415
415
|
return;
|
|
416
416
|
}
|
|
417
417
|
|
|
418
|
+
// /api/perf — v0.8.8 Perf dashboard tab. GET returns rolling-window
|
|
419
|
+
// aggregates over perf_samples: per-kind p50/p95 (turn/provider latency,
|
|
420
|
+
// tps avg, db recompute, disk write), latest rss/heap, cpu user/sys delta,
|
|
421
|
+
// cache hit %, plus the diag recompute/skip/replay counts (read from
|
|
422
|
+
// dashboard.json snapshot if available). The dashboard server is a detached
|
|
423
|
+
// child with no MegaRuntime ref, so it reads perf_samples via a require()'d
|
|
424
|
+
// sqlite helper (same pattern as /api/game-scores). Unknown/invalid params
|
|
425
|
+
// are clamped (never throw). Non-GET -> 405. PREVENT-PI-004: loopback.
|
|
426
|
+
if (req.url?.startsWith("/api/perf")) {
|
|
427
|
+
const pfReq = createRequire(import.meta.url);
|
|
428
|
+
const { readPerfSamples } = pfReq("../../src/store/sqlite.js") as typeof import("../../src/store/sqlite.js");
|
|
429
|
+
if (req.method !== "GET") {
|
|
430
|
+
res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
|
|
431
|
+
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
try {
|
|
435
|
+
const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
|
|
436
|
+
let minutes = Number(url.searchParams.get("minutes") ?? "30");
|
|
437
|
+
if (!Number.isFinite(minutes) || minutes <= 0) minutes = 30;
|
|
438
|
+
minutes = Math.min(minutes, 1440); // cap at 24h
|
|
439
|
+
const sinceTs = Date.now() - minutes * 60_000;
|
|
440
|
+
const rows = readPerfSamples(stateDir, sinceTs); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
|
|
441
|
+
const byKind = new Map<string, number[]>();
|
|
442
|
+
for (const r of rows) {
|
|
443
|
+
let arr = byKind.get(r.kind);
|
|
444
|
+
if (!arr) { arr = []; byKind.set(r.kind, arr); }
|
|
445
|
+
arr.push(r.value);
|
|
446
|
+
}
|
|
447
|
+
// Nearest-rank percentile (ceil(p/100*n)-1, clamped). Code-controlled,
|
|
448
|
+
// never user input (PREVENT-002 safe).
|
|
449
|
+
function pct(arr: number[], p: number): number {
|
|
450
|
+
if (!arr.length) return 0;
|
|
451
|
+
const s = [...arr].sort((a, b) => a - b);
|
|
452
|
+
const idx = Math.min(s.length - 1, Math.max(0, Math.ceil((p / 100) * s.length) - 1));
|
|
453
|
+
return s[idx];
|
|
454
|
+
}
|
|
455
|
+
function avg(arr: number[]): number {
|
|
456
|
+
if (!arr.length) return 0;
|
|
457
|
+
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
|
458
|
+
}
|
|
459
|
+
// rows are ASC by ts, so the last pushed value is the most recent.
|
|
460
|
+
function latest(arr: number[]): number {
|
|
461
|
+
return arr.length ? arr[arr.length - 1] : 0;
|
|
462
|
+
}
|
|
463
|
+
const get = (k: string): number[] => byKind.get(k) ?? [];
|
|
464
|
+
// diag counters live in the runtime-written dashboard.json (the server is
|
|
465
|
+
// a detached child with no MegaRuntime ref). Read defensively — absent
|
|
466
|
+
// until the first snapshot() write (PREVENT-001: assign before access).
|
|
467
|
+
let diag: { ctxFastGate: number; liveTrimFires: number; liveTrimReplays: number } | null = null;
|
|
468
|
+
try {
|
|
469
|
+
const raw = readFileSync(snapshotPath, "utf-8");
|
|
470
|
+
const parsed = JSON.parse(raw) as { diag?: { ctxFastGate: number; liveTrimFires: number; liveTrimReplays: number } };
|
|
471
|
+
if (parsed && typeof parsed === "object" && parsed.diag) diag = parsed.diag;
|
|
472
|
+
} catch { /* dashboard.json not written yet */ }
|
|
473
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
474
|
+
res.end(JSON.stringify({
|
|
475
|
+
updatedAt: new Date().toISOString(),
|
|
476
|
+
windowMinutes: minutes,
|
|
477
|
+
sampleCount: rows.length,
|
|
478
|
+
turn_latency_ms: { p50: pct(get("turn_latency_ms"), 50), p95: pct(get("turn_latency_ms"), 95), n: get("turn_latency_ms").length },
|
|
479
|
+
provider_latency_ms: { p50: pct(get("provider_latency_ms"), 50), p95: pct(get("provider_latency_ms"), 95), n: get("provider_latency_ms").length },
|
|
480
|
+
tps: { avg: avg(get("tps")), n: get("tps").length },
|
|
481
|
+
cache_hit_pct: { avg: avg(get("cache_hit_pct")), latest: latest(get("cache_hit_pct")), n: get("cache_hit_pct").length },
|
|
482
|
+
db_recompute_ms: { p50: pct(get("db_recompute_ms"), 50), p95: pct(get("db_recompute_ms"), 95), n: get("db_recompute_ms").length },
|
|
483
|
+
disk_write_ms: { p50: pct(get("disk_write_ms"), 50), p95: pct(get("disk_write_ms"), 95), n: get("disk_write_ms").length },
|
|
484
|
+
rss_mb: { latest: latest(get("rss_mb")), n: get("rss_mb").length },
|
|
485
|
+
heap_mb: { latest: latest(get("heap_mb")), n: get("heap_mb").length },
|
|
486
|
+
cpu_user_ms: { latest: latest(get("cpu_user_ms")), n: get("cpu_user_ms").length },
|
|
487
|
+
cpu_sys_ms: { latest: latest(get("cpu_sys_ms")), n: get("cpu_sys_ms").length },
|
|
488
|
+
diag,
|
|
489
|
+
}));
|
|
490
|
+
} catch (e) {
|
|
491
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
492
|
+
res.end(JSON.stringify({ error: "perf_unavailable", detail: String(e) }));
|
|
493
|
+
}
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
418
497
|
// /api/achievements — S35 achievement tiles. GET returns the 9 seeded rows
|
|
419
498
|
// {id,title,description,icon,hidden,unlocked_at}. The dashboard server is a
|
|
420
499
|
// detached child with no MegaRuntime ref, so it reads game_achievements via
|
|
@@ -139,6 +139,13 @@ export interface DashboardSnapshot {
|
|
|
139
139
|
inputRate: number; // USD per input token (Model.cost)
|
|
140
140
|
outputRate: number; // USD per output token (Model.cost)
|
|
141
141
|
};
|
|
142
|
+
/** v0.8.8 Perf dashboard: live diag counters (skip vs recompute vs replay)
|
|
143
|
+
* for the Perf tab's "TUI lag proxy" cards. Optional for back-compat. */
|
|
144
|
+
diag?: {
|
|
145
|
+
ctxFastGate: number;
|
|
146
|
+
liveTrimFires: number;
|
|
147
|
+
liveTrimReplays: number;
|
|
148
|
+
};
|
|
142
149
|
}
|
|
143
150
|
|
|
144
151
|
export class Dashboard {
|
|
@@ -151,9 +158,19 @@ export class Dashboard {
|
|
|
151
158
|
this.eventsPath = join(stateDir, "events.log");
|
|
152
159
|
}
|
|
153
160
|
|
|
161
|
+
/** v0.8.8: duration (ms) of the last dashboard.json write — read by
|
|
162
|
+
* MegaRuntime.snapshot() to record a `disk_write_ms` perf sample without
|
|
163
|
+
* wrapping the giant snapshot object literal at the call site. */
|
|
164
|
+
private _lastWriteMs = 0;
|
|
165
|
+
get lastWriteMs(): number {
|
|
166
|
+
return this._lastWriteMs;
|
|
167
|
+
}
|
|
168
|
+
|
|
154
169
|
/** Write a full state snapshot (atomically replaces previous). */
|
|
155
170
|
snapshot(data: DashboardSnapshot): void {
|
|
171
|
+
const t = performance.now();
|
|
156
172
|
writeFileSync(this.snapshotPath, JSON.stringify(data, null, 2) + "\n");
|
|
173
|
+
this._lastWriteMs = performance.now() - t;
|
|
157
174
|
}
|
|
158
175
|
|
|
159
176
|
/** Append a timestamped JSONL event line. */
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mega-events/perf-handler.ts — local perf instrumentation handlers (v0.8.8).
|
|
3
|
+
*
|
|
4
|
+
* Captures cheap, local-only telemetry into the `perf_samples` SQLite table for
|
|
5
|
+
* the dashboard's Perf tab: turn + provider latency, TPS, cache hit %, and (via
|
|
6
|
+
* MegaRuntime.ensurePerfInterval) a 5s cpu/mem interval. All capture is wrapped in
|
|
7
|
+
* try/catch — instrumentation NEVER blocks the agent loop (non-fatal).
|
|
8
|
+
*
|
|
9
|
+
* PREVENT-PI-004: Date.now / process.cpuUsage / process.memoryUsage + local
|
|
10
|
+
* SQLite only, zero network.
|
|
11
|
+
* PREVENT-011: no `any` — the usage block is narrowed structurally.
|
|
12
|
+
*/
|
|
13
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { type MegaRuntime } from "../mega-runtime.js";
|
|
15
|
+
import { recordPerfSample } from "../../src/store/sqlite.js";
|
|
16
|
+
|
|
17
|
+
/** Structural view of an AssistantMessage usage block (no pi-ai import). */
|
|
18
|
+
interface UsageBlock {
|
|
19
|
+
input: number;
|
|
20
|
+
output: number;
|
|
21
|
+
cacheRead: number;
|
|
22
|
+
cacheWrite: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Narrow a turn_end message to its usage block when it is an assistant msg. */
|
|
26
|
+
function usageOf(
|
|
27
|
+
msg: { role?: string; usage?: UsageBlock },
|
|
28
|
+
): UsageBlock | null {
|
|
29
|
+
if (msg.role !== "assistant" || !msg.usage) return null;
|
|
30
|
+
return msg.usage;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Register perf instrumentation handlers + start the 5s cpu/mem interval. */
|
|
34
|
+
export function registerPerfHandler(
|
|
35
|
+
pi: ExtensionAPI,
|
|
36
|
+
runtime: MegaRuntime,
|
|
37
|
+
): void {
|
|
38
|
+
// turn_start: record the wall-clock start of the turn. Using Date.now() (not
|
|
39
|
+
// event.timestamp) so the turn_end duration is on ONE clock — mixing pi's
|
|
40
|
+
// timestamp with Date.now() would skew the delta. Also (re)arms the cpu/mem
|
|
41
|
+
// interval so a new session after a dispose() resumes sampling on its first
|
|
42
|
+
// turn (the interval is cleared in runtime.dispose()).
|
|
43
|
+
pi.on("turn_start", async () => {
|
|
44
|
+
try {
|
|
45
|
+
runtime.perfTurnStart = Date.now();
|
|
46
|
+
runtime.ensurePerfInterval();
|
|
47
|
+
} catch {
|
|
48
|
+
/* non-fatal */
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// turn_end: compute turn latency + TPS + cache hit % from the assistant
|
|
53
|
+
// message's usage block. One perf_samples row per metric per turn.
|
|
54
|
+
pi.on("turn_end", async (event) => {
|
|
55
|
+
try {
|
|
56
|
+
if (runtime.perfTurnStart > 0) {
|
|
57
|
+
const durMs = Date.now() - runtime.perfTurnStart;
|
|
58
|
+
recordPerfSample(runtime.currentStateDir, "turn_latency_ms", durMs, {
|
|
59
|
+
turnIndex: event.turnIndex,
|
|
60
|
+
});
|
|
61
|
+
const u = usageOf(event.message);
|
|
62
|
+
if (u) {
|
|
63
|
+
const durSec = Math.max(durMs / 1000, 0.001);
|
|
64
|
+
recordPerfSample(
|
|
65
|
+
runtime.currentStateDir,
|
|
66
|
+
"tps",
|
|
67
|
+
u.output / durSec,
|
|
68
|
+
{ outputTokens: u.output },
|
|
69
|
+
);
|
|
70
|
+
const denom = u.cacheRead + u.input + u.cacheWrite;
|
|
71
|
+
const hitPct = denom > 0 ? (u.cacheRead / denom) * 100 : 0;
|
|
72
|
+
recordPerfSample(
|
|
73
|
+
runtime.currentStateDir,
|
|
74
|
+
"cache_hit_pct",
|
|
75
|
+
hitPct,
|
|
76
|
+
{ input: u.input, cacheRead: u.cacheRead, cacheWrite: u.cacheWrite },
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
/* non-fatal: instrumentation must never break the agent loop */
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// before_provider_request -> after_provider_response: raw round-trip latency
|
|
86
|
+
// to the model endpoint (HTTP status carried on the response event).
|
|
87
|
+
pi.on("before_provider_request", async () => {
|
|
88
|
+
try {
|
|
89
|
+
runtime.perfProviderStart = Date.now();
|
|
90
|
+
} catch {
|
|
91
|
+
/* non-fatal */
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
pi.on("after_provider_response", async (event) => {
|
|
95
|
+
try {
|
|
96
|
+
if (runtime.perfProviderStart > 0) {
|
|
97
|
+
const lat = Date.now() - runtime.perfProviderStart;
|
|
98
|
+
recordPerfSample(
|
|
99
|
+
runtime.currentStateDir,
|
|
100
|
+
"provider_latency_ms",
|
|
101
|
+
lat,
|
|
102
|
+
{ status: event.status },
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
/* non-fatal */
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Start the 5s cpu/mem sampling interval (one per MegaRuntime; cleared in
|
|
111
|
+
// runtime.dispose()). Idempotent — safe to call again after a dispose().
|
|
112
|
+
runtime.ensurePerfInterval();
|
|
113
|
+
}
|
|
@@ -12,6 +12,7 @@ import { registerSessionHandlers } from "./session-handlers.js";
|
|
|
12
12
|
import { registerAgentHandlers } from "./agent-handlers.js";
|
|
13
13
|
import { registerContextHandler } from "./context-handler.js";
|
|
14
14
|
import { registerCompactHandlers } from "./compact-handlers.js";
|
|
15
|
+
import { registerPerfHandler } from "./perf-handler.js";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* DIAG accessor for the headless test harness: the most recently constructed
|
|
@@ -34,4 +35,5 @@ export function registerEventHandlers(
|
|
|
34
35
|
registerAgentHandlers(pi, runtime, config);
|
|
35
36
|
registerContextHandler(pi, runtime, config);
|
|
36
37
|
registerCompactHandlers(pi, runtime, config);
|
|
38
|
+
registerPerfHandler(pi, runtime);
|
|
37
39
|
}
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
getRecallInjected,
|
|
29
29
|
getCacheHitTokensSaved,
|
|
30
30
|
getGameState,
|
|
31
|
+
recordPerfSample,
|
|
31
32
|
type ModelSnapshot,
|
|
32
33
|
type GameState,
|
|
33
34
|
} from "../../src/store/sqlite.js";
|
|
@@ -164,6 +165,12 @@ export class MegaRuntime {
|
|
|
164
165
|
// Last explain-why line (dedup reason / anchor-kept / superseded), surfaced
|
|
165
166
|
// while fresh.
|
|
166
167
|
lastWhy: string | undefined = undefined;
|
|
168
|
+
// v0.8.8 Perf dashboard instrumentation: turn/provider start timestamps +
|
|
169
|
+
// the 5s cpu/mem interval handle (one per MegaRuntime, cleared in dispose()).
|
|
170
|
+
perfTurnStart = 0;
|
|
171
|
+
perfProviderStart = 0;
|
|
172
|
+
perfCpuInterval: ReturnType<typeof setInterval> | undefined;
|
|
173
|
+
private perfCpuBaseline: { user: number; sys: number } | undefined;
|
|
167
174
|
|
|
168
175
|
// Context tracking for the dashboard (updated in the context handler).
|
|
169
176
|
lastCtxTokens: number | null = null;
|
|
@@ -385,6 +392,7 @@ export class MegaRuntime {
|
|
|
385
392
|
this.renderWidget(ctx);
|
|
386
393
|
return;
|
|
387
394
|
}
|
|
395
|
+
const perfT0 = performance.now();
|
|
388
396
|
const st = this.store.stats(this.rt.sessionId);
|
|
389
397
|
const repo = this.store.repoStats();
|
|
390
398
|
const di = this.store.dataInvariant();
|
|
@@ -540,7 +548,13 @@ export class MegaRuntime {
|
|
|
540
548
|
cacheHit: { sessionSec: sec(this.rt.cacheHitTokens), totalSec: sec(cacheHitsTotalTokens) },
|
|
541
549
|
},
|
|
542
550
|
model,
|
|
551
|
+
diag: {
|
|
552
|
+
ctxFastGate: this.diagCtxFastGate,
|
|
553
|
+
liveTrimFires: this.diagLiveTrimFires,
|
|
554
|
+
liveTrimReplays: this.diagLiveTrimReplays,
|
|
555
|
+
},
|
|
543
556
|
} as DashboardSnapshot);
|
|
557
|
+
const perfDiskMs = this.dashboard.lastWriteMs;
|
|
544
558
|
|
|
545
559
|
// Live stats widget above the editor
|
|
546
560
|
if (ctx) {
|
|
@@ -708,6 +722,12 @@ export class MegaRuntime {
|
|
|
708
722
|
}
|
|
709
723
|
// v0.8.5: record the material-change signature computed at the top so the
|
|
710
724
|
// next snapshot() can skip this whole body when nothing material changed.
|
|
725
|
+
try {
|
|
726
|
+
recordPerfSample(this.currentStateDir, "db_recompute_ms", performance.now() - perfT0);
|
|
727
|
+
recordPerfSample(this.currentStateDir, "disk_write_ms", perfDiskMs);
|
|
728
|
+
} catch {
|
|
729
|
+
/* non-fatal: perf instrumentation never blocks the agent */
|
|
730
|
+
}
|
|
711
731
|
this.lastSnapshotSig = sig;
|
|
712
732
|
}
|
|
713
733
|
|
|
@@ -981,6 +1001,43 @@ export class MegaRuntime {
|
|
|
981
1001
|
this.gameStateWatcher = undefined;
|
|
982
1002
|
this.gameStateWatchDir = undefined;
|
|
983
1003
|
}
|
|
1004
|
+
// v0.8.8: stop the cpu/mem sampling interval on teardown. Re-armed lazily
|
|
1005
|
+
// by ensurePerfInterval() on the next turn_start.
|
|
1006
|
+
if (this.perfCpuInterval) {
|
|
1007
|
+
clearInterval(this.perfCpuInterval);
|
|
1008
|
+
this.perfCpuInterval = undefined;
|
|
1009
|
+
this.perfCpuBaseline = undefined;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** v0.8.8: (re)start the 5s cpu/mem sampling interval (idempotent). One per
|
|
1014
|
+
* MegaRuntime; cleared in dispose(). Samples process.cpuUsage() (user/sys
|
|
1015
|
+
* delta vs the last tick → ms) + process.memoryUsage() (rss/heap → MB) and
|
|
1016
|
+
* records them as perf_samples. unref'd so it never keeps the process alive
|
|
1017
|
+
* on its own. Non-fatal: any failure is swallowed (instrumentation never
|
|
1018
|
+
* blocks the agent). PREVENT-PI-004: local process stats + SQLite only. */
|
|
1019
|
+
ensurePerfInterval(): void {
|
|
1020
|
+
if (this.perfCpuInterval) return;
|
|
1021
|
+
this.perfCpuBaseline = undefined; // first tick sets the baseline (no delta)
|
|
1022
|
+
this.perfCpuInterval = setInterval(() => {
|
|
1023
|
+
try {
|
|
1024
|
+
const dir = this.currentStateDir;
|
|
1025
|
+
const cpu = process.cpuUsage();
|
|
1026
|
+
const mem = process.memoryUsage();
|
|
1027
|
+
if (this.perfCpuBaseline) {
|
|
1028
|
+
const du = (cpu.user - this.perfCpuBaseline.user) / 1000; // μs → ms
|
|
1029
|
+
const ds = (cpu.system - this.perfCpuBaseline.sys) / 1000;
|
|
1030
|
+
recordPerfSample(dir, "cpu_user_ms", Math.max(0, du));
|
|
1031
|
+
recordPerfSample(dir, "cpu_sys_ms", Math.max(0, ds));
|
|
1032
|
+
}
|
|
1033
|
+
this.perfCpuBaseline = { user: cpu.user, sys: cpu.system };
|
|
1034
|
+
recordPerfSample(dir, "rss_mb", mem.rss / 1_000_000);
|
|
1035
|
+
recordPerfSample(dir, "heap_mb", mem.heapUsed / 1_000_000);
|
|
1036
|
+
} catch {
|
|
1037
|
+
/* non-fatal */
|
|
1038
|
+
}
|
|
1039
|
+
}, 5000);
|
|
1040
|
+
this.perfCpuInterval.unref?.();
|
|
984
1041
|
}
|
|
985
1042
|
|
|
986
1043
|
/** S31: the cached game-mode state (game_mode_on/theme/tui_display_mode).
|
package/package.json
CHANGED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* perf-samples.test.ts — v0.8.8 perf_samples table round-trip + filtering.
|
|
3
|
+
* Pi-agnostic. Uses an isolated state dir (never the real user dir — G7).
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, before, after } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { closeStore } from "./utils.js";
|
|
11
|
+
import {
|
|
12
|
+
recordPerfSample,
|
|
13
|
+
readPerfSamples,
|
|
14
|
+
PERF_KINDS,
|
|
15
|
+
} from "./perf-samples.js";
|
|
16
|
+
|
|
17
|
+
describe("perf-samples (v0.8.8)", () => {
|
|
18
|
+
let dir: string;
|
|
19
|
+
before(() => {
|
|
20
|
+
dir = mkdtempSync(join(tmpdir(), "mc-perfsamples-"));
|
|
21
|
+
process.env.MEGACOMPACT_STATE_DIR = dir;
|
|
22
|
+
});
|
|
23
|
+
after(() => {
|
|
24
|
+
closeStore(dir);
|
|
25
|
+
delete process.env.MEGACOMPACT_STATE_DIR;
|
|
26
|
+
rmSync(dir, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("records + reads back a turn_latency_ms sample with parsed meta", () => {
|
|
30
|
+
recordPerfSample(dir, "turn_latency_ms", 123.4, { turnIndex: 2 });
|
|
31
|
+
const rows = readPerfSamples(dir, 0);
|
|
32
|
+
assert.equal(rows.length, 1);
|
|
33
|
+
assert.equal(rows[0].kind, "turn_latency_ms");
|
|
34
|
+
assert.equal(rows[0].value, 123.4);
|
|
35
|
+
assert.deepEqual(rows[0].meta, { turnIndex: 2 });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("filters by kind and by sinceTs", () => {
|
|
39
|
+
recordPerfSample(dir, "tps", 50);
|
|
40
|
+
recordPerfSample(dir, "rss_mb", 256);
|
|
41
|
+
const tps = readPerfSamples(dir, 0, "tps");
|
|
42
|
+
assert.equal(tps.length, 1);
|
|
43
|
+
assert.equal(tps[0].kind, "tps");
|
|
44
|
+
assert.equal(tps[0].value, 50);
|
|
45
|
+
const future = readPerfSamples(dir, Date.now() + 10000, "tps");
|
|
46
|
+
assert.equal(future.length, 0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("ignores non-finite values + unknown kinds (never throws, nothing added)", () => {
|
|
50
|
+
const before = readPerfSamples(dir, 0).length;
|
|
51
|
+
recordPerfSample(dir, "tps", Number.NaN);
|
|
52
|
+
recordPerfSample(dir, "tps", Infinity);
|
|
53
|
+
assert.doesNotThrow(() =>
|
|
54
|
+
recordPerfSample(dir, "bogus" as never, 1),
|
|
55
|
+
);
|
|
56
|
+
const after = readPerfSamples(dir, 0).length;
|
|
57
|
+
assert.equal(after, before);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("PERF_KINDS lists the 10 instrumentation kinds", () => {
|
|
61
|
+
assert.equal(PERF_KINDS.length, 10);
|
|
62
|
+
assert.ok(PERF_KINDS.includes("db_recompute_ms"));
|
|
63
|
+
assert.ok(PERF_KINDS.includes("cache_hit_pct"));
|
|
64
|
+
});
|
|
65
|
+
});
|