pi-mega-compact 0.8.17 → 0.8.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/extensions/dashboard-server/api-contracts/endpoints.js +12 -0
- package/dist/extensions/dashboard-server/api-contracts.test.js +19 -6
- package/dist/extensions/dashboard-server/helpers.js +37 -0
- package/dist/extensions/dashboard-server/html/all-repos-tab.js +26 -0
- package/dist/extensions/dashboard-server/html/body-open.js +23 -0
- package/dist/extensions/dashboard-server/html/current-repo-tab.js +130 -0
- package/dist/extensions/dashboard-server/html/head-open.js +16 -0
- package/dist/extensions/dashboard-server/html/high-score-tab.js +25 -0
- package/dist/extensions/dashboard-server/html/repo-detail-modal.js +26 -0
- package/dist/extensions/dashboard-server/html/script.js +259 -0
- package/dist/extensions/dashboard-server/html/styles.js +103 -0
- package/dist/extensions/dashboard-server/html/summary-tab.js +19 -0
- package/dist/extensions/dashboard-server/html-template.js +41 -0
- package/dist/extensions/dashboard-server/server.js +83 -0
- package/dist/extensions/mega-events/context-handler.js +22 -8
- package/dist/extensions/mega-runtime/state.js +17 -1
- package/dist/src/store/sqlite/connection.js +35 -0
- package/dist/src/store/sqlite/global-index.js +199 -1
- package/dist/src/store/sqlite/global-index.test.js +136 -0
- package/dist/src/store/sqlite/index-store.js +167 -0
- package/dist/src/store/sqlite/memory.js +54 -0
- package/dist/src/store/sqlite/minhash-lsh.js +47 -0
- package/dist/src/store/sqlite/sessions.js +39 -0
- package/dist/src/store/sqlite/transaction.js +19 -0
- package/dist/src/vectorStore/add.js +260 -0
- package/dist/src/vectorStore/dedup.js +52 -0
- package/dist/src/vectorStore/index.js +10 -0
- package/dist/src/vectorStore/queries.js +83 -0
- package/dist/src/vectorStore/search.js +95 -0
- package/dist/src/vectorStore/session.js +19 -0
- package/dist/src/vectorStore/store.js +105 -0
- package/dist/src/vectorStore/types.js +6 -0
- package/dist/src/vectorStore/utils.js +23 -0
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-BozP_h3c.js → AchievementTiles-DVgFowc-.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-BozP_h3c.js.map → AchievementTiles-DVgFowc-.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/AchievementsTab-Bg86TWPU.js +2 -0
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-DKgqx1RQ.js.map → AchievementsTab-Bg86TWPU.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{CacheTab-CPvso4-0.js → CacheTab-DBzoH_q_.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{CacheTab-CPvso4-0.js.map → CacheTab-DBzoH_q_.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ConfigTab-DXD7zbqy.js → ConfigTab-Bj12LgTh.js} +2 -2
- package/extensions/dashboard-client/dist/assets/ConfigTab-Bj12LgTh.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/EventsTab-EC0wMsp_.js +2 -0
- package/extensions/dashboard-client/dist/assets/EventsTab-EC0wMsp_.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{GameTab-Qj19W1Y-.js → GameTab-CISQitbu.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{GameTab-Qj19W1Y-.js.map → GameTab-CISQitbu.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MetricsTab-DA27vA95.js → MetricsTab-us4vVG5D.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MetricsTab-DA27vA95.js.map → MetricsTab-us4vVG5D.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/OverviewTab-eJ7vFgBE.js +2 -0
- package/extensions/dashboard-client/dist/assets/OverviewTab-eJ7vFgBE.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{ReposTab-_HNR3qYj.js → ReposTab-CiU9AJkb.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ReposTab-_HNR3qYj.js.map → ReposTab-CiU9AJkb.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/SessionsTab-DaCUvTC7.js +70 -0
- package/extensions/dashboard-client/dist/assets/SessionsTab-DaCUvTC7.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B7aPcBMs.js → TimeSavedCard-BAUSCzq5.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B7aPcBMs.js.map → TimeSavedCard-BAUSCzq5.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{index-B2JJExE9.js → index-5MPZyayK.js} +10 -10
- package/extensions/dashboard-client/dist/assets/{index-B2JJExE9.js.map → index-5MPZyayK.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{index-Cfxniu2R.css → index-CDkmYP9l.css} +1 -1
- package/extensions/dashboard-client/dist/assets/useSSE-BSpmWZZi.js +2 -0
- package/extensions/dashboard-client/dist/assets/useSSE-BSpmWZZi.js.map +1 -0
- package/extensions/dashboard-client/dist/index.html +2 -2
- package/extensions/dashboard-client/package-lock.json +374 -2
- package/extensions/dashboard-client/package.json +2 -1
- package/extensions/dashboard-client/src/App.tsx +5 -1
- package/extensions/dashboard-client/src/api/client.ts +12 -0
- package/extensions/dashboard-client/src/components/ActiveSessionsTable.tsx +185 -0
- package/extensions/dashboard-client/src/components/EventStream.tsx +3 -0
- package/extensions/dashboard-client/src/components/RepoContextStack.tsx +238 -0
- package/extensions/dashboard-client/src/components/SessionsMemoryChart.tsx +199 -0
- package/extensions/dashboard-client/src/main.tsx +2 -0
- package/extensions/dashboard-client/src/styles/repostack.css +87 -0
- package/extensions/dashboard-client/src/styles/sessions.css +193 -0
- package/extensions/dashboard-client/src/tabs/ConfigTab.tsx +6 -3
- package/extensions/dashboard-client/src/tabs/OverviewTab.tsx +49 -1
- package/extensions/dashboard-client/src/tabs/SessionsTab.tsx +193 -0
- package/extensions/dashboard-server/api-contracts/core.ts +25 -0
- package/extensions/dashboard-server/api-contracts/endpoints.ts +102 -0
- package/extensions/dashboard-server/api-contracts/index.ts +10 -1
- package/extensions/dashboard-server/api-contracts.test.ts +20 -7
- package/extensions/dashboard-server/server.ts +100 -0
- package/extensions/mega-events/context-handler.ts +40 -18
- package/extensions/mega-runtime/state.ts +31 -0
- package/package.json +1 -1
- package/src/store/sqlite/global-index.test.ts +222 -0
- package/src/store/sqlite/global-index.ts +316 -1
- package/dist/extensions/dashboard-client/src/hooks/useApi.js +0 -51
- package/dist/extensions/dashboard-client/src/hooks/useSSE.js +0 -63
- package/extensions/dashboard-client/dist/assets/AchievementsTab-DKgqx1RQ.js +0 -2
- package/extensions/dashboard-client/dist/assets/ConfigTab-DXD7zbqy.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/EventsTab-BGeuYvM3.js +0 -2
- package/extensions/dashboard-client/dist/assets/EventsTab-BGeuYvM3.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/OverviewTab-lVGR1Jl_.js +0 -2
- package/extensions/dashboard-client/dist/assets/OverviewTab-lVGR1Jl_.js.map +0 -1
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* global-index.test.ts — S39 session heartbeats + token_samples round-trip.
|
|
3
|
+
*
|
|
4
|
+
* Verifies recordSessionHeartbeat + appendTokenSample + readActiveSessions +
|
|
5
|
+
* readSessionTimeseries + pruneStaleSessions + pruneTokenSamples +
|
|
6
|
+
* clearSessionHeartbeat, with particular focus on the Step 5 events.log
|
|
7
|
+
* session_sample JSONL line shape (the SSE real-time push wire format).
|
|
8
|
+
*
|
|
9
|
+
* Pi-agnostic. Uses an isolated mock index dir (never the real user
|
|
10
|
+
* `~/.mega-compact-index/` — guardrail G7). PREVENT-PI-004 (zero network):
|
|
11
|
+
* every operation is local SQLite + local FS append.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, before, after } from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
recordSessionHeartbeat,
|
|
21
|
+
appendTokenSample,
|
|
22
|
+
readActiveSessions,
|
|
23
|
+
readSessionTimeseries,
|
|
24
|
+
pruneStaleSessions,
|
|
25
|
+
pruneTokenSamples,
|
|
26
|
+
clearSessionHeartbeat,
|
|
27
|
+
closeIndexStore,
|
|
28
|
+
} from "./global-index.js";
|
|
29
|
+
|
|
30
|
+
describe("global-index session time-series (S39)", () => {
|
|
31
|
+
let indexDir: string;
|
|
32
|
+
let eventsLogPath: string;
|
|
33
|
+
|
|
34
|
+
before(() => {
|
|
35
|
+
indexDir = mkdtempSync(join(tmpdir(), "mc-global-idx-"));
|
|
36
|
+
eventsLogPath = join(indexDir, "events.log");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
after(() => {
|
|
40
|
+
closeIndexStore();
|
|
41
|
+
rmSync(indexDir, { recursive: true, force: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("recordSessionHeartbeat + readActiveSessions: heartbeat row appears", () => {
|
|
45
|
+
recordSessionHeartbeat(
|
|
46
|
+
12_345,
|
|
47
|
+
"sess-abc-1",
|
|
48
|
+
"/home/u/repos/proj-a",
|
|
49
|
+
"/home/u/.pi/state-a",
|
|
50
|
+
200_000,
|
|
51
|
+
indexDir,
|
|
52
|
+
);
|
|
53
|
+
const sessions = readActiveSessions(indexDir);
|
|
54
|
+
assert.equal(sessions.length, 1);
|
|
55
|
+
const s = sessions[0];
|
|
56
|
+
assert.equal(s.pid, 12_345);
|
|
57
|
+
assert.equal(s.sessionId, "sess-abc-1");
|
|
58
|
+
assert.equal(s.repoRoot, "/home/u/repos/proj-a");
|
|
59
|
+
assert.equal(s.stateDir, "/home/u/.pi/state-a");
|
|
60
|
+
assert.equal(s.ctxWindow, 200_000);
|
|
61
|
+
assert.ok(s.lastSeen > 0);
|
|
62
|
+
// No token sample yet → tokens/percent are null.
|
|
63
|
+
assert.equal(s.tokens, null);
|
|
64
|
+
assert.equal(s.percent, null);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("appendTokenSample writes a token_samples row + a session_sample JSONL line", () => {
|
|
68
|
+
appendTokenSample(
|
|
69
|
+
"sess-abc-1",
|
|
70
|
+
"/home/u/repos/proj-a",
|
|
71
|
+
42_000,
|
|
72
|
+
21.5,
|
|
73
|
+
200_000,
|
|
74
|
+
eventsLogPath,
|
|
75
|
+
indexDir,
|
|
76
|
+
);
|
|
77
|
+
// The token_samples row should now be the latest sample for this session.
|
|
78
|
+
const sessions = readActiveSessions(indexDir);
|
|
79
|
+
const s = sessions.find((x) => x.sessionId === "sess-abc-1");
|
|
80
|
+
assert.ok(s, "session must still appear in readActiveSessions");
|
|
81
|
+
assert.equal(s!.tokens, 42_000);
|
|
82
|
+
assert.equal(s!.percent, 21.5);
|
|
83
|
+
|
|
84
|
+
// The events.log JSONL line should exist and match the wire format
|
|
85
|
+
// described by the SseSessionSample contract: an object with `ts`
|
|
86
|
+
// (ISO 8601 string), `type: 'session_sample'`, and the payload fields.
|
|
87
|
+
assert.ok(existsSync(eventsLogPath), "events.log should exist");
|
|
88
|
+
const lines = readFileSync(eventsLogPath, "utf-8")
|
|
89
|
+
.trim()
|
|
90
|
+
.split("\n")
|
|
91
|
+
.filter(Boolean);
|
|
92
|
+
assert.equal(lines.length, 1, "exactly one JSONL line expected");
|
|
93
|
+
const parsed = JSON.parse(lines[0]) as Record<string, unknown>;
|
|
94
|
+
assert.equal(parsed["type"], "session_sample");
|
|
95
|
+
assert.equal(parsed["sessionId"], "sess-abc-1");
|
|
96
|
+
assert.equal(parsed["tokens"], 42_000);
|
|
97
|
+
assert.equal(parsed["percent"], 21.5);
|
|
98
|
+
// Step 5 contract mirroring: `ts` is an ISO 8601 string (matches every
|
|
99
|
+
// other SseEvent variant's `ts: string` and the DashboardEmitter
|
|
100
|
+
// events.log pattern), NOT a numeric ms value.
|
|
101
|
+
assert.equal(
|
|
102
|
+
typeof parsed["ts"],
|
|
103
|
+
"string",
|
|
104
|
+
"ts must be an ISO 8601 string (matches other SseEvent variants)",
|
|
105
|
+
);
|
|
106
|
+
// Must be ISO-parseable (NaN if invalid → typeof won't be number).
|
|
107
|
+
const tsMs = Date.parse(parsed["ts"] as string);
|
|
108
|
+
assert.ok(
|
|
109
|
+
Number.isFinite(tsMs),
|
|
110
|
+
"ts must be a valid ISO 8601 timestamp",
|
|
111
|
+
);
|
|
112
|
+
// And recent (within the last 5s).
|
|
113
|
+
assert.ok(
|
|
114
|
+
Math.abs(Date.now() - tsMs) < 5_000,
|
|
115
|
+
"ts must be within ~5s of now",
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("readSessionTimeseries returns per-session series + totals in chronological order", () => {
|
|
120
|
+
// Two sessions, two samples each — fully within the rolling window.
|
|
121
|
+
// sess-xyz-9 needs its own heartbeat row so readActiveSessions can later
|
|
122
|
+
// surface it (heartbeats are the primary key, not the samples).
|
|
123
|
+
recordSessionHeartbeat(
|
|
124
|
+
67_890,
|
|
125
|
+
"sess-xyz-9",
|
|
126
|
+
"/home/u/repos/proj-b",
|
|
127
|
+
"/home/u/.pi/state-b",
|
|
128
|
+
200_000,
|
|
129
|
+
indexDir,
|
|
130
|
+
);
|
|
131
|
+
appendTokenSample(
|
|
132
|
+
"sess-abc-1",
|
|
133
|
+
"/home/u/repos/proj-a",
|
|
134
|
+
50_000,
|
|
135
|
+
25,
|
|
136
|
+
200_000,
|
|
137
|
+
null, // no events.log line for this assertion
|
|
138
|
+
indexDir,
|
|
139
|
+
);
|
|
140
|
+
appendTokenSample(
|
|
141
|
+
"sess-xyz-9",
|
|
142
|
+
"/home/u/repos/proj-b",
|
|
143
|
+
10_000,
|
|
144
|
+
5,
|
|
145
|
+
200_000,
|
|
146
|
+
null,
|
|
147
|
+
indexDir,
|
|
148
|
+
);
|
|
149
|
+
appendTokenSample(
|
|
150
|
+
"sess-xyz-9",
|
|
151
|
+
"/home/u/repos/proj-b",
|
|
152
|
+
30_000,
|
|
153
|
+
15,
|
|
154
|
+
200_000,
|
|
155
|
+
null,
|
|
156
|
+
indexDir,
|
|
157
|
+
);
|
|
158
|
+
const since = Date.now() - 60_000;
|
|
159
|
+
const result = readSessionTimeseries(since, indexDir);
|
|
160
|
+
|
|
161
|
+
const seriesIds = result.series.map((s) => s.sessionId).sort();
|
|
162
|
+
assert.deepEqual(seriesIds, ["sess-abc-1", "sess-xyz-9"]);
|
|
163
|
+
|
|
164
|
+
const xyz = result.series.find((s) => s.sessionId === "sess-xyz-9");
|
|
165
|
+
assert.ok(xyz, "sess-xyz-9 series must exist");
|
|
166
|
+
assert.equal(xyz!.data.length, 2);
|
|
167
|
+
assert.equal(xyz!.data[0].tokens, 10_000);
|
|
168
|
+
assert.equal(xyz!.data[1].tokens, 30_000);
|
|
169
|
+
// Stable hash-based color from the SESSION_COLORS palette.
|
|
170
|
+
assert.match(xyz!.color, /^#[0-9a-f]{6}$/i);
|
|
171
|
+
|
|
172
|
+
// Totals: each timestamp summed across sessions.
|
|
173
|
+
assert.ok(result.totals.length >= 2);
|
|
174
|
+
for (const t of result.totals) {
|
|
175
|
+
assert.ok(typeof t.ts === "number" && t.ts > 0);
|
|
176
|
+
assert.ok(typeof t.tokens === "number" && t.tokens >= 0);
|
|
177
|
+
}
|
|
178
|
+
// Totals are sorted ascending by ts.
|
|
179
|
+
for (let i = 1; i < result.totals.length; i++) {
|
|
180
|
+
assert.ok(
|
|
181
|
+
result.totals[i].ts >= result.totals[i - 1].ts,
|
|
182
|
+
"totals must be chronologically ascending",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("pruneStaleSessions clears heartbeats older than the cutoff (and only those)", () => {
|
|
188
|
+
// At this point we have 2 sessions; both are fresh (lastSeen ≈ now).
|
|
189
|
+
const beforeCount = readActiveSessions(indexDir).length;
|
|
190
|
+
assert.equal(beforeCount, 2);
|
|
191
|
+
|
|
192
|
+
// With a 0 ms cutoff, both are stale → pruned.
|
|
193
|
+
const pruned = pruneStaleSessions(0, indexDir);
|
|
194
|
+
assert.equal(pruned, 2, "both heartbeats should be pruned");
|
|
195
|
+
assert.equal(readActiveSessions(indexDir).length, 0);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("pruneTokenSamples clears token_samples older than the cutoff", () => {
|
|
199
|
+
// Re-seed two samples; the previously-written ones were for two
|
|
200
|
+
// sessions (sess-abc-1 + sess-xyz-9). The prunedStaleSessions call
|
|
201
|
+
// above left token_samples untouched (separate table).
|
|
202
|
+
const sinceBefore = Date.now() - 60_000;
|
|
203
|
+
const before = readSessionTimeseries(sinceBefore, indexDir);
|
|
204
|
+
const beforeCount = before.series.reduce((n, s) => n + s.data.length, 0);
|
|
205
|
+
assert.ok(beforeCount >= 3, "expected to find the seeded samples");
|
|
206
|
+
|
|
207
|
+
const npurged = pruneTokenSamples(0, indexDir);
|
|
208
|
+
assert.ok(npurged >= 3, "should have pruned the seeded samples");
|
|
209
|
+
|
|
210
|
+
const after = readSessionTimeseries(sinceBefore, indexDir);
|
|
211
|
+
const afterCount = after.series.reduce((n, s) => n + s.data.length, 0);
|
|
212
|
+
assert.equal(afterCount, 0, "no samples should remain after pruning");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("clearSessionHeartbeat is a no-op on missing rows (never throws)", () => {
|
|
216
|
+
const before = readActiveSessions(indexDir).length;
|
|
217
|
+
assert.doesNotThrow(() =>
|
|
218
|
+
clearSessionHeartbeat(99_999, "nonexistent-session", indexDir),
|
|
219
|
+
);
|
|
220
|
+
assert.equal(readActiveSessions(indexDir).length, before);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* infrequent idempotent upserts (ON CONFLICT). Fully local (PREVENT-PI-004).
|
|
13
13
|
*/
|
|
14
14
|
import { DatabaseSync } from "node:sqlite";
|
|
15
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
15
|
+
import { appendFileSync, existsSync, mkdirSync } from "node:fs";
|
|
16
16
|
import { homedir, tmpdir } from "node:os";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
|
|
@@ -70,6 +70,31 @@ export function openIndexStore(indexDir: string = getIndexDir()): DatabaseSync {
|
|
|
70
70
|
PRIMARY KEY (checkpoint_id, session_id)
|
|
71
71
|
);
|
|
72
72
|
CREATE INDEX IF NOT EXISTS idx_injected_global_cid ON injected_global(checkpoint_id);
|
|
73
|
+
-- S39: machine-wide session heartbeats + token time-series for the live
|
|
74
|
+
-- multi-pi stacked-memory graph. One row per (pid, session_id), updated
|
|
75
|
+
-- on every material snapshot() change (NOT on idle/context-repeat events).
|
|
76
|
+
-- Written by every pi process; read by the dashboard server. WAL-safe.
|
|
77
|
+
-- PRAMETERIZED queries (PREVENT-002); local node:sqlite (PREVENT-PI-004).
|
|
78
|
+
CREATE TABLE IF NOT EXISTS session_heartbeats (
|
|
79
|
+
pid INTEGER NOT NULL,
|
|
80
|
+
session_id TEXT NOT NULL,
|
|
81
|
+
repo_root TEXT,
|
|
82
|
+
state_dir TEXT,
|
|
83
|
+
ctx_window INTEGER DEFAULT 0,
|
|
84
|
+
last_seen INTEGER NOT NULL,
|
|
85
|
+
PRIMARY KEY (pid, session_id)
|
|
86
|
+
);
|
|
87
|
+
CREATE TABLE IF NOT EXISTS token_samples (
|
|
88
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
89
|
+
session_id TEXT NOT NULL,
|
|
90
|
+
repo_root TEXT,
|
|
91
|
+
tokens INTEGER NOT NULL,
|
|
92
|
+
percent REAL NOT NULL,
|
|
93
|
+
ctx_window INTEGER DEFAULT 0,
|
|
94
|
+
ts INTEGER NOT NULL
|
|
95
|
+
);
|
|
96
|
+
CREATE INDEX IF NOT EXISTS idx_token_samples_ts ON token_samples(ts);
|
|
97
|
+
CREATE INDEX IF NOT EXISTS idx_token_samples_sid_ts ON token_samples(session_id, ts);
|
|
73
98
|
`);
|
|
74
99
|
indexCache = iddb;
|
|
75
100
|
indexCacheDir = indexDir;
|
|
@@ -303,3 +328,293 @@ export function countInjectedGlobal(indexDir: string = getIndexDir()): number {
|
|
|
303
328
|
const row = db.prepare("SELECT COUNT(*) AS n FROM injected_global").get() as { n: number } | undefined;
|
|
304
329
|
return row?.n ?? 0;
|
|
305
330
|
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// S39: session heartbeats + token time-series (multi-pi stacked memory graph)
|
|
334
|
+
//
|
|
335
|
+
// `session_heartbeats`: one row per (pid, session_id), upserted on every
|
|
336
|
+
// material snapshot() change. This is the reliable liveness signal the
|
|
337
|
+
// dashboard /api/sessions endpoint reads (repo_registry.last_seen only updates
|
|
338
|
+
// on repo-switch / bindRepo, so a long-running single-repo process looks
|
|
339
|
+
// inactive; these heartbeats update on every material change).
|
|
340
|
+
//
|
|
341
|
+
// `token_samples`: append-only rows with (session_id, tokens, percent, ts) for
|
|
342
|
+
// the stacked-memory graph. Garbage-collected by pruneTokenSamples.
|
|
343
|
+
//
|
|
344
|
+
// All queries use @named/$named bind parameters (PREVENT-002); local node:sqlite
|
|
345
|
+
// + WAL (PREVENT-PI-004), multi-process safe.
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
/** A live active session row (joined heartbeat + latest token sample). */
|
|
349
|
+
export interface ActiveSessionRow {
|
|
350
|
+
pid: number;
|
|
351
|
+
sessionId: string;
|
|
352
|
+
repoRoot: string | null;
|
|
353
|
+
stateDir: string | null;
|
|
354
|
+
ctxWindow: number;
|
|
355
|
+
lastSeen: number;
|
|
356
|
+
tokens: number | null;
|
|
357
|
+
percent: number | null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** A single time-series data point for a session. */
|
|
361
|
+
export interface TokenSamplePoint {
|
|
362
|
+
ts: number;
|
|
363
|
+
tokens: number;
|
|
364
|
+
percent: number;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** A recharts-ready per-session series with a stable color. */
|
|
368
|
+
export interface SessionSeries {
|
|
369
|
+
sessionId: string;
|
|
370
|
+
label: string;
|
|
371
|
+
color: string;
|
|
372
|
+
data: TokenSamplePoint[];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Timeseries response shape (recharts-ready). */
|
|
376
|
+
export interface SessionTimeseriesResult {
|
|
377
|
+
series: SessionSeries[];
|
|
378
|
+
totals: { ts: number; tokens: number }[];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Stable color palette for per-session series (hash-based, no randomness). */
|
|
382
|
+
const SESSION_COLORS = [
|
|
383
|
+
"#60a5fa", // blue-400
|
|
384
|
+
"#34d399", // emerald-400
|
|
385
|
+
"#fbbf24", // amber-400
|
|
386
|
+
"#f87171", // red-400
|
|
387
|
+
"#a78bfa", // violet-400
|
|
388
|
+
"#f472b6", // pink-400
|
|
389
|
+
"#22d3ee", // cyan-400
|
|
390
|
+
"#a3e635", // lime-400
|
|
391
|
+
];
|
|
392
|
+
|
|
393
|
+
/** Hash a sessionId to a stable color index. */
|
|
394
|
+
function sessionColor(sessionId: string): string {
|
|
395
|
+
let h = 0;
|
|
396
|
+
for (let i = 0; i < sessionId.length; i++) {
|
|
397
|
+
h = (h * 31 + sessionId.charCodeAt(i)) | 0;
|
|
398
|
+
}
|
|
399
|
+
return SESSION_COLORS[Math.abs(h) % SESSION_COLORS.length];
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Record (upsert) a session heartbeat. Called from snapshot() on every material
|
|
404
|
+
* change. PRIMARY KEY (pid, session_id) means concurrent pi processes each get
|
|
405
|
+
* their own row. Non-fatal on conflict (INSERT ... ON CONFLICT DO UPDATE).
|
|
406
|
+
*/
|
|
407
|
+
export function recordSessionHeartbeat(
|
|
408
|
+
pid: number,
|
|
409
|
+
sessionId: string,
|
|
410
|
+
repoRoot: string,
|
|
411
|
+
stateDir: string,
|
|
412
|
+
ctxWindow: number,
|
|
413
|
+
indexDir: string = getIndexDir(),
|
|
414
|
+
): void {
|
|
415
|
+
const db = openIndexStore(indexDir);
|
|
416
|
+
const now = Date.now();
|
|
417
|
+
db.prepare(
|
|
418
|
+
`INSERT INTO session_heartbeats (pid, session_id, repo_root, state_dir, ctx_window, last_seen)
|
|
419
|
+
VALUES (@pid, @session_id, @repo_root, @state_dir, @ctx_window, @last_seen)
|
|
420
|
+
ON CONFLICT(pid, session_id) DO UPDATE SET
|
|
421
|
+
repo_root = excluded.repo_root,
|
|
422
|
+
state_dir = excluded.state_dir,
|
|
423
|
+
ctx_window = excluded.ctx_window,
|
|
424
|
+
last_seen = excluded.last_seen`,
|
|
425
|
+
).run({
|
|
426
|
+
pid,
|
|
427
|
+
session_id: sessionId,
|
|
428
|
+
repo_root: repoRoot,
|
|
429
|
+
state_dir: stateDir,
|
|
430
|
+
ctx_window: ctxWindow,
|
|
431
|
+
last_seen: now,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Append a token sample row + optionally a session_sample line to events.log
|
|
437
|
+
* (for SSE real-time push via /api/events). The eventsLogPath is optional —
|
|
438
|
+
* callers without an events.log (e.g. tests) can omit it.
|
|
439
|
+
*/
|
|
440
|
+
export function appendTokenSample(
|
|
441
|
+
sessionId: string,
|
|
442
|
+
repoRoot: string,
|
|
443
|
+
tokens: number,
|
|
444
|
+
percent: number,
|
|
445
|
+
ctxWindow: number,
|
|
446
|
+
eventsLogPath: string | null,
|
|
447
|
+
indexDir: string = getIndexDir(),
|
|
448
|
+
): void {
|
|
449
|
+
const db = openIndexStore(indexDir);
|
|
450
|
+
const now = Date.now();
|
|
451
|
+
db.prepare(
|
|
452
|
+
`INSERT INTO token_samples (session_id, repo_root, tokens, percent, ctx_window, ts)
|
|
453
|
+
VALUES (@session_id, @repo_root, @tokens, @percent, @ctx_window, @ts)`,
|
|
454
|
+
).run({
|
|
455
|
+
session_id: sessionId,
|
|
456
|
+
repo_root: repoRoot,
|
|
457
|
+
tokens,
|
|
458
|
+
percent,
|
|
459
|
+
ctx_window: ctxWindow,
|
|
460
|
+
ts: now,
|
|
461
|
+
});
|
|
462
|
+
// Step 5: also append a session_sample JSON line to events.log so the
|
|
463
|
+
// existing /api/events SSE tail streams it for free (real-time chart push).
|
|
464
|
+
// Mirrors the DashboardEmitter events.log append pattern in
|
|
465
|
+
// extensions/mega-dashboard.ts:{ event(type, data) }: a JSON object with
|
|
466
|
+
// `ts` (ISO 8601 string), `type`, and the event-specific payload. The ISO
|
|
467
|
+
// timestamp matches the shape of every other SSE variant (SseSessionSample
|
|
468
|
+
// contract; every SSE variant's `ts` is a string); a numeric ms `ts` would
|
|
469
|
+
// violate the contract union's "every SSE variant has a ts field of type
|
|
470
|
+
// string" invariant and break DashboardEmitter consumers.
|
|
471
|
+
if (eventsLogPath) {
|
|
472
|
+
try {
|
|
473
|
+
const dir = eventsLogPath.includes("/") ? eventsLogPath.slice(0, eventsLogPath.lastIndexOf("/")) : ".";
|
|
474
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
475
|
+
appendFileSync(
|
|
476
|
+
eventsLogPath,
|
|
477
|
+
JSON.stringify({
|
|
478
|
+
ts: new Date(now).toISOString(),
|
|
479
|
+
type: "session_sample",
|
|
480
|
+
sessionId,
|
|
481
|
+
tokens,
|
|
482
|
+
percent,
|
|
483
|
+
}) + "\n",
|
|
484
|
+
);
|
|
485
|
+
} catch {
|
|
486
|
+
/* non-fatal: SSE push is best-effort */
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Prune stale session heartbeats (sessions not seen within maxAgeMs).
|
|
493
|
+
* Default 30-min retention. Called by /api/sessions.
|
|
494
|
+
*/
|
|
495
|
+
export function pruneStaleSessions(
|
|
496
|
+
maxAgeMs: number = 1_800_000,
|
|
497
|
+
indexDir: string = getIndexDir(),
|
|
498
|
+
): number {
|
|
499
|
+
const db = openIndexStore(indexDir);
|
|
500
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
501
|
+
const result = db.prepare(
|
|
502
|
+
"DELETE FROM session_heartbeats WHERE last_seen < @cutoff",
|
|
503
|
+
).run({ cutoff });
|
|
504
|
+
return Number(result.changes);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Prune old token samples older than maxAgeMs. Default 30-min retention.
|
|
509
|
+
* Called by /api/sessions/timeseries.
|
|
510
|
+
*/
|
|
511
|
+
export function pruneTokenSamples(
|
|
512
|
+
maxAgeMs: number = 1_800_000,
|
|
513
|
+
indexDir: string = getIndexDir(),
|
|
514
|
+
): number {
|
|
515
|
+
const db = openIndexStore(indexDir);
|
|
516
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
517
|
+
const result = db.prepare(
|
|
518
|
+
"DELETE FROM token_samples WHERE ts < @cutoff",
|
|
519
|
+
).run({ cutoff });
|
|
520
|
+
return Number(result.changes);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Read all active sessions with their latest token sample (if any).
|
|
525
|
+
* JOINs session_heartbeats with the latest token_samples per session_id
|
|
526
|
+
* via a correlated subquery. Returns rows sorted by last_seen descending.
|
|
527
|
+
*/
|
|
528
|
+
export function readActiveSessions(
|
|
529
|
+
indexDir: string = getIndexDir(),
|
|
530
|
+
): ActiveSessionRow[] {
|
|
531
|
+
const db = openIndexStore(indexDir);
|
|
532
|
+
const rows = db.prepare(
|
|
533
|
+
`SELECT h.pid, h.session_id, h.repo_root, h.state_dir, h.ctx_window, h.last_seen,
|
|
534
|
+
s.tokens, s.percent
|
|
535
|
+
FROM session_heartbeats h
|
|
536
|
+
LEFT JOIN token_samples s ON s.id = (
|
|
537
|
+
SELECT id FROM token_samples t
|
|
538
|
+
WHERE t.session_id = h.session_id
|
|
539
|
+
ORDER BY t.ts DESC LIMIT 1
|
|
540
|
+
)
|
|
541
|
+
ORDER BY h.last_seen DESC`,
|
|
542
|
+
).all() as Array<{
|
|
543
|
+
pid: number;
|
|
544
|
+
session_id: string;
|
|
545
|
+
repo_root: string | null;
|
|
546
|
+
state_dir: string | null;
|
|
547
|
+
ctx_window: number;
|
|
548
|
+
last_seen: number;
|
|
549
|
+
tokens: number | null;
|
|
550
|
+
percent: number | null;
|
|
551
|
+
}>;
|
|
552
|
+
return rows.map((r) => ({
|
|
553
|
+
pid: r.pid,
|
|
554
|
+
sessionId: r.session_id,
|
|
555
|
+
repoRoot: r.repo_root,
|
|
556
|
+
stateDir: r.state_dir,
|
|
557
|
+
ctxWindow: r.ctx_window ?? 0,
|
|
558
|
+
lastSeen: r.last_seen,
|
|
559
|
+
tokens: r.tokens,
|
|
560
|
+
percent: r.percent,
|
|
561
|
+
}));
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Read token samples since sinceMs, returning a recharts-ready stacked shape:
|
|
566
|
+
* per-session `SessionSeries` (with stable color) + a `totals` array
|
|
567
|
+
* [{ts, tokens}] (sum of all sessions at each timestamp).
|
|
568
|
+
*/
|
|
569
|
+
export function readSessionTimeseries(
|
|
570
|
+
sinceMs: number,
|
|
571
|
+
indexDir: string = getIndexDir(),
|
|
572
|
+
): SessionTimeseriesResult {
|
|
573
|
+
const db = openIndexStore(indexDir);
|
|
574
|
+
const rows = db.prepare(
|
|
575
|
+
`SELECT session_id, tokens, percent, ts FROM token_samples WHERE ts >= @since ORDER BY ts ASC`,
|
|
576
|
+
).all({ since: sinceMs }) as Array<{
|
|
577
|
+
session_id: string;
|
|
578
|
+
tokens: number;
|
|
579
|
+
percent: number;
|
|
580
|
+
ts: number;
|
|
581
|
+
}>;
|
|
582
|
+
// Group by session_id → series; + accumulate totals per timestamp.
|
|
583
|
+
const seriesMap = new Map<string, TokenSamplePoint[]>();
|
|
584
|
+
const totalsMap = new Map<number, number>();
|
|
585
|
+
for (const r of rows) {
|
|
586
|
+
let pts = seriesMap.get(r.session_id);
|
|
587
|
+
if (!pts) {
|
|
588
|
+
pts = [];
|
|
589
|
+
seriesMap.set(r.session_id, pts);
|
|
590
|
+
}
|
|
591
|
+
pts.push({ ts: r.ts, tokens: r.tokens, percent: r.percent });
|
|
592
|
+
totalsMap.set(r.ts, (totalsMap.get(r.ts) ?? 0) + r.tokens);
|
|
593
|
+
}
|
|
594
|
+
const series: SessionSeries[] = [];
|
|
595
|
+
for (const [sessionId, data] of seriesMap) {
|
|
596
|
+
const label = sessionId.length > 12 ? sessionId.slice(0, 12) : sessionId;
|
|
597
|
+
series.push({ sessionId, label, color: sessionColor(sessionId), data });
|
|
598
|
+
}
|
|
599
|
+
// Sort series by first-timestamp for stable legend order.
|
|
600
|
+
series.sort((a, b) => (a.data[0]?.ts ?? 0) - (b.data[0]?.ts ?? 0));
|
|
601
|
+
const totals = Array.from(totalsMap.entries())
|
|
602
|
+
.sort((a, b) => a[0] - b[0])
|
|
603
|
+
.map(([ts, tokens]) => ({ ts, tokens }));
|
|
604
|
+
return { series, totals };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Clear a session's heartbeat row (e.g. on clean shutdown / session reset).
|
|
609
|
+
* Non-fatal: no-op if the row doesn't exist.
|
|
610
|
+
*/
|
|
611
|
+
export function clearSessionHeartbeat(
|
|
612
|
+
pid: number,
|
|
613
|
+
sessionId: string,
|
|
614
|
+
indexDir: string = getIndexDir(),
|
|
615
|
+
): void {
|
|
616
|
+
const db = openIndexStore(indexDir);
|
|
617
|
+
db.prepare(
|
|
618
|
+
"DELETE FROM session_heartbeats WHERE pid = @pid AND session_id = @session_id",
|
|
619
|
+
).run({ pid, session_id: sessionId });
|
|
620
|
+
}
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dashboard-client/src/hooks/useApi.ts — Generic data fetching hook.
|
|
3
|
-
*
|
|
4
|
-
* Provides typed fetch with retry, stale detection, and error handling.
|
|
5
|
-
* SPRINT-B1: basic fetch. SPRINT-D1: retry + stale.
|
|
6
|
-
*/
|
|
7
|
-
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
8
|
-
// SPRINT-D1-REMAINING: integrate retryWithBackoff from utils/retry.ts.
|
|
9
|
-
// SPRINT-D1-REMAINING: integrate staleness detection from utils/staleness.ts.
|
|
10
|
-
// SPRINT-T1-REMAINING: add Authorization header when auth token present.
|
|
11
|
-
export function useApi(fetchFn, options = {}) {
|
|
12
|
-
const { pollInterval = 0, maxRetries = 0 } = options;
|
|
13
|
-
const [data, setData] = useState(null);
|
|
14
|
-
const [error, setError] = useState(null);
|
|
15
|
-
const [loading, setLoading] = useState(true);
|
|
16
|
-
const [lastFetchedAt, setLastFetchedAt] = useState(null);
|
|
17
|
-
const mountedRef = useRef(true);
|
|
18
|
-
const doFetch = useCallback(async () => {
|
|
19
|
-
setLoading(true);
|
|
20
|
-
setError(null);
|
|
21
|
-
try {
|
|
22
|
-
const result = await fetchFn();
|
|
23
|
-
if (mountedRef.current) {
|
|
24
|
-
setData(result);
|
|
25
|
-
setLastFetchedAt(Date.now());
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
catch (err) {
|
|
29
|
-
if (mountedRef.current) {
|
|
30
|
-
setError(err instanceof Error ? err : new Error(String(err)));
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
finally {
|
|
34
|
-
if (mountedRef.current) {
|
|
35
|
-
setLoading(false);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}, [fetchFn]);
|
|
39
|
-
useEffect(() => {
|
|
40
|
-
mountedRef.current = true;
|
|
41
|
-
doFetch();
|
|
42
|
-
return () => { mountedRef.current = false; };
|
|
43
|
-
}, [doFetch]);
|
|
44
|
-
useEffect(() => {
|
|
45
|
-
if (pollInterval <= 0)
|
|
46
|
-
return;
|
|
47
|
-
const timer = setInterval(doFetch, pollInterval);
|
|
48
|
-
return () => clearInterval(timer);
|
|
49
|
-
}, [doFetch, pollInterval]);
|
|
50
|
-
return { data, error, loading, refetch: doFetch, lastFetchedAt };
|
|
51
|
-
}
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dashboard-client/src/hooks/useSSE.ts — Server-Sent Events hook.
|
|
3
|
-
*
|
|
4
|
-
* Connects to /api/events, provides real-time event stream.
|
|
5
|
-
* SPRINT-B1: basic connection. SPRINT-D1: reconnect with backoff.
|
|
6
|
-
*/
|
|
7
|
-
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
8
|
-
// SPRINT-D1-REMAINING: exponential backoff (1s → 2s → 4s → max 30s).
|
|
9
|
-
// SPRINT-D1-REMAINING: after 5 failures, set status='disconnected'.
|
|
10
|
-
export function useSSE(options = {}) {
|
|
11
|
-
const { maxEvents = 500, maxReconnects = 5 } = options;
|
|
12
|
-
const [events, setEvents] = useState([]);
|
|
13
|
-
const [status, setStatus] = useState('connecting');
|
|
14
|
-
const [eventCount, setEventCount] = useState(0);
|
|
15
|
-
const [lastEventAt, setLastEventAt] = useState(null);
|
|
16
|
-
const reconnectCount = useRef(0);
|
|
17
|
-
const sourceRef = useRef(null);
|
|
18
|
-
const connect = useCallback(() => {
|
|
19
|
-
if (sourceRef.current) {
|
|
20
|
-
sourceRef.current.close();
|
|
21
|
-
}
|
|
22
|
-
setStatus('connecting');
|
|
23
|
-
const source = new EventSource('/api/events');
|
|
24
|
-
sourceRef.current = source;
|
|
25
|
-
source.onopen = () => {
|
|
26
|
-
setStatus('connected');
|
|
27
|
-
reconnectCount.current = 0;
|
|
28
|
-
};
|
|
29
|
-
source.onmessage = (event) => {
|
|
30
|
-
try {
|
|
31
|
-
const parsed = JSON.parse(event.data);
|
|
32
|
-
setEvents(prev => {
|
|
33
|
-
const next = [...prev, parsed];
|
|
34
|
-
return next.length > maxEvents ? next.slice(-maxEvents) : next;
|
|
35
|
-
});
|
|
36
|
-
setEventCount(c => c + 1);
|
|
37
|
-
setLastEventAt(Date.now());
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
// non-fatal: skip malformed events
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
|
-
source.onerror = () => {
|
|
44
|
-
source.close();
|
|
45
|
-
if (reconnectCount.current < maxReconnects) {
|
|
46
|
-
reconnectCount.current += 1;
|
|
47
|
-
setStatus('connecting');
|
|
48
|
-
// SPRINT-D1: use exponential backoff instead of fixed delay
|
|
49
|
-
setTimeout(connect, 1000);
|
|
50
|
-
}
|
|
51
|
-
else {
|
|
52
|
-
setStatus('disconnected');
|
|
53
|
-
}
|
|
54
|
-
};
|
|
55
|
-
}, [maxEvents, maxReconnects]);
|
|
56
|
-
useEffect(() => {
|
|
57
|
-
connect();
|
|
58
|
-
return () => {
|
|
59
|
-
sourceRef.current?.close();
|
|
60
|
-
};
|
|
61
|
-
}, [connect]);
|
|
62
|
-
return { events, status, eventCount, lastEventAt };
|
|
63
|
-
}
|
|
@@ -1,2 +0,0 @@
|
|
|
1
|
-
import{r as t,g as m,j as s}from"./index-B2JJExE9.js";import{A as v}from"./AchievementTiles-BozP_h3c.js";function x(){const[r,o]=t.useState([]),[l,u]=t.useState(!0),[a,n]=t.useState(null),c=t.useCallback(async()=>{try{const e=await m();o(e),n(null)}catch(e){n(e instanceof Error?e:new Error(String(e)))}finally{u(!1)}},[]);return t.useEffect(()=>{let e=!0;const i=async()=>{e&&await c()};i();const h=setInterval(()=>void i(),15e3);return()=>{e=!1,clearInterval(h)}},[c]),l&&r.length===0?s.jsx("div",{className:"tab-stub",children:"Loading achievements…"}):a&&r.length===0?s.jsxs("div",{className:"tab-stub",children:["Error: ",a.message]}):s.jsxs("div",{className:"achievements-tab",children:[s.jsx("h2",{children:"Achievements"}),s.jsx(v,{achievements:r})]})}export{x as default};
|
|
2
|
-
//# sourceMappingURL=AchievementsTab-DKgqx1RQ.js.map
|