@tekyzinc/gsd-t 3.29.11 → 4.0.14
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/CHANGELOG.md +171 -0
- package/bin/gsd-t-parallel.cjs +64 -13
- package/bin/gsd-t.js +48 -376
- package/bin/journey-coverage.cjs +6 -3
- package/bin/parallel-cli.cjs +13 -1
- package/commands/gsd-t-complete-milestone.md +6 -12
- package/commands/gsd-t-debug.md +47 -533
- package/commands/gsd-t-design-decompose.md +24 -497
- package/commands/gsd-t-doc-ripple.md +23 -139
- package/commands/gsd-t-execute.md +41 -958
- package/commands/gsd-t-feature.md +2 -6
- package/commands/gsd-t-gap-analysis.md +3 -6
- package/commands/gsd-t-help.md +2 -4
- package/commands/gsd-t-impact.md +26 -295
- package/commands/gsd-t-init-scan-setup.md +7 -7
- package/commands/gsd-t-integrate.md +44 -369
- package/commands/gsd-t-milestone.md +25 -124
- package/commands/gsd-t-partition.md +28 -539
- package/commands/gsd-t-plan.md +26 -472
- package/commands/gsd-t-prd.md +25 -311
- package/commands/gsd-t-quick.md +1 -1
- package/commands/gsd-t-resume.md +0 -33
- package/commands/gsd-t-scan.md +45 -652
- package/commands/gsd-t-verify.md +52 -569
- package/commands/gsd-t-wave.md +41 -430
- package/package.json +1 -1
- package/scripts/gsd-t-calibration-hook.js +3 -1
- package/scripts/hooks/pre-commit-journey-coverage +0 -2
- package/scripts/hooks/pre-commit-playwright-gate +0 -2
- package/templates/CLAUDE-global.md +44 -173
- package/templates/CLAUDE-project.md +118 -104
- package/templates/prompts/design-verify-subagent.md +3 -0
- package/templates/prompts/qa-subagent.md +3 -0
- package/templates/prompts/red-team-subagent.md +5 -2
- package/templates/workflows/_lib.js +176 -0
- package/templates/workflows/gsd-t-debug.workflow.js +86 -0
- package/templates/workflows/gsd-t-execute.workflow.js +206 -0
- package/templates/workflows/gsd-t-integrate.workflow.js +71 -0
- package/templates/workflows/gsd-t-phase.workflow.js +94 -0
- package/templates/workflows/gsd-t-quick.workflow.js +72 -0
- package/templates/workflows/gsd-t-scan.workflow.js +711 -0
- package/templates/workflows/gsd-t-verify.workflow.js +348 -0
- package/templates/workflows/gsd-t-wave.workflow.js +43 -0
- package/bin/check-headless-sessions.js +0 -140
- package/bin/context-budget-audit.cjs +0 -447
- package/bin/context-meter-config.cjs +0 -101
- package/bin/context-meter-config.test.cjs +0 -101
- package/bin/event-stream.cjs +0 -205
- package/bin/gsd-t-benchmark-orchestrator.js +0 -437
- package/bin/gsd-t-capture-lint.cjs +0 -440
- package/bin/gsd-t-economics.cjs +0 -315
- package/bin/gsd-t-in-session-usage.cjs +0 -213
- package/bin/gsd-t-orchestrator-config.cjs +0 -161
- package/bin/gsd-t-orchestrator-queue.cjs +0 -180
- package/bin/gsd-t-orchestrator-recover.cjs +0 -231
- package/bin/gsd-t-orchestrator-worker.cjs +0 -219
- package/bin/gsd-t-orchestrator.js +0 -535
- package/bin/gsd-t-parallel-probe.cjs +0 -132
- package/bin/gsd-t-ratelimit-probe-worker.cjs +0 -237
- package/bin/gsd-t-ratelimit-probe.cjs +0 -648
- package/bin/gsd-t-report-tokens.cjs +0 -549
- package/bin/gsd-t-stream-feed-client.cjs +0 -151
- package/bin/gsd-t-token-backfill.cjs +0 -366
- package/bin/gsd-t-token-capture.cjs +0 -321
- package/bin/gsd-t-token-dashboard.cjs +0 -353
- package/bin/gsd-t-token-regenerate-log.cjs +0 -129
- package/bin/gsd-t-tool-attribution.cjs +0 -377
- package/bin/gsd-t-tool-cost.cjs +0 -195
- package/bin/gsd-t-transcript-tee.cjs +0 -246
- package/bin/gsd-t-unattended-heartbeat.cjs +0 -188
- package/bin/gsd-t-unattended-platform.cjs +0 -551
- package/bin/gsd-t-unattended-platform.js +0 -381
- package/bin/gsd-t-unattended-safety.cjs +0 -773
- package/bin/gsd-t-unattended-safety.js +0 -788
- package/bin/gsd-t-unattended.cjs +0 -2109
- package/bin/gsd-t-unattended.js +0 -1367
- package/bin/gsd-t-worker-dispatch.cjs +0 -211
- package/bin/handoff-lock.cjs +0 -249
- package/bin/handoff-lock.js +0 -249
- package/bin/headless-auto-spawn.cjs +0 -711
- package/bin/headless-auto-spawn.js +0 -373
- package/bin/headless-exit-codes.cjs +0 -67
- package/bin/live-activity-report.cjs +0 -615
- package/bin/log-tail.cjs +0 -81
- package/bin/m44-proof-measure.cjs +0 -285
- package/bin/m46-iter-proof.cjs +0 -149
- package/bin/m46-worker-proof.cjs +0 -201
- package/bin/m55-substrate-proof.cjs +0 -134
- package/bin/metrics-rollup.js +0 -200
- package/bin/model-windows.cjs +0 -99
- package/bin/model-windows.test.cjs +0 -75
- package/bin/parallelism-report.cjs +0 -535
- package/bin/runway-estimator.cjs +0 -242
- package/bin/spawn-plan-derive.cjs +0 -163
- package/bin/spawn-plan-status-updater.cjs +0 -292
- package/bin/spawn-plan-writer.cjs +0 -204
- package/bin/supervisor-pid-fingerprint.cjs +0 -126
- package/bin/token-budget.cjs +0 -265
- package/bin/unattended-watch-format.cjs +0 -178
- package/bin/watch-progress.js +0 -155
- package/commands/gsd-t-unattended-stop.md +0 -85
- package/commands/gsd-t-unattended-watch.md +0 -465
- package/commands/gsd-t-unattended.md +0 -476
- package/commands/gsd-t-visualize.md +0 -116
- package/scripts/gsd-t-context-meter.e2e.test.js +0 -364
- package/scripts/gsd-t-context-meter.js +0 -341
- package/scripts/gsd-t-context-meter.test.js +0 -471
- package/scripts/gsd-t-dashboard-server.js +0 -1203
- package/scripts/gsd-t-post-commit-spawn-plan.sh +0 -86
- package/scripts/gsd-t-transcript.html +0 -1821
- package/scripts/hooks/gsd-t-conversation-capture.js +0 -439
- package/scripts/hooks/gsd-t-in-session-usage-hook.js +0 -84
- package/scripts/spawn-plan-fmt-tokens.cjs +0 -80
- package/templates/hooks/post-commit-spawn-plan.sh +0 -85
|
@@ -1,1203 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* GSD-T Dashboard Server — Zero-dep SSE server for .gsd-t/events/*.jsonl
|
|
4
|
-
* Serves gsd-t-dashboard.html and streams events to browser clients.
|
|
5
|
-
*/
|
|
6
|
-
const http = require("http");
|
|
7
|
-
const fs = require("fs");
|
|
8
|
-
const path = require("path");
|
|
9
|
-
const { spawn } = require("child_process");
|
|
10
|
-
|
|
11
|
-
// Base port — the effective default is project-hashed (see
|
|
12
|
-
// projectScopedDefaultPort). Explicit --port N always overrides.
|
|
13
|
-
const DEFAULT_PORT = 7433;
|
|
14
|
-
const MAX_EVENTS = 500;
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Deterministic project-scoped default port so two projects running
|
|
18
|
-
* `gsd-t visualize` simultaneously don't collide on 7433. Hashes the
|
|
19
|
-
* resolved project directory (djb2) and maps into [7433, 7532].
|
|
20
|
-
*
|
|
21
|
-
* @param {string} projectDir — any path-like string; resolved internally.
|
|
22
|
-
* @returns {number} port in [DEFAULT_PORT, DEFAULT_PORT + 99].
|
|
23
|
-
*/
|
|
24
|
-
function projectScopedDefaultPort(projectDir) {
|
|
25
|
-
const resolved = path.resolve(projectDir || ".");
|
|
26
|
-
let hash = 5381;
|
|
27
|
-
for (let i = 0; i < resolved.length; i++) {
|
|
28
|
-
hash = ((hash * 33) ^ resolved.charCodeAt(i)) >>> 0;
|
|
29
|
-
}
|
|
30
|
-
return DEFAULT_PORT + (hash % 100);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Pure helper so callers (and tests) can resolve the effective port from a
|
|
35
|
-
* parsed --port argument + projectDir. Explicit argPort always wins.
|
|
36
|
-
*/
|
|
37
|
-
function resolvePort({ argPort, projectDir }) {
|
|
38
|
-
if (argPort != null && argPort !== "" && !Number.isNaN(parseInt(argPort, 10))) {
|
|
39
|
-
return parseInt(argPort, 10);
|
|
40
|
-
}
|
|
41
|
-
return projectScopedDefaultPort(projectDir);
|
|
42
|
-
}
|
|
43
|
-
const KEEPALIVE_MS = 15000;
|
|
44
|
-
const SSE_HEADERS = { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*" };
|
|
45
|
-
|
|
46
|
-
function parseEventLine(line) {
|
|
47
|
-
if (!line || !line.trim()) return null;
|
|
48
|
-
try { return JSON.parse(line.trim()); } catch { return null; }
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function findEventsDir(projectDir) {
|
|
52
|
-
return path.join(projectDir || process.cwd(), ".gsd-t", "events");
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function safeReadJsonl(filePath) {
|
|
56
|
-
try { if (fs.lstatSync(filePath).isSymbolicLink()) return []; } catch { /* safe */ }
|
|
57
|
-
try { return fs.readFileSync(filePath, "utf8").split("\n").map(parseEventLine).filter(Boolean); } catch { return []; }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function readExistingEvents(eventsDir, maxEvents) {
|
|
61
|
-
const limit = maxEvents || MAX_EVENTS;
|
|
62
|
-
if (!eventsDir) return [];
|
|
63
|
-
try { fs.accessSync(eventsDir); } catch { return []; }
|
|
64
|
-
let files;
|
|
65
|
-
try { files = fs.readdirSync(eventsDir).filter((f) => f.endsWith(".jsonl")).sort().reverse(); } catch { return []; }
|
|
66
|
-
const results = [];
|
|
67
|
-
for (const f of files) {
|
|
68
|
-
if (results.length >= limit) break;
|
|
69
|
-
safeReadJsonl(path.join(eventsDir, f)).forEach((e) => { if (results.length < limit) results.push(e); });
|
|
70
|
-
}
|
|
71
|
-
return results;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function tailEventsFile(filePath, callback) {
|
|
75
|
-
let offset = 0;
|
|
76
|
-
try { offset = fs.statSync(filePath).size; } catch { /* new file */ }
|
|
77
|
-
function processNewData() {
|
|
78
|
-
try { if (fs.lstatSync(filePath).isSymbolicLink()) return; } catch { return; }
|
|
79
|
-
let stat;
|
|
80
|
-
try { stat = fs.statSync(filePath); } catch { return; }
|
|
81
|
-
if (stat.size <= offset) return;
|
|
82
|
-
const fd = fs.openSync(filePath, "r");
|
|
83
|
-
let chunk;
|
|
84
|
-
try {
|
|
85
|
-
const buf = Buffer.alloc(stat.size - offset);
|
|
86
|
-
fs.readSync(fd, buf, 0, buf.length, offset);
|
|
87
|
-
chunk = buf.toString("utf8");
|
|
88
|
-
offset = stat.size;
|
|
89
|
-
} finally { fs.closeSync(fd); }
|
|
90
|
-
chunk.split("\n").forEach((line) => { const obj = parseEventLine(line); if (obj) callback(obj); });
|
|
91
|
-
}
|
|
92
|
-
fs.watchFile(filePath, { interval: 500, persistent: true }, processNewData);
|
|
93
|
-
return () => fs.unwatchFile(filePath, processNewData);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function handleRoot(req, res, htmlPath) {
|
|
97
|
-
fs.readFile(htmlPath, (err, data) => {
|
|
98
|
-
if (err) { res.writeHead(404); res.end("Not found"); return; }
|
|
99
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
100
|
-
res.end(data);
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function handlePing(req, res, port) {
|
|
105
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
106
|
-
res.end(JSON.stringify({ status: "ok", port }));
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function handleStop(req, res, server) {
|
|
110
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
111
|
-
res.end(JSON.stringify({ status: "stopping" }));
|
|
112
|
-
setImmediate(() => server.close());
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function getNewestJsonl(eventsDir) {
|
|
116
|
-
try { const files = fs.readdirSync(eventsDir).filter((f) => f.endsWith(".jsonl")).sort(); return files.length ? path.join(eventsDir, files[files.length - 1]) : null; } catch { return null; }
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function handleEvents(req, res, eventsDir) {
|
|
120
|
-
res.writeHead(200, SSE_HEADERS);
|
|
121
|
-
readExistingEvents(eventsDir, MAX_EVENTS).forEach((e) => { try { res.write("data: " + JSON.stringify(e) + "\n\n"); } catch { /* gone */ } });
|
|
122
|
-
const sendEvent = (obj) => { try { res.write("data: " + JSON.stringify(obj) + "\n\n"); } catch { /* gone */ } };
|
|
123
|
-
let watchedFile = getNewestJsonl(eventsDir);
|
|
124
|
-
let unwatchFile = watchedFile ? tailEventsFile(watchedFile, sendEvent) : null;
|
|
125
|
-
// Watch events directory for new JSONL files (e.g., after midnight date rollover)
|
|
126
|
-
let dirWatcher = null;
|
|
127
|
-
try {
|
|
128
|
-
dirWatcher = fs.watch(eventsDir, (eventType, filename) => {
|
|
129
|
-
if (!filename || !filename.endsWith(".jsonl")) return;
|
|
130
|
-
const newFile = getNewestJsonl(eventsDir);
|
|
131
|
-
if (newFile && newFile !== watchedFile) {
|
|
132
|
-
if (unwatchFile) unwatchFile();
|
|
133
|
-
watchedFile = newFile;
|
|
134
|
-
unwatchFile = tailEventsFile(watchedFile, sendEvent);
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
} catch { /* eventsDir may not exist yet */ }
|
|
138
|
-
const timer = setInterval(() => { try { res.write(": keepalive\n\n"); } catch { clearInterval(timer); } }, KEEPALIVE_MS);
|
|
139
|
-
req.on("close", () => { clearInterval(timer); if (unwatchFile) unwatchFile(); if (dirWatcher) dirWatcher.close(); });
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function readMetricsData(metricsDir) {
|
|
143
|
-
const taskFile = path.join(metricsDir, "task-metrics.jsonl");
|
|
144
|
-
const rollupFile = path.join(metricsDir, "rollup.jsonl");
|
|
145
|
-
const taskMetrics = fs.existsSync(taskFile) ? safeReadJsonl(taskFile) : [];
|
|
146
|
-
const rollups = fs.existsSync(rollupFile) ? safeReadJsonl(rollupFile) : [];
|
|
147
|
-
return { taskMetrics, rollups };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function handleMetrics(req, res, projectDir) {
|
|
151
|
-
const metricsDir = path.join(projectDir, ".gsd-t", "metrics");
|
|
152
|
-
const data = readMetricsData(metricsDir);
|
|
153
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
154
|
-
res.end(JSON.stringify(data));
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// ── M42 D2 — per-spawn transcript routes ──────────────────────────────────────
|
|
158
|
-
|
|
159
|
-
const TRANSCRIPTS_SUBDIR = path.join(".gsd-t", "transcripts");
|
|
160
|
-
|
|
161
|
-
function transcriptsDir(projectDir) {
|
|
162
|
-
return path.join(projectDir, TRANSCRIPTS_SUBDIR);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function readTranscriptsIndex(projectDir) {
|
|
166
|
-
const p = path.join(transcriptsDir(projectDir), ".index.json");
|
|
167
|
-
try {
|
|
168
|
-
const raw = fs.readFileSync(p, "utf8");
|
|
169
|
-
const parsed = JSON.parse(raw);
|
|
170
|
-
if (parsed && Array.isArray(parsed.spawns)) return parsed;
|
|
171
|
-
} catch { /* no index yet */ }
|
|
172
|
-
return { spawns: [] };
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function isValidSpawnId(id) {
|
|
176
|
-
return typeof id === "string" && /^[a-zA-Z0-9._-]+$/.test(id) && id.length <= 200;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// M45 D2 follow-up: filesystem-walk fallback for in-session conversation NDJSONs.
|
|
180
|
-
// The conversation-capture hook writes `in-session-{sessionId}.ndjson` directly
|
|
181
|
-
// to `transcripts/`, but does NOT update `.index.json` (the index is owned by
|
|
182
|
-
// the spawn lifecycle, not by the in-session hook). Without this scan, the
|
|
183
|
-
// dashboard's left rail never shows in-session conversations even though the
|
|
184
|
-
// NDJSONs are on disk. Synthesizes a spawn-shaped entry per in-session file
|
|
185
|
-
// using filesystem timestamps; the viewer's `in-session-` prefix detection
|
|
186
|
-
// then labels it as `💬 conversation`.
|
|
187
|
-
//
|
|
188
|
-
// M47 D2: `status` is derived per-entry from mtime. A file modified within the
|
|
189
|
-
// last 30 seconds is `active` (the conversation hook is still appending);
|
|
190
|
-
// otherwise `completed`. The viewer (M47 D1) buckets entries by this field
|
|
191
|
-
// into Live vs Completed rail sections. The `success | failed | killed`
|
|
192
|
-
// taxonomy is intentionally out of scope here — see contract v1.3.0.
|
|
193
|
-
const IN_SESSION_ACTIVE_WINDOW_MS = 30_000;
|
|
194
|
-
function listInSessionTranscripts(projectDir) {
|
|
195
|
-
const dir = transcriptsDir(projectDir);
|
|
196
|
-
let files;
|
|
197
|
-
try { files = fs.readdirSync(dir); } catch { return []; }
|
|
198
|
-
const now = Date.now();
|
|
199
|
-
const out = [];
|
|
200
|
-
for (const f of files) {
|
|
201
|
-
if (!f.startsWith("in-session-") || !f.endsWith(".ndjson")) continue;
|
|
202
|
-
const spawnId = f.slice(0, -".ndjson".length); // "in-session-{sessionId}"
|
|
203
|
-
if (!isValidSpawnId(spawnId)) continue;
|
|
204
|
-
let stat;
|
|
205
|
-
try { stat = fs.statSync(path.join(dir, f)); } catch { continue; }
|
|
206
|
-
const status = (now - stat.mtimeMs) < IN_SESSION_ACTIVE_WINDOW_MS ? "active" : "completed";
|
|
207
|
-
out.push({
|
|
208
|
-
spawnId,
|
|
209
|
-
command: "in-session conversation",
|
|
210
|
-
startedAt: stat.birthtime ? stat.birthtime.toISOString() : stat.mtime.toISOString(),
|
|
211
|
-
lastUpdatedAt: stat.mtime.toISOString(),
|
|
212
|
-
status,
|
|
213
|
-
kind: "in-session",
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
return out;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// M47 D2: Resolve the most-recently-modified `in-session-*.ndjson` file. Used
|
|
220
|
-
// by the viewer's top-pane default load (zero-click main-conversation stream).
|
|
221
|
-
// Reuses `isValidSpawnId` for path-traversal safety.
|
|
222
|
-
function handleMainSession(req, res, projectDir) {
|
|
223
|
-
const dir = transcriptsDir(projectDir);
|
|
224
|
-
let files;
|
|
225
|
-
try { files = fs.readdirSync(dir); } catch { files = []; }
|
|
226
|
-
let best = null;
|
|
227
|
-
for (const f of files) {
|
|
228
|
-
if (!f.startsWith("in-session-") || !f.endsWith(".ndjson")) continue;
|
|
229
|
-
const spawnId = f.slice(0, -".ndjson".length);
|
|
230
|
-
if (!isValidSpawnId(spawnId)) continue;
|
|
231
|
-
let stat;
|
|
232
|
-
try { stat = fs.statSync(path.join(dir, f)); } catch { continue; }
|
|
233
|
-
if (!best || stat.mtimeMs > best.mtimeMs) {
|
|
234
|
-
best = { filename: f, sessionId: f.slice("in-session-".length, -".ndjson".length), mtimeMs: stat.mtimeMs };
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
res.writeHead(200, {
|
|
238
|
-
"Content-Type": "application/json",
|
|
239
|
-
"Cache-Control": "no-store",
|
|
240
|
-
});
|
|
241
|
-
if (!best) {
|
|
242
|
-
res.end(JSON.stringify({ filename: null, sessionId: null, mtimeMs: null }));
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
res.end(JSON.stringify(best));
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function handleTranscriptsList(req, res, projectDir, transcriptHtmlPath) {
|
|
249
|
-
const idx = readTranscriptsIndex(projectDir);
|
|
250
|
-
|
|
251
|
-
// Merge index entries with in-session NDJSONs from the filesystem.
|
|
252
|
-
// De-dupe by spawnId — index entries take precedence (richer metadata).
|
|
253
|
-
const known = new Set(idx.spawns.map((s) => s.spawnId));
|
|
254
|
-
const inSession = listInSessionTranscripts(projectDir).filter((s) => !known.has(s.spawnId));
|
|
255
|
-
const merged = idx.spawns.concat(inSession);
|
|
256
|
-
|
|
257
|
-
const sorted = merged
|
|
258
|
-
.slice()
|
|
259
|
-
.sort((a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0));
|
|
260
|
-
|
|
261
|
-
// Content negotiation: browser navigations send Accept: text/html, fetch()
|
|
262
|
-
// defaults to */*. For text/html we serve the viewer (same HTML as
|
|
263
|
-
// /transcript/:id) with an empty spawn-id placeholder — the viewer's left
|
|
264
|
-
// rail populates from /api/spawns-index and the main pane defers until the
|
|
265
|
-
// user clicks a spawn. Programmatic clients (fetch's default */* or explicit
|
|
266
|
-
// application/json) continue to get the JSON shape the dashboard JS already
|
|
267
|
-
// consumes.
|
|
268
|
-
const accept = String(req.headers["accept"] || "");
|
|
269
|
-
if (accept.includes("text/html") && transcriptHtmlPath) {
|
|
270
|
-
fs.readFile(transcriptHtmlPath, (err, data) => {
|
|
271
|
-
if (err) { res.writeHead(404); res.end("Transcript UI not found"); return; }
|
|
272
|
-
// Substitute the __SPAWN_ID__ placeholder with an empty string; the
|
|
273
|
-
// viewer's initialId logic falls through to location.hash (also empty)
|
|
274
|
-
// and connect('') is a no-op beyond a 404 SSE attempt — harmless, since
|
|
275
|
-
// the left rail polls /api/spawns-index independently.
|
|
276
|
-
const projectName = path.basename(path.resolve(projectDir || "."));
|
|
277
|
-
// Function-form replacement: a string replacement would interpret
|
|
278
|
-
// `$&`, `$1`, `$$`, etc. in the project basename as backreferences,
|
|
279
|
-
// re-injecting the placeholder or fragments of it (Red Team BUG-1).
|
|
280
|
-
const escapedName = _escapeHtml(projectName);
|
|
281
|
-
const html = data.toString("utf8")
|
|
282
|
-
.replace(/__SPAWN_ID__/g, () => "")
|
|
283
|
-
.replace(/__PROJECT_NAME__/g, () => escapedName);
|
|
284
|
-
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
285
|
-
res.end(html);
|
|
286
|
-
});
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
290
|
-
res.end(JSON.stringify({ spawns: sorted }));
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function handleTranscriptPage(req, res, spawnId, transcriptHtmlPath, projectDir) {
|
|
294
|
-
if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
|
|
295
|
-
fs.readFile(transcriptHtmlPath, (err, data) => {
|
|
296
|
-
if (err) { res.writeHead(404); res.end("Transcript UI not found"); return; }
|
|
297
|
-
// Inject the spawn-id as a data attribute on <body> by string replacement;
|
|
298
|
-
// the HTML ships with a placeholder `data-spawn-id="__SPAWN_ID__"`.
|
|
299
|
-
const projectName = path.basename(path.resolve(projectDir || "."));
|
|
300
|
-
// Function-form replacement: see comment in handleTranscriptsList. Even
|
|
301
|
-
// though isValidSpawnId guards spawnId against `$`, defence in depth.
|
|
302
|
-
const escapedName = _escapeHtml(projectName);
|
|
303
|
-
const html = data.toString("utf8")
|
|
304
|
-
.replace(/__SPAWN_ID__/g, () => spawnId)
|
|
305
|
-
.replace(/__PROJECT_NAME__/g, () => escapedName);
|
|
306
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
307
|
-
res.end(html);
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// HTML-escape just enough to make a directory basename safe in <title> and
|
|
312
|
-
// <div class="title">. Project basenames effectively never contain quotes or
|
|
313
|
-
// angle brackets, but we still escape to keep the surface tight.
|
|
314
|
-
function _escapeHtml(s) {
|
|
315
|
-
return String(s == null ? "" : s)
|
|
316
|
-
.replace(/&/g, "&")
|
|
317
|
-
.replace(/</g, "<")
|
|
318
|
-
.replace(/>/g, ">")
|
|
319
|
-
.replace(/"/g, """)
|
|
320
|
-
.replace(/'/g, "'");
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function tailTranscriptFile(filePath, callback) {
|
|
324
|
-
let offset = 0;
|
|
325
|
-
let buf = "";
|
|
326
|
-
function processNewData() {
|
|
327
|
-
let stat;
|
|
328
|
-
try { stat = fs.statSync(filePath); } catch { return; }
|
|
329
|
-
if (stat.size <= offset) return;
|
|
330
|
-
const fd = fs.openSync(filePath, "r");
|
|
331
|
-
try {
|
|
332
|
-
const b = Buffer.alloc(stat.size - offset);
|
|
333
|
-
fs.readSync(fd, b, 0, b.length, offset);
|
|
334
|
-
buf += b.toString("utf8");
|
|
335
|
-
offset = stat.size;
|
|
336
|
-
} finally { fs.closeSync(fd); }
|
|
337
|
-
let nl;
|
|
338
|
-
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
339
|
-
const line = buf.slice(0, nl);
|
|
340
|
-
buf = buf.slice(nl + 1);
|
|
341
|
-
if (line.length > 0) callback(line);
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
fs.watchFile(filePath, { interval: 500, persistent: true }, processNewData);
|
|
345
|
-
return () => fs.unwatchFile(filePath, processNewData);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// ── M43 D6 — per-spawn tool-cost + usage routes ────────────────────────────
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Load per-turn usage rows for a given spawn-id from
|
|
352
|
-
* `.gsd-t/metrics/token-usage.jsonl`.
|
|
353
|
-
*
|
|
354
|
-
* Rows emitted by M41/M43 include either `spawn_id` OR `session_id`. We match
|
|
355
|
-
* against both so the route works both for headless spawns (which tag rows
|
|
356
|
-
* with `spawn_id` at emit time) and in-session D1 captures (which tag with
|
|
357
|
-
* the session's `session_id`).
|
|
358
|
-
*
|
|
359
|
-
* @param {string} projectDir
|
|
360
|
-
* @param {string} spawnId
|
|
361
|
-
* @returns {Array<object>}
|
|
362
|
-
*/
|
|
363
|
-
function readSpawnUsageRows(projectDir, spawnId) {
|
|
364
|
-
const fp = path.join(projectDir, ".gsd-t", "metrics", "token-usage.jsonl");
|
|
365
|
-
if (!fs.existsSync(fp)) return [];
|
|
366
|
-
const lines = safeReadJsonl(fp);
|
|
367
|
-
return lines.filter((row) => {
|
|
368
|
-
if (!row || typeof row !== "object") return false;
|
|
369
|
-
return row.spawn_id === spawnId || row.session_id === spawnId;
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function handleTranscriptUsage(req, res, spawnId, projectDir) {
|
|
374
|
-
if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
|
|
375
|
-
const rows = readSpawnUsageRows(projectDir, spawnId);
|
|
376
|
-
res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
|
|
377
|
-
res.end(JSON.stringify({ spawn_id: spawnId, rows, generated_at: new Date().toISOString() }));
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Proxy to D2's `aggregateByTool`. D2 is co-deployed in Wave 2 — if the
|
|
382
|
-
* library isn't on disk yet, we return HTTP 503 with a machine-readable
|
|
383
|
-
* body so the viewer panel can display "Tool attribution not yet available"
|
|
384
|
-
* without breaking.
|
|
385
|
-
*/
|
|
386
|
-
function handleTranscriptToolCost(req, res, spawnId, projectDir) {
|
|
387
|
-
if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
|
|
388
|
-
let attribution;
|
|
389
|
-
try {
|
|
390
|
-
// TODO(D6→D2): remove the try/catch once D2 lands. Keep the call shape.
|
|
391
|
-
// eslint-disable-next-line global-require
|
|
392
|
-
attribution = require("../bin/gsd-t-tool-attribution.cjs");
|
|
393
|
-
} catch (_) {
|
|
394
|
-
res.writeHead(503, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
|
|
395
|
-
res.end(JSON.stringify({ error: "tool-attribution library not yet available" }));
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
398
|
-
const rows = readSpawnUsageRows(projectDir, spawnId);
|
|
399
|
-
let tools = [];
|
|
400
|
-
try {
|
|
401
|
-
if (typeof attribution.aggregateByTool !== "function") {
|
|
402
|
-
res.writeHead(503, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
|
|
403
|
-
res.end(JSON.stringify({ error: "aggregateByTool not exported by tool-attribution library" }));
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
tools = attribution.aggregateByTool(rows) || [];
|
|
407
|
-
} catch (err) {
|
|
408
|
-
res.writeHead(500, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
|
|
409
|
-
res.end(JSON.stringify({ error: String(err && err.message ? err.message : err) }));
|
|
410
|
-
return;
|
|
411
|
-
}
|
|
412
|
-
res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
|
|
413
|
-
res.end(JSON.stringify({
|
|
414
|
-
spawn_id: spawnId,
|
|
415
|
-
tools,
|
|
416
|
-
generated_at: new Date().toISOString(),
|
|
417
|
-
}));
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// ── M42 D3 — kill per-spawn ────────────────────────────────────────────────
|
|
421
|
-
|
|
422
|
-
function writeTranscriptsIndex(projectDir, idx) {
|
|
423
|
-
const p = path.join(transcriptsDir(projectDir), ".index.json");
|
|
424
|
-
const tmp = p + ".tmp";
|
|
425
|
-
try { fs.mkdirSync(path.dirname(p), { recursive: true }); } catch { /* exists */ }
|
|
426
|
-
fs.writeFileSync(tmp, JSON.stringify(idx, null, 2));
|
|
427
|
-
fs.renameSync(tmp, p);
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
function handleTranscriptKill(req, res, spawnId, projectDir) {
|
|
431
|
-
if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
|
|
432
|
-
const idx = readTranscriptsIndex(projectDir);
|
|
433
|
-
const i = idx.spawns.findIndex((s) => s.spawnId === spawnId);
|
|
434
|
-
if (i < 0) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unknown_spawn" })); return; }
|
|
435
|
-
const entry = idx.spawns[i];
|
|
436
|
-
const pid = entry.workerPid;
|
|
437
|
-
if (!pid || typeof pid !== "number") {
|
|
438
|
-
res.writeHead(409, { "Content-Type": "application/json" });
|
|
439
|
-
res.end(JSON.stringify({ error: "no_pid_recorded" }));
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
442
|
-
try {
|
|
443
|
-
process.kill(pid, "SIGTERM");
|
|
444
|
-
idx.spawns[i].status = "stopped";
|
|
445
|
-
idx.spawns[i].endedAt = idx.spawns[i].endedAt || new Date().toISOString();
|
|
446
|
-
writeTranscriptsIndex(projectDir, idx);
|
|
447
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
448
|
-
res.end(JSON.stringify({ status: "stopped", pid }));
|
|
449
|
-
} catch (err) {
|
|
450
|
-
if (err.code === "ESRCH") {
|
|
451
|
-
// Process already gone — treat as success and mark ended
|
|
452
|
-
idx.spawns[i].status = idx.spawns[i].status === "running" ? "stopped" : idx.spawns[i].status;
|
|
453
|
-
idx.spawns[i].endedAt = idx.spawns[i].endedAt || new Date().toISOString();
|
|
454
|
-
writeTranscriptsIndex(projectDir, idx);
|
|
455
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
456
|
-
res.end(JSON.stringify({ status: "already_stopped", pid }));
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
if (err.code === "EPERM") {
|
|
460
|
-
res.writeHead(403, { "Content-Type": "application/json" });
|
|
461
|
-
res.end(JSON.stringify({ error: "permission_denied", pid }));
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
465
|
-
res.end(JSON.stringify({ error: String(err.message || err) }));
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/**
|
|
470
|
-
* Find the transcripts-index entry for a spawn-id, or null when the index
|
|
471
|
-
* is missing / the id isn't recorded. Used by handleTranscriptStream to
|
|
472
|
-
* detect already-finished spawns so the SSE stream can emit `event: end`
|
|
473
|
-
* and close instead of tailing indefinitely.
|
|
474
|
-
*/
|
|
475
|
-
function readIndexEntry(projectDir, spawnId) {
|
|
476
|
-
const idx = readTranscriptsIndex(projectDir);
|
|
477
|
-
if (!idx || !Array.isArray(idx.spawns)) return null;
|
|
478
|
-
return idx.spawns.find((s) => s && s.spawnId === spawnId) || null;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
function handleTranscriptStream(req, res, spawnId, projectDir) {
|
|
482
|
-
if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
|
|
483
|
-
const filePath = path.join(transcriptsDir(projectDir), `${spawnId}.ndjson`);
|
|
484
|
-
let exists = true;
|
|
485
|
-
let fileSize = 0;
|
|
486
|
-
try { fileSize = fs.statSync(filePath).size; } catch { exists = false; }
|
|
487
|
-
|
|
488
|
-
res.writeHead(200, SSE_HEADERS);
|
|
489
|
-
|
|
490
|
-
// Consult the transcripts index — if the spawn is already finished we
|
|
491
|
-
// replay and close instead of attaching a live tail.
|
|
492
|
-
const entry = readIndexEntry(projectDir, spawnId);
|
|
493
|
-
const finishedStatuses = ["done", "failed", "stopped"];
|
|
494
|
-
const isFinished = !!(entry && entry.status && finishedStatuses.includes(entry.status));
|
|
495
|
-
|
|
496
|
-
// Replay: read the full file from byte 0 and send each line.
|
|
497
|
-
let replayedBytes = 0;
|
|
498
|
-
if (exists) {
|
|
499
|
-
try {
|
|
500
|
-
const full = fs.readFileSync(filePath, "utf8");
|
|
501
|
-
replayedBytes = Buffer.byteLength(full, "utf8");
|
|
502
|
-
full.split("\n").forEach((line) => {
|
|
503
|
-
if (line.length > 0) {
|
|
504
|
-
try { res.write("data: " + line + "\n\n"); } catch { /* gone */ }
|
|
505
|
-
}
|
|
506
|
-
});
|
|
507
|
-
} catch { /* empty transcript */ }
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
// Finished spawn → emit end event and close the stream. No tail, no keepalive.
|
|
511
|
-
if (isFinished) {
|
|
512
|
-
const endPayload = JSON.stringify({
|
|
513
|
-
status: entry.status,
|
|
514
|
-
endedAt: entry.endedAt || null,
|
|
515
|
-
});
|
|
516
|
-
try { res.write("event: end\ndata: " + endPayload + "\n\n"); } catch { /* gone */ }
|
|
517
|
-
try { res.end(); } catch { /* gone */ }
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// Missing file — tell the viewer the stream is live but empty. fs.watchFile
|
|
522
|
-
// needs the file to exist, so we skip the tail here. A running producer
|
|
523
|
-
// will have created the file already (the finished-spawn branch above
|
|
524
|
-
// catches the post-hoc teed case via index status).
|
|
525
|
-
if (!exists) {
|
|
526
|
-
const waitingPayload = JSON.stringify({
|
|
527
|
-
status: "waiting",
|
|
528
|
-
reason: "no transcript file yet",
|
|
529
|
-
});
|
|
530
|
-
try { res.write("event: status\ndata: " + waitingPayload + "\n\n"); } catch { /* gone */ }
|
|
531
|
-
const timer = setInterval(() => { try { res.write(": keepalive\n\n"); } catch { clearInterval(timer); } }, KEEPALIVE_MS);
|
|
532
|
-
req.on("close", () => { clearInterval(timer); });
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
// Empty file — producer hasn't written anything yet. Emit a status frame
|
|
537
|
-
// so the viewer knows the stream is live but the file is 0 bytes, then
|
|
538
|
-
// attach the live tail so we pick up writes as they arrive.
|
|
539
|
-
if (fileSize === 0) {
|
|
540
|
-
const emptyPayload = JSON.stringify({
|
|
541
|
-
status: "empty",
|
|
542
|
-
reason: "transcript file exists but is empty",
|
|
543
|
-
});
|
|
544
|
-
try { res.write("event: status\ndata: " + emptyPayload + "\n\n"); } catch { /* gone */ }
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
// Tail: from replayedBytes onward. We reuse the tailer, but seed offset
|
|
548
|
-
// with what we already replayed so we don't double-send.
|
|
549
|
-
let offset = replayedBytes;
|
|
550
|
-
let buf = "";
|
|
551
|
-
const processNewData = () => {
|
|
552
|
-
let stat;
|
|
553
|
-
try { stat = fs.statSync(filePath); } catch { return; }
|
|
554
|
-
if (stat.size <= offset) return;
|
|
555
|
-
const fd = fs.openSync(filePath, "r");
|
|
556
|
-
try {
|
|
557
|
-
const b = Buffer.alloc(stat.size - offset);
|
|
558
|
-
fs.readSync(fd, b, 0, b.length, offset);
|
|
559
|
-
buf += b.toString("utf8");
|
|
560
|
-
offset = stat.size;
|
|
561
|
-
} finally { fs.closeSync(fd); }
|
|
562
|
-
let nl;
|
|
563
|
-
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
564
|
-
const line = buf.slice(0, nl);
|
|
565
|
-
buf = buf.slice(nl + 1);
|
|
566
|
-
if (line.length > 0) {
|
|
567
|
-
try { res.write("data: " + line + "\n\n"); } catch { /* gone */ }
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
};
|
|
571
|
-
fs.watchFile(filePath, { interval: 500, persistent: true }, processNewData);
|
|
572
|
-
const unwatchFile = () => fs.unwatchFile(filePath, processNewData);
|
|
573
|
-
|
|
574
|
-
const timer = setInterval(() => { try { res.write(": keepalive\n\n"); } catch { clearInterval(timer); } }, KEEPALIVE_MS);
|
|
575
|
-
req.on("close", () => { clearInterval(timer); if (unwatchFile) unwatchFile(); });
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
// ── M44 D8 — spawn-plan endpoint + SSE channel ─────────────────────────────
|
|
579
|
-
//
|
|
580
|
-
// Additive; does not change existing endpoints or SSE streams. Reads plan
|
|
581
|
-
// files atomically written by `bin/spawn-plan-writer.cjs` under
|
|
582
|
-
// `.gsd-t/spawns/{spawnId}.json`. A plan is "active" when `endedAt === null`.
|
|
583
|
-
// Contract: .gsd-t/contracts/spawn-plan-contract.md v1.0.0
|
|
584
|
-
|
|
585
|
-
const SPAWNS_SUBDIR = path.join(".gsd-t", "spawns");
|
|
586
|
-
|
|
587
|
-
function spawnsDir(projectDir) {
|
|
588
|
-
return path.join(projectDir, SPAWNS_SUBDIR);
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
function readSpawnPlanFile(fp) {
|
|
592
|
-
try {
|
|
593
|
-
const raw = fs.readFileSync(fp, "utf8");
|
|
594
|
-
const parsed = JSON.parse(raw);
|
|
595
|
-
if (!parsed || typeof parsed !== "object") return null;
|
|
596
|
-
return parsed;
|
|
597
|
-
} catch { return null; }
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
function listAllSpawnPlans(projectDir) {
|
|
601
|
-
const dir = spawnsDir(projectDir);
|
|
602
|
-
if (!fs.existsSync(dir)) return [];
|
|
603
|
-
let files;
|
|
604
|
-
try { files = fs.readdirSync(dir).filter((f) => f.endsWith(".json")); } catch { return []; }
|
|
605
|
-
const out = [];
|
|
606
|
-
for (const f of files) {
|
|
607
|
-
const plan = readSpawnPlanFile(path.join(dir, f));
|
|
608
|
-
if (plan) out.push(plan);
|
|
609
|
-
}
|
|
610
|
-
return out;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
function listActiveSpawnPlans(projectDir) {
|
|
614
|
-
return listAllSpawnPlans(projectDir).filter((p) => p && p.endedAt == null);
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
// ── M44 D9 — Parallelism observability ──────────────────────────────────────
|
|
618
|
-
// Additive endpoints powered by bin/parallelism-report.cjs (v1.0.0 contract).
|
|
619
|
-
// Pure read-only observer; never writes, never spawns. 5-second per-response
|
|
620
|
-
// cache so rapid panel polls don't hammer the filesystem.
|
|
621
|
-
|
|
622
|
-
const PARALLELISM_CACHE_MS = 5000;
|
|
623
|
-
const _parallelismCache = { metrics: { at: 0, body: null, wave: null }, report: new Map() };
|
|
624
|
-
|
|
625
|
-
function _loadParallelismReporter() {
|
|
626
|
-
try {
|
|
627
|
-
// Resolve at call-time so tests that don't install the module don't break
|
|
628
|
-
// unrelated endpoints. Require is cached by Node after first success.
|
|
629
|
-
return require(path.join(__dirname, "..", "bin", "parallelism-report.cjs"));
|
|
630
|
-
} catch (err) {
|
|
631
|
-
return { _loadError: err && err.message || String(err) };
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
function handleParallelism(req, res, projectDir) {
|
|
636
|
-
const urlObj = req.url ? req.url.split("?") : ["", ""];
|
|
637
|
-
const qs = urlObj[1] || "";
|
|
638
|
-
const params = new URLSearchParams(qs);
|
|
639
|
-
const wave = params.get("wave") || null;
|
|
640
|
-
const now = Date.now();
|
|
641
|
-
const cache = _parallelismCache.metrics;
|
|
642
|
-
if (cache.body && cache.wave === wave && (now - cache.at) < PARALLELISM_CACHE_MS) {
|
|
643
|
-
res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "X-Cache": "hit" });
|
|
644
|
-
res.end(cache.body);
|
|
645
|
-
return;
|
|
646
|
-
}
|
|
647
|
-
const reporter = _loadParallelismReporter();
|
|
648
|
-
if (reporter._loadError) {
|
|
649
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
650
|
-
res.end(JSON.stringify({ error: "parallelism-report module unavailable", detail: reporter._loadError }));
|
|
651
|
-
return;
|
|
652
|
-
}
|
|
653
|
-
let metrics;
|
|
654
|
-
try {
|
|
655
|
-
metrics = reporter.computeParallelismMetrics({ projectDir, wave: wave || undefined });
|
|
656
|
-
} catch (err) {
|
|
657
|
-
// Contract says silent-fail; a thrown error here means contract regression.
|
|
658
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
659
|
-
res.end(JSON.stringify({ error: "computeParallelismMetrics threw", detail: err && err.message || String(err) }));
|
|
660
|
-
return;
|
|
661
|
-
}
|
|
662
|
-
const body = JSON.stringify(metrics);
|
|
663
|
-
cache.at = now;
|
|
664
|
-
cache.wave = wave;
|
|
665
|
-
cache.body = body;
|
|
666
|
-
res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "X-Cache": "miss" });
|
|
667
|
-
res.end(body);
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
function handleParallelismReport(req, res, projectDir) {
|
|
671
|
-
const urlObj = req.url ? req.url.split("?") : ["", ""];
|
|
672
|
-
const qs = urlObj[1] || "";
|
|
673
|
-
const params = new URLSearchParams(qs);
|
|
674
|
-
const wave = params.get("wave") || null;
|
|
675
|
-
const cacheKey = wave || "__all__";
|
|
676
|
-
const now = Date.now();
|
|
677
|
-
const cached = _parallelismCache.report.get(cacheKey);
|
|
678
|
-
if (cached && (now - cached.at) < PARALLELISM_CACHE_MS) {
|
|
679
|
-
res.writeHead(200, { "Content-Type": "text/markdown; charset=utf-8", "Access-Control-Allow-Origin": "*", "X-Cache": "hit" });
|
|
680
|
-
res.end(cached.body);
|
|
681
|
-
return;
|
|
682
|
-
}
|
|
683
|
-
const reporter = _loadParallelismReporter();
|
|
684
|
-
if (reporter._loadError) {
|
|
685
|
-
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
686
|
-
res.end("parallelism-report module unavailable: " + reporter._loadError);
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
689
|
-
let md;
|
|
690
|
-
try {
|
|
691
|
-
md = reporter.buildFullReport({ projectDir, wave: wave || undefined });
|
|
692
|
-
} catch (err) {
|
|
693
|
-
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
694
|
-
res.end("buildFullReport threw: " + (err && err.message || String(err)));
|
|
695
|
-
return;
|
|
696
|
-
}
|
|
697
|
-
_parallelismCache.report.set(cacheKey, { at: now, body: md });
|
|
698
|
-
res.writeHead(200, { "Content-Type": "text/markdown; charset=utf-8", "Access-Control-Allow-Origin": "*", "X-Cache": "miss" });
|
|
699
|
-
res.end(md);
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
// M54 D1 T3 — Live Activity endpoints (additive, read-only)
|
|
703
|
-
// Contract: .gsd-t/contracts/live-activity-contract.md v1.0.0
|
|
704
|
-
// Three endpoints: GET /api/live-activity (5s cache),
|
|
705
|
-
// GET /api/live-activity/<id>/tail (per-id 5s cache, path-traversal guard),
|
|
706
|
-
// GET /api/live-activity/<id>/stream (SSE, uncached, 15s heartbeat).
|
|
707
|
-
|
|
708
|
-
const LIVE_ACTIVITY_CACHE_MS = 5000;
|
|
709
|
-
const _liveActivityCache = { list: { at: 0, body: null }, tail: new Map() };
|
|
710
|
-
|
|
711
|
-
function _loadLiveActivityReporter() {
|
|
712
|
-
try {
|
|
713
|
-
return require(path.join(__dirname, "..", "bin", "live-activity-report.cjs"));
|
|
714
|
-
} catch (err) {
|
|
715
|
-
return { _loadError: err && err.message || String(err) };
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
function isValidActivityId(id) {
|
|
720
|
-
if (typeof id !== "string") return false;
|
|
721
|
-
if (id.length === 0 || id.length > 256) return false;
|
|
722
|
-
if (id.indexOf("..") !== -1) return false;
|
|
723
|
-
if (id.indexOf("/") !== -1) return false;
|
|
724
|
-
if (id.indexOf("\\") !== -1) return false;
|
|
725
|
-
if (id.indexOf("\0") !== -1) return false;
|
|
726
|
-
return true;
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
function handleLiveActivity(req, res, projectDir) {
|
|
730
|
-
const now = Date.now();
|
|
731
|
-
const cache = _liveActivityCache.list;
|
|
732
|
-
if (cache.body && (now - cache.at) < LIVE_ACTIVITY_CACHE_MS) {
|
|
733
|
-
res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Cache-Control": "no-store", "X-Cache": "hit" });
|
|
734
|
-
res.end(cache.body);
|
|
735
|
-
return;
|
|
736
|
-
}
|
|
737
|
-
const reporter = _loadLiveActivityReporter();
|
|
738
|
-
if (reporter._loadError) {
|
|
739
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
740
|
-
res.end(JSON.stringify({ error: "live-activity-report module unavailable", detail: reporter._loadError }));
|
|
741
|
-
return;
|
|
742
|
-
}
|
|
743
|
-
let result;
|
|
744
|
-
try {
|
|
745
|
-
result = reporter.computeLiveActivities({ projectDir });
|
|
746
|
-
} catch (err) {
|
|
747
|
-
// Contract regression — computeLiveActivities must never throw
|
|
748
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
749
|
-
res.end(JSON.stringify({ error: "computeLiveActivities threw", detail: err && err.message || String(err) }));
|
|
750
|
-
return;
|
|
751
|
-
}
|
|
752
|
-
const body = JSON.stringify(result);
|
|
753
|
-
cache.at = now;
|
|
754
|
-
cache.body = body;
|
|
755
|
-
res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Cache-Control": "no-store", "X-Cache": "miss" });
|
|
756
|
-
res.end(body);
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
function handleLiveActivityTail(req, res, projectDir, id) {
|
|
760
|
-
if (!isValidActivityId(id)) {
|
|
761
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
762
|
-
res.end(JSON.stringify({ error: "invalid_id" }));
|
|
763
|
-
return;
|
|
764
|
-
}
|
|
765
|
-
const cacheKey = projectDir + "\0" + id;
|
|
766
|
-
const now = Date.now();
|
|
767
|
-
const cached = _liveActivityCache.tail.get(cacheKey);
|
|
768
|
-
if (cached && (now - cached.at) < LIVE_ACTIVITY_CACHE_MS) {
|
|
769
|
-
res.writeHead(cached.statusCode, cached.headers);
|
|
770
|
-
res.end(cached.body);
|
|
771
|
-
return;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
// Find the activity to determine kind and tail path
|
|
775
|
-
const reporter = _loadLiveActivityReporter();
|
|
776
|
-
if (reporter._loadError) {
|
|
777
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
778
|
-
res.end(JSON.stringify({ error: "live-activity-report module unavailable" }));
|
|
779
|
-
return;
|
|
780
|
-
}
|
|
781
|
-
let result;
|
|
782
|
-
try {
|
|
783
|
-
result = reporter.computeLiveActivities({ projectDir });
|
|
784
|
-
} catch (err) {
|
|
785
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
786
|
-
res.end(JSON.stringify({ error: "computeLiveActivities threw", detail: err && err.message || String(err) }));
|
|
787
|
-
return;
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
const activity = result.activities.find((a) => a.id === id);
|
|
791
|
-
if (!activity) {
|
|
792
|
-
const notFound = { statusCode: 404, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, body: JSON.stringify({ error: "not_found" }) };
|
|
793
|
-
_liveActivityCache.tail.set(cacheKey, Object.assign({ at: now }, notFound));
|
|
794
|
-
res.writeHead(404, notFound.headers);
|
|
795
|
-
res.end(notFound.body);
|
|
796
|
-
return;
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
// Read tail content by kind
|
|
800
|
-
let tailContent = "";
|
|
801
|
-
const kind = activity.kind;
|
|
802
|
-
if (kind === "bash" || kind === "spawn") {
|
|
803
|
-
// Last ~64 KB of process stdout — use _readFileTail pattern
|
|
804
|
-
// For bash: the log file path is not directly known without process inspection.
|
|
805
|
-
// Return a JSON snapshot of the activity as a fallback.
|
|
806
|
-
tailContent = JSON.stringify(activity, null, 2);
|
|
807
|
-
} else if (kind === "monitor") {
|
|
808
|
-
tailContent = JSON.stringify(activity, null, 2);
|
|
809
|
-
} else if (kind === "tool") {
|
|
810
|
-
tailContent = JSON.stringify(activity, null, 2);
|
|
811
|
-
} else {
|
|
812
|
-
tailContent = JSON.stringify(activity, null, 2);
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
const hit = { statusCode: 200, headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "Access-Control-Allow-Origin": "*" }, body: tailContent };
|
|
816
|
-
_liveActivityCache.tail.set(cacheKey, Object.assign({ at: now }, hit));
|
|
817
|
-
res.writeHead(200, hit.headers);
|
|
818
|
-
res.end(hit.body);
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
function handleLiveActivityStream(req, res, projectDir, id) {
|
|
822
|
-
if (!isValidActivityId(id)) {
|
|
823
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
824
|
-
res.end(JSON.stringify({ error: "invalid_id" }));
|
|
825
|
-
return;
|
|
826
|
-
}
|
|
827
|
-
res.writeHead(200, Object.assign({}, SSE_HEADERS, { "Access-Control-Allow-Origin": "*", "Cache-Control": "no-store" }));
|
|
828
|
-
|
|
829
|
-
// Send an initial data frame
|
|
830
|
-
res.write("data: " + JSON.stringify({ id, status: "stream_opened" }) + "\n\n");
|
|
831
|
-
|
|
832
|
-
// Heartbeat every 15s
|
|
833
|
-
const heartbeat = setInterval(() => {
|
|
834
|
-
if (!res.writableEnded) {
|
|
835
|
-
res.write(": heartbeat\n\n");
|
|
836
|
-
}
|
|
837
|
-
}, KEEPALIVE_MS);
|
|
838
|
-
|
|
839
|
-
// Poll for activity presence; close stream when activity disappears
|
|
840
|
-
const reporter = _loadLiveActivityReporter();
|
|
841
|
-
const poll = setInterval(() => {
|
|
842
|
-
if (res.writableEnded) {
|
|
843
|
-
clearInterval(poll);
|
|
844
|
-
clearInterval(heartbeat);
|
|
845
|
-
return;
|
|
846
|
-
}
|
|
847
|
-
if (reporter._loadError) {
|
|
848
|
-
clearInterval(poll);
|
|
849
|
-
clearInterval(heartbeat);
|
|
850
|
-
res.write("data: " + JSON.stringify({ id, status: "reporter_unavailable" }) + "\n\n");
|
|
851
|
-
res.end();
|
|
852
|
-
return;
|
|
853
|
-
}
|
|
854
|
-
let result;
|
|
855
|
-
try {
|
|
856
|
-
result = reporter.computeLiveActivities({ projectDir });
|
|
857
|
-
} catch (_) {
|
|
858
|
-
return; // silent — will retry next tick
|
|
859
|
-
}
|
|
860
|
-
const still = result.activities.find((a) => a.id === id);
|
|
861
|
-
if (!still) {
|
|
862
|
-
clearInterval(poll);
|
|
863
|
-
clearInterval(heartbeat);
|
|
864
|
-
if (!res.writableEnded) {
|
|
865
|
-
res.write("data: " + JSON.stringify({ id, status: "activity_ended" }) + "\n\n");
|
|
866
|
-
res.end();
|
|
867
|
-
}
|
|
868
|
-
return;
|
|
869
|
-
}
|
|
870
|
-
// Emit a status frame
|
|
871
|
-
res.write("data: " + JSON.stringify({ id, durationMs: still.durationMs, alive: still.alive }) + "\n\n");
|
|
872
|
-
}, 1000);
|
|
873
|
-
|
|
874
|
-
req.on("close", () => {
|
|
875
|
-
clearInterval(poll);
|
|
876
|
-
clearInterval(heartbeat);
|
|
877
|
-
});
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
// POST /api/unattended-stop — proxies to the existing stop-sentinel flow so
|
|
881
|
-
// the transcript panel's "Stop Supervisor" button reuses the canonical
|
|
882
|
-
// kill path. Writes `.gsd-t/.unattended/stop` sentinel; supervisor polls
|
|
883
|
-
// it and self-exits. Contract reminder: D9 does NOT implement its own
|
|
884
|
-
// stop logic, does NOT PID-kill.
|
|
885
|
-
function handleUnattendedStop(req, res, projectDir) {
|
|
886
|
-
if (req.method !== "POST") {
|
|
887
|
-
res.writeHead(405, { "Content-Type": "application/json", "Allow": "POST" });
|
|
888
|
-
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
889
|
-
return;
|
|
890
|
-
}
|
|
891
|
-
const stopDir = path.join(projectDir, ".gsd-t", ".unattended");
|
|
892
|
-
const stopFile = path.join(stopDir, "stop");
|
|
893
|
-
try {
|
|
894
|
-
fs.mkdirSync(stopDir, { recursive: true });
|
|
895
|
-
fs.writeFileSync(stopFile, new Date().toISOString() + "\n");
|
|
896
|
-
} catch (err) {
|
|
897
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
898
|
-
res.end(JSON.stringify({ error: "failed to write stop sentinel", detail: err && err.message || String(err) }));
|
|
899
|
-
return;
|
|
900
|
-
}
|
|
901
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
902
|
-
res.end(JSON.stringify({ ok: true, sentinel: stopFile }));
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
function handleSpawnPlans(req, res, projectDir) {
|
|
906
|
-
const plans = listActiveSpawnPlans(projectDir).sort((a, b) => {
|
|
907
|
-
const ta = Date.parse(a.startedAt) || 0;
|
|
908
|
-
const tb = Date.parse(b.startedAt) || 0;
|
|
909
|
-
return tb - ta;
|
|
910
|
-
});
|
|
911
|
-
res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
|
|
912
|
-
res.end(JSON.stringify({ plans, generated_at: new Date().toISOString() }));
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
function handleSpawnPlanUpdates(req, res, projectDir) {
|
|
916
|
-
res.writeHead(200, SSE_HEADERS);
|
|
917
|
-
// Initial snapshot: every current active plan.
|
|
918
|
-
const initial = listActiveSpawnPlans(projectDir);
|
|
919
|
-
for (const plan of initial) {
|
|
920
|
-
try { res.write("data: " + JSON.stringify({ spawnId: plan.spawnId, plan }) + "\n\n"); } catch { /* gone */ }
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
const dir = spawnsDir(projectDir);
|
|
924
|
-
let dirWatcher = null;
|
|
925
|
-
const emittedCache = new Map(); // spawnId → last-mtime-ms, dedup rapid rename events
|
|
926
|
-
|
|
927
|
-
function pushChange(spawnId) {
|
|
928
|
-
if (!spawnId) return;
|
|
929
|
-
const fp = path.join(dir, spawnId + ".json");
|
|
930
|
-
let mtime = 0;
|
|
931
|
-
try { mtime = fs.statSync(fp).mtimeMs || 0; } catch { /* missing */ }
|
|
932
|
-
// Dedup rapid-fire rename events — only emit when mtime advances.
|
|
933
|
-
if (emittedCache.get(spawnId) === mtime && mtime > 0) return;
|
|
934
|
-
emittedCache.set(spawnId, mtime);
|
|
935
|
-
const plan = readSpawnPlanFile(fp);
|
|
936
|
-
if (!plan) return;
|
|
937
|
-
try { res.write("data: " + JSON.stringify({ spawnId, plan }) + "\n\n"); } catch { /* gone */ }
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
try {
|
|
941
|
-
if (fs.existsSync(dir)) {
|
|
942
|
-
dirWatcher = fs.watch(dir, (eventType, filename) => {
|
|
943
|
-
if (!filename || !filename.endsWith(".json")) return;
|
|
944
|
-
const spawnId = filename.slice(0, -5);
|
|
945
|
-
pushChange(spawnId);
|
|
946
|
-
});
|
|
947
|
-
}
|
|
948
|
-
} catch { /* dir may not exist yet; skip watch */ }
|
|
949
|
-
|
|
950
|
-
const timer = setInterval(() => { try { res.write(": keepalive\n\n"); } catch { clearInterval(timer); } }, KEEPALIVE_MS);
|
|
951
|
-
req.on("close", () => { clearInterval(timer); if (dirWatcher) { try { dirWatcher.close(); } catch { /* ok */ } } });
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
// ── M49 — Idle-TTL self-shutdown ────────────────────────────────────────────
|
|
955
|
-
//
|
|
956
|
-
// A dashboard with zero HTTP requests AND zero active SSE connections for the
|
|
957
|
-
// full TTL window self-exits cleanly. Safety net for any dashboard that
|
|
958
|
-
// somehow gets started and then walks away — even if a future bug lets one
|
|
959
|
-
// be auto-started, it dies on its own. Configurable via env
|
|
960
|
-
// `GSD_T_DASHBOARD_IDLE_TTL_MS` or `--idle-ttl-ms` flag.
|
|
961
|
-
//
|
|
962
|
-
// "Idle" means: zero HTTP requests AND zero active SSE connections for the
|
|
963
|
-
// full TTL window. `lastActivity` is bumped on every HTTP request handler
|
|
964
|
-
// entry and on SSE connect/disconnect. SSE-active dashboards never exit.
|
|
965
|
-
//
|
|
966
|
-
// On shutdown, removes `.gsd-t/.dashboard.pid` so the lazy probe (M49 in
|
|
967
|
-
// `bin/headless-auto-spawn.cjs`) sees a clean state.
|
|
968
|
-
|
|
969
|
-
const DEFAULT_IDLE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
|
|
970
|
-
const IDLE_CHECK_INTERVAL_MS = 60 * 1000; // 60s
|
|
971
|
-
|
|
972
|
-
function _activityTracker() {
|
|
973
|
-
let lastActivity = Date.now();
|
|
974
|
-
let activeSseConnections = 0;
|
|
975
|
-
return {
|
|
976
|
-
bump() { lastActivity = Date.now(); },
|
|
977
|
-
sseConnect() { activeSseConnections++; lastActivity = Date.now(); },
|
|
978
|
-
sseDisconnect() {
|
|
979
|
-
if (activeSseConnections > 0) activeSseConnections--;
|
|
980
|
-
lastActivity = Date.now();
|
|
981
|
-
},
|
|
982
|
-
snapshot() { return { lastActivity, activeSseConnections }; },
|
|
983
|
-
};
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
/**
|
|
987
|
-
* Wrap an SSE handler so it bumps the connect/disconnect counters.
|
|
988
|
-
*/
|
|
989
|
-
function _wrapSseHandler(handler, tracker) {
|
|
990
|
-
return function (req, res, ...rest) {
|
|
991
|
-
tracker.sseConnect();
|
|
992
|
-
let closed = false;
|
|
993
|
-
const onClose = () => {
|
|
994
|
-
if (closed) return;
|
|
995
|
-
closed = true;
|
|
996
|
-
tracker.sseDisconnect();
|
|
997
|
-
};
|
|
998
|
-
req.on("close", onClose);
|
|
999
|
-
res.on("close", onClose);
|
|
1000
|
-
res.on("finish", onClose);
|
|
1001
|
-
return handler(req, res, ...rest);
|
|
1002
|
-
};
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
/**
|
|
1006
|
-
* @param {object} opts { ttlMs, intervalMs, projectDir, server }
|
|
1007
|
-
* @returns timer handle (so callers can clearInterval in tests).
|
|
1008
|
-
*/
|
|
1009
|
-
function _startIdleTtlTimer({ ttlMs, intervalMs, projectDir, server, tracker, onShutdown }) {
|
|
1010
|
-
const interval = setInterval(() => {
|
|
1011
|
-
const { lastActivity, activeSseConnections } = tracker.snapshot();
|
|
1012
|
-
const idle = Date.now() - lastActivity;
|
|
1013
|
-
if (activeSseConnections === 0 && idle >= ttlMs) {
|
|
1014
|
-
clearInterval(interval);
|
|
1015
|
-
try {
|
|
1016
|
-
// Remove pid file so the lazy probe in headless-auto-spawn sees clean state.
|
|
1017
|
-
if (projectDir) {
|
|
1018
|
-
const pidFile = path.join(projectDir, ".gsd-t", ".dashboard.pid");
|
|
1019
|
-
try { fs.unlinkSync(pidFile); } catch { /* may not exist */ }
|
|
1020
|
-
}
|
|
1021
|
-
} catch { /* best-effort */ }
|
|
1022
|
-
try { if (typeof onShutdown === "function") onShutdown(); } catch { /* best-effort */ }
|
|
1023
|
-
try {
|
|
1024
|
-
if (server) server.close(() => process.exit(0));
|
|
1025
|
-
else process.exit(0);
|
|
1026
|
-
} catch { process.exit(0); }
|
|
1027
|
-
}
|
|
1028
|
-
}, intervalMs);
|
|
1029
|
-
if (typeof interval.unref === "function") interval.unref();
|
|
1030
|
-
return interval;
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath, opts) {
|
|
1034
|
-
const projDir = projectDir || path.resolve(eventsDir, "..", "..");
|
|
1035
|
-
const tHtmlPath = transcriptHtmlPath || path.join(path.dirname(htmlPath), "gsd-t-transcript.html");
|
|
1036
|
-
const tracker = _activityTracker();
|
|
1037
|
-
const ttlMs = (opts && Number.isFinite(opts.idleTtlMs))
|
|
1038
|
-
? opts.idleTtlMs
|
|
1039
|
-
: (Number.parseInt(process.env.GSD_T_DASHBOARD_IDLE_TTL_MS || "", 10) || DEFAULT_IDLE_TTL_MS);
|
|
1040
|
-
const intervalMs = (opts && Number.isFinite(opts.idleCheckIntervalMs))
|
|
1041
|
-
? opts.idleCheckIntervalMs
|
|
1042
|
-
: IDLE_CHECK_INTERVAL_MS;
|
|
1043
|
-
|
|
1044
|
-
// Wrap the three SSE handlers with the connect/disconnect tracker.
|
|
1045
|
-
const handleEventsSse = _wrapSseHandler(handleEvents, tracker);
|
|
1046
|
-
const handleTranscriptStreamSse = _wrapSseHandler(handleTranscriptStream, tracker);
|
|
1047
|
-
const handleSpawnPlanUpdatesSse = _wrapSseHandler(handleSpawnPlanUpdates, tracker);
|
|
1048
|
-
|
|
1049
|
-
const server = http.createServer((req, res) => {
|
|
1050
|
-
tracker.bump(); // bump on every HTTP request handler entry
|
|
1051
|
-
const url = req.url.split("?")[0];
|
|
1052
|
-
if (url === "/" || url === "") return handleRoot(req, res, htmlPath);
|
|
1053
|
-
if (url === "/events") return handleEventsSse(req, res, eventsDir);
|
|
1054
|
-
if (url === "/metrics") return handleMetrics(req, res, projDir);
|
|
1055
|
-
if (url === "/ping") return handlePing(req, res, port);
|
|
1056
|
-
if (url === "/stop") return handleStop(req, res, server);
|
|
1057
|
-
if (url === "/transcripts") return handleTranscriptsList(req, res, projDir, tHtmlPath);
|
|
1058
|
-
// M47 D2 — most-recent in-session NDJSON for the viewer top-pane default load
|
|
1059
|
-
if (url === "/api/main-session") return handleMainSession(req, res, projDir);
|
|
1060
|
-
// M44 D8 — spawn plans: GET list + SSE change stream
|
|
1061
|
-
if (url === "/api/spawn-plans") return handleSpawnPlans(req, res, projDir);
|
|
1062
|
-
if (url === "/api/spawn-plans/stream") return handleSpawnPlanUpdatesSse(req, res, projDir);
|
|
1063
|
-
// M44 D9 — parallelism observability (additive, read-only)
|
|
1064
|
-
if (url === "/api/parallelism") return handleParallelism(req, res, projDir);
|
|
1065
|
-
if (url === "/api/parallelism/report") return handleParallelismReport(req, res, projDir);
|
|
1066
|
-
// M44 D9 — stop-supervisor proxy (POST only; reuses existing sentinel flow)
|
|
1067
|
-
if (url === "/api/unattended-stop") return handleUnattendedStop(req, res, projDir);
|
|
1068
|
-
// M54 D1 T3 — live-activity endpoints (additive, read-only)
|
|
1069
|
-
if (url === "/api/live-activity") return handleLiveActivity(req, res, projDir);
|
|
1070
|
-
const laTailMatch = url.match(/^\/api\/live-activity\/([^/]+)\/tail$/);
|
|
1071
|
-
if (laTailMatch) return handleLiveActivityTail(req, res, projDir, decodeURIComponent(laTailMatch[1]));
|
|
1072
|
-
const laStreamMatch = url.match(/^\/api\/live-activity\/([^/]+)\/stream$/);
|
|
1073
|
-
if (laStreamMatch) return handleLiveActivityStream(req, res, projDir, decodeURIComponent(laStreamMatch[1]));
|
|
1074
|
-
// POST /transcript/:spawnId/kill — SIGTERM the recorded workerPid
|
|
1075
|
-
const killMatch = url.match(/^\/transcript\/([^/]+)\/kill$/);
|
|
1076
|
-
if (killMatch && req.method === "POST") return handleTranscriptKill(req, res, decodeURIComponent(killMatch[1]), projDir);
|
|
1077
|
-
// M43 D6 — /transcript/:spawnId/tool-cost — D2 attribution proxy
|
|
1078
|
-
const toolCostMatch = url.match(/^\/transcript\/([^/]+)\/tool-cost$/);
|
|
1079
|
-
if (toolCostMatch) return handleTranscriptToolCost(req, res, decodeURIComponent(toolCostMatch[1]), projDir);
|
|
1080
|
-
// M43 D6 — /transcript/:spawnId/usage — per-turn rows for this spawn
|
|
1081
|
-
const usageMatch = url.match(/^\/transcript\/([^/]+)\/usage$/);
|
|
1082
|
-
if (usageMatch) return handleTranscriptUsage(req, res, decodeURIComponent(usageMatch[1]), projDir);
|
|
1083
|
-
// /transcript/:spawnId/stream — SSE tail of per-spawn ndjson
|
|
1084
|
-
const streamMatch = url.match(/^\/transcript\/([^/]+)\/stream$/);
|
|
1085
|
-
if (streamMatch) return handleTranscriptStreamSse(req, res, decodeURIComponent(streamMatch[1]), projDir);
|
|
1086
|
-
// /transcript/:spawnId — HTML viewer page
|
|
1087
|
-
const pageMatch = url.match(/^\/transcript\/([^/]+)$/);
|
|
1088
|
-
if (pageMatch) return handleTranscriptPage(req, res, decodeURIComponent(pageMatch[1]), tHtmlPath, projDir);
|
|
1089
|
-
res.writeHead(404); res.end("Not found");
|
|
1090
|
-
});
|
|
1091
|
-
server.listen(port);
|
|
1092
|
-
|
|
1093
|
-
// M49 — install idle-TTL self-shutdown timer. Skipped only when caller
|
|
1094
|
-
// explicitly passes `idleTtlMs: 0` (used by tests that don't want the
|
|
1095
|
-
// server to self-exit mid-test).
|
|
1096
|
-
let idleTimer = null;
|
|
1097
|
-
if (ttlMs > 0) {
|
|
1098
|
-
idleTimer = _startIdleTtlTimer({
|
|
1099
|
-
ttlMs,
|
|
1100
|
-
intervalMs,
|
|
1101
|
-
projectDir: projDir,
|
|
1102
|
-
server,
|
|
1103
|
-
tracker,
|
|
1104
|
-
onShutdown: opts && opts.onShutdown,
|
|
1105
|
-
});
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
return { server, url: `http://localhost:${port}`, tracker, idleTimer };
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
module.exports = {
|
|
1112
|
-
startServer,
|
|
1113
|
-
tailEventsFile,
|
|
1114
|
-
readExistingEvents,
|
|
1115
|
-
parseEventLine,
|
|
1116
|
-
findEventsDir,
|
|
1117
|
-
readMetricsData,
|
|
1118
|
-
readTranscriptsIndex,
|
|
1119
|
-
writeTranscriptsIndex,
|
|
1120
|
-
readIndexEntry,
|
|
1121
|
-
isValidSpawnId,
|
|
1122
|
-
listInSessionTranscripts,
|
|
1123
|
-
handleMainSession,
|
|
1124
|
-
handleTranscriptsList,
|
|
1125
|
-
handleTranscriptStream,
|
|
1126
|
-
handleTranscriptPage,
|
|
1127
|
-
handleTranscriptKill,
|
|
1128
|
-
handleTranscriptToolCost,
|
|
1129
|
-
handleTranscriptUsage,
|
|
1130
|
-
readSpawnUsageRows,
|
|
1131
|
-
transcriptsDir,
|
|
1132
|
-
DEFAULT_PORT,
|
|
1133
|
-
projectScopedDefaultPort,
|
|
1134
|
-
resolvePort,
|
|
1135
|
-
// M44 D8 — spawn-plan visibility
|
|
1136
|
-
listAllSpawnPlans,
|
|
1137
|
-
listActiveSpawnPlans,
|
|
1138
|
-
handleSpawnPlans,
|
|
1139
|
-
handleSpawnPlanUpdates,
|
|
1140
|
-
readSpawnPlanFile,
|
|
1141
|
-
spawnsDir,
|
|
1142
|
-
// M44 D9 — parallelism observability
|
|
1143
|
-
handleParallelism,
|
|
1144
|
-
handleParallelismReport,
|
|
1145
|
-
handleUnattendedStop,
|
|
1146
|
-
// M54 D1 T3 — live-activity observability
|
|
1147
|
-
handleLiveActivity,
|
|
1148
|
-
handleLiveActivityTail,
|
|
1149
|
-
handleLiveActivityStream,
|
|
1150
|
-
isValidActivityId,
|
|
1151
|
-
_liveActivityCache,
|
|
1152
|
-
// M49 — idle-TTL exports for tests
|
|
1153
|
-
_activityTracker,
|
|
1154
|
-
_wrapSseHandler,
|
|
1155
|
-
_startIdleTtlTimer,
|
|
1156
|
-
DEFAULT_IDLE_TTL_MS,
|
|
1157
|
-
};
|
|
1158
|
-
|
|
1159
|
-
if (require.main === module) {
|
|
1160
|
-
const argv = process.argv.slice(2);
|
|
1161
|
-
const getArg = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
|
|
1162
|
-
const hasFlag = (f) => argv.includes(f);
|
|
1163
|
-
const projectDir = process.env.GSD_T_PROJECT_DIR || process.cwd();
|
|
1164
|
-
const port = resolvePort({ argPort: getArg("--port"), projectDir });
|
|
1165
|
-
const eventsDir = getArg("--events") || findEventsDir(projectDir);
|
|
1166
|
-
const pidFile = path.join(projectDir, ".gsd-t", "dashboard.pid");
|
|
1167
|
-
const htmlPath = path.join(__dirname, "gsd-t-dashboard.html");
|
|
1168
|
-
const transcriptHtmlPath = path.join(__dirname, "gsd-t-transcript.html");
|
|
1169
|
-
|
|
1170
|
-
if (hasFlag("--stop")) {
|
|
1171
|
-
try { const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10); process.kill(pid); fs.unlinkSync(pidFile); }
|
|
1172
|
-
catch (e) { process.stderr.write("No running server: " + e.message + "\n"); }
|
|
1173
|
-
process.exit(0);
|
|
1174
|
-
}
|
|
1175
|
-
if (hasFlag("--detach")) {
|
|
1176
|
-
const child = spawn(process.execPath, [__filename, ...argv.filter((a) => a !== "--detach")], { detached: true, stdio: "ignore" });
|
|
1177
|
-
child.unref();
|
|
1178
|
-
try { fs.mkdirSync(path.dirname(pidFile), { recursive: true }); } catch { /* exists */ }
|
|
1179
|
-
fs.writeFileSync(pidFile, String(child.pid));
|
|
1180
|
-
process.exit(0);
|
|
1181
|
-
}
|
|
1182
|
-
// M49 — idle-TTL flag/env override. Falls through to startServer's default
|
|
1183
|
-
// (env var GSD_T_DASHBOARD_IDLE_TTL_MS or 4h).
|
|
1184
|
-
const ttlArg = getArg("--idle-ttl-ms");
|
|
1185
|
-
const startOpts = {};
|
|
1186
|
-
if (ttlArg != null && ttlArg !== "") {
|
|
1187
|
-
const n = Number.parseInt(ttlArg, 10);
|
|
1188
|
-
if (Number.isFinite(n)) startOpts.idleTtlMs = n;
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
const { server, url } = startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath, startOpts);
|
|
1192
|
-
process.stdout.write("GSD-T Dashboard: " + url + "\n");
|
|
1193
|
-
function cleanup() {
|
|
1194
|
-
try { fs.unlinkSync(pidFile); } catch { /* ok */ }
|
|
1195
|
-
// M49 — also remove the lazy-probe pidfile so headless-auto-spawn sees clean state.
|
|
1196
|
-
try {
|
|
1197
|
-
fs.unlinkSync(path.join(projectDir, ".gsd-t", ".dashboard.pid"));
|
|
1198
|
-
} catch { /* ok */ }
|
|
1199
|
-
server.close(() => process.exit(0));
|
|
1200
|
-
}
|
|
1201
|
-
process.on("SIGTERM", cleanup);
|
|
1202
|
-
process.on("SIGINT", cleanup);
|
|
1203
|
-
}
|