@tekyzinc/gsd-t 3.15.10 → 3.16.11
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/bin/gsd-t-orchestrator-worker.cjs +35 -3
- package/bin/gsd-t-token-capture.cjs +24 -3
- package/bin/gsd-t-token-regenerate-log.cjs +129 -0
- package/bin/gsd-t-transcript-tee.cjs +246 -0
- package/bin/gsd-t-unattended-heartbeat.cjs +188 -0
- package/bin/gsd-t-unattended-platform.cjs +191 -27
- package/bin/gsd-t-unattended-safety.cjs +8 -1
- package/bin/gsd-t-unattended.cjs +192 -31
- package/bin/gsd-t.js +15 -1
- package/bin/supervisor-pid-fingerprint.cjs +126 -0
- package/commands/gsd-t-resume.md +18 -4
- package/docs/architecture.md +16 -0
- package/package.json +1 -1
- package/scripts/gsd-t-dashboard-server.js +291 -4
- package/scripts/gsd-t-dashboard.html +31 -1
- package/scripts/gsd-t-transcript.html +422 -0
- package/scripts/hooks/gsd-t-in-session-probe.js +62 -0
|
@@ -8,8 +8,38 @@ const fs = require("fs");
|
|
|
8
8
|
const path = require("path");
|
|
9
9
|
const { spawn } = require("child_process");
|
|
10
10
|
|
|
11
|
+
// Base port — the effective default is project-hashed (see
|
|
12
|
+
// projectScopedDefaultPort). Explicit --port N always overrides.
|
|
11
13
|
const DEFAULT_PORT = 7433;
|
|
12
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
|
+
}
|
|
13
43
|
const KEEPALIVE_MS = 15000;
|
|
14
44
|
const SSE_HEADERS = { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*" };
|
|
15
45
|
|
|
@@ -124,8 +154,235 @@ function handleMetrics(req, res, projectDir) {
|
|
|
124
154
|
res.end(JSON.stringify(data));
|
|
125
155
|
}
|
|
126
156
|
|
|
127
|
-
|
|
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
|
+
function handleTranscriptsList(req, res, projectDir) {
|
|
180
|
+
const idx = readTranscriptsIndex(projectDir);
|
|
181
|
+
const sorted = idx.spawns
|
|
182
|
+
.slice()
|
|
183
|
+
.sort((a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0));
|
|
184
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
185
|
+
res.end(JSON.stringify({ spawns: sorted }));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function handleTranscriptPage(req, res, spawnId, transcriptHtmlPath) {
|
|
189
|
+
if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
|
|
190
|
+
fs.readFile(transcriptHtmlPath, (err, data) => {
|
|
191
|
+
if (err) { res.writeHead(404); res.end("Transcript UI not found"); return; }
|
|
192
|
+
// Inject the spawn-id as a data attribute on <body> by string replacement;
|
|
193
|
+
// the HTML ships with a placeholder `data-spawn-id="__SPAWN_ID__"`.
|
|
194
|
+
const html = data.toString("utf8").replace(/__SPAWN_ID__/g, spawnId);
|
|
195
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
196
|
+
res.end(html);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function tailTranscriptFile(filePath, callback) {
|
|
201
|
+
let offset = 0;
|
|
202
|
+
let buf = "";
|
|
203
|
+
function processNewData() {
|
|
204
|
+
let stat;
|
|
205
|
+
try { stat = fs.statSync(filePath); } catch { return; }
|
|
206
|
+
if (stat.size <= offset) return;
|
|
207
|
+
const fd = fs.openSync(filePath, "r");
|
|
208
|
+
try {
|
|
209
|
+
const b = Buffer.alloc(stat.size - offset);
|
|
210
|
+
fs.readSync(fd, b, 0, b.length, offset);
|
|
211
|
+
buf += b.toString("utf8");
|
|
212
|
+
offset = stat.size;
|
|
213
|
+
} finally { fs.closeSync(fd); }
|
|
214
|
+
let nl;
|
|
215
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
216
|
+
const line = buf.slice(0, nl);
|
|
217
|
+
buf = buf.slice(nl + 1);
|
|
218
|
+
if (line.length > 0) callback(line);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
fs.watchFile(filePath, { interval: 500, persistent: true }, processNewData);
|
|
222
|
+
return () => fs.unwatchFile(filePath, processNewData);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── M42 D3 — kill per-spawn ────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
function writeTranscriptsIndex(projectDir, idx) {
|
|
228
|
+
const p = path.join(transcriptsDir(projectDir), ".index.json");
|
|
229
|
+
const tmp = p + ".tmp";
|
|
230
|
+
try { fs.mkdirSync(path.dirname(p), { recursive: true }); } catch { /* exists */ }
|
|
231
|
+
fs.writeFileSync(tmp, JSON.stringify(idx, null, 2));
|
|
232
|
+
fs.renameSync(tmp, p);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function handleTranscriptKill(req, res, spawnId, projectDir) {
|
|
236
|
+
if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
|
|
237
|
+
const idx = readTranscriptsIndex(projectDir);
|
|
238
|
+
const i = idx.spawns.findIndex((s) => s.spawnId === spawnId);
|
|
239
|
+
if (i < 0) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unknown_spawn" })); return; }
|
|
240
|
+
const entry = idx.spawns[i];
|
|
241
|
+
const pid = entry.workerPid;
|
|
242
|
+
if (!pid || typeof pid !== "number") {
|
|
243
|
+
res.writeHead(409, { "Content-Type": "application/json" });
|
|
244
|
+
res.end(JSON.stringify({ error: "no_pid_recorded" }));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
process.kill(pid, "SIGTERM");
|
|
249
|
+
idx.spawns[i].status = "stopped";
|
|
250
|
+
idx.spawns[i].endedAt = idx.spawns[i].endedAt || new Date().toISOString();
|
|
251
|
+
writeTranscriptsIndex(projectDir, idx);
|
|
252
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
253
|
+
res.end(JSON.stringify({ status: "stopped", pid }));
|
|
254
|
+
} catch (err) {
|
|
255
|
+
if (err.code === "ESRCH") {
|
|
256
|
+
// Process already gone — treat as success and mark ended
|
|
257
|
+
idx.spawns[i].status = idx.spawns[i].status === "running" ? "stopped" : idx.spawns[i].status;
|
|
258
|
+
idx.spawns[i].endedAt = idx.spawns[i].endedAt || new Date().toISOString();
|
|
259
|
+
writeTranscriptsIndex(projectDir, idx);
|
|
260
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
261
|
+
res.end(JSON.stringify({ status: "already_stopped", pid }));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (err.code === "EPERM") {
|
|
265
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
266
|
+
res.end(JSON.stringify({ error: "permission_denied", pid }));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
270
|
+
res.end(JSON.stringify({ error: String(err.message || err) }));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Find the transcripts-index entry for a spawn-id, or null when the index
|
|
276
|
+
* is missing / the id isn't recorded. Used by handleTranscriptStream to
|
|
277
|
+
* detect already-finished spawns so the SSE stream can emit `event: end`
|
|
278
|
+
* and close instead of tailing indefinitely.
|
|
279
|
+
*/
|
|
280
|
+
function readIndexEntry(projectDir, spawnId) {
|
|
281
|
+
const idx = readTranscriptsIndex(projectDir);
|
|
282
|
+
if (!idx || !Array.isArray(idx.spawns)) return null;
|
|
283
|
+
return idx.spawns.find((s) => s && s.spawnId === spawnId) || null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function handleTranscriptStream(req, res, spawnId, projectDir) {
|
|
287
|
+
if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
|
|
288
|
+
const filePath = path.join(transcriptsDir(projectDir), `${spawnId}.ndjson`);
|
|
289
|
+
let exists = true;
|
|
290
|
+
let fileSize = 0;
|
|
291
|
+
try { fileSize = fs.statSync(filePath).size; } catch { exists = false; }
|
|
292
|
+
|
|
293
|
+
res.writeHead(200, SSE_HEADERS);
|
|
294
|
+
|
|
295
|
+
// Consult the transcripts index — if the spawn is already finished we
|
|
296
|
+
// replay and close instead of attaching a live tail.
|
|
297
|
+
const entry = readIndexEntry(projectDir, spawnId);
|
|
298
|
+
const finishedStatuses = ["done", "failed", "stopped"];
|
|
299
|
+
const isFinished = !!(entry && entry.status && finishedStatuses.includes(entry.status));
|
|
300
|
+
|
|
301
|
+
// Replay: read the full file from byte 0 and send each line.
|
|
302
|
+
let replayedBytes = 0;
|
|
303
|
+
if (exists) {
|
|
304
|
+
try {
|
|
305
|
+
const full = fs.readFileSync(filePath, "utf8");
|
|
306
|
+
replayedBytes = Buffer.byteLength(full, "utf8");
|
|
307
|
+
full.split("\n").forEach((line) => {
|
|
308
|
+
if (line.length > 0) {
|
|
309
|
+
try { res.write("data: " + line + "\n\n"); } catch { /* gone */ }
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
} catch { /* empty transcript */ }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Finished spawn → emit end event and close the stream. No tail, no keepalive.
|
|
316
|
+
if (isFinished) {
|
|
317
|
+
const endPayload = JSON.stringify({
|
|
318
|
+
status: entry.status,
|
|
319
|
+
endedAt: entry.endedAt || null,
|
|
320
|
+
});
|
|
321
|
+
try { res.write("event: end\ndata: " + endPayload + "\n\n"); } catch { /* gone */ }
|
|
322
|
+
try { res.end(); } catch { /* gone */ }
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Missing file — tell the viewer the stream is live but empty. fs.watchFile
|
|
327
|
+
// needs the file to exist, so we skip the tail here. A running producer
|
|
328
|
+
// will have created the file already (the finished-spawn branch above
|
|
329
|
+
// catches the post-hoc teed case via index status).
|
|
330
|
+
if (!exists) {
|
|
331
|
+
const waitingPayload = JSON.stringify({
|
|
332
|
+
status: "waiting",
|
|
333
|
+
reason: "no transcript file yet",
|
|
334
|
+
});
|
|
335
|
+
try { res.write("event: status\ndata: " + waitingPayload + "\n\n"); } catch { /* gone */ }
|
|
336
|
+
const timer = setInterval(() => { try { res.write(": keepalive\n\n"); } catch { clearInterval(timer); } }, KEEPALIVE_MS);
|
|
337
|
+
req.on("close", () => { clearInterval(timer); });
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Empty file — producer hasn't written anything yet. Emit a status frame
|
|
342
|
+
// so the viewer knows the stream is live but the file is 0 bytes, then
|
|
343
|
+
// attach the live tail so we pick up writes as they arrive.
|
|
344
|
+
if (fileSize === 0) {
|
|
345
|
+
const emptyPayload = JSON.stringify({
|
|
346
|
+
status: "empty",
|
|
347
|
+
reason: "transcript file exists but is empty",
|
|
348
|
+
});
|
|
349
|
+
try { res.write("event: status\ndata: " + emptyPayload + "\n\n"); } catch { /* gone */ }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Tail: from replayedBytes onward. We reuse the tailer, but seed offset
|
|
353
|
+
// with what we already replayed so we don't double-send.
|
|
354
|
+
let offset = replayedBytes;
|
|
355
|
+
let buf = "";
|
|
356
|
+
const processNewData = () => {
|
|
357
|
+
let stat;
|
|
358
|
+
try { stat = fs.statSync(filePath); } catch { return; }
|
|
359
|
+
if (stat.size <= offset) return;
|
|
360
|
+
const fd = fs.openSync(filePath, "r");
|
|
361
|
+
try {
|
|
362
|
+
const b = Buffer.alloc(stat.size - offset);
|
|
363
|
+
fs.readSync(fd, b, 0, b.length, offset);
|
|
364
|
+
buf += b.toString("utf8");
|
|
365
|
+
offset = stat.size;
|
|
366
|
+
} finally { fs.closeSync(fd); }
|
|
367
|
+
let nl;
|
|
368
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
369
|
+
const line = buf.slice(0, nl);
|
|
370
|
+
buf = buf.slice(nl + 1);
|
|
371
|
+
if (line.length > 0) {
|
|
372
|
+
try { res.write("data: " + line + "\n\n"); } catch { /* gone */ }
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
fs.watchFile(filePath, { interval: 500, persistent: true }, processNewData);
|
|
377
|
+
const unwatchFile = () => fs.unwatchFile(filePath, processNewData);
|
|
378
|
+
|
|
379
|
+
const timer = setInterval(() => { try { res.write(": keepalive\n\n"); } catch { clearInterval(timer); } }, KEEPALIVE_MS);
|
|
380
|
+
req.on("close", () => { clearInterval(timer); if (unwatchFile) unwatchFile(); });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath) {
|
|
128
384
|
const projDir = projectDir || path.resolve(eventsDir, "..", "..");
|
|
385
|
+
const tHtmlPath = transcriptHtmlPath || path.join(path.dirname(htmlPath), "gsd-t-transcript.html");
|
|
129
386
|
const server = http.createServer((req, res) => {
|
|
130
387
|
const url = req.url.split("?")[0];
|
|
131
388
|
if (url === "/" || url === "") return handleRoot(req, res, htmlPath);
|
|
@@ -133,23 +390,53 @@ function startServer(port, eventsDir, htmlPath, projectDir) {
|
|
|
133
390
|
if (url === "/metrics") return handleMetrics(req, res, projDir);
|
|
134
391
|
if (url === "/ping") return handlePing(req, res, port);
|
|
135
392
|
if (url === "/stop") return handleStop(req, res, server);
|
|
393
|
+
if (url === "/transcripts") return handleTranscriptsList(req, res, projDir);
|
|
394
|
+
// POST /transcript/:spawnId/kill — SIGTERM the recorded workerPid
|
|
395
|
+
const killMatch = url.match(/^\/transcript\/([^/]+)\/kill$/);
|
|
396
|
+
if (killMatch && req.method === "POST") return handleTranscriptKill(req, res, decodeURIComponent(killMatch[1]), projDir);
|
|
397
|
+
// /transcript/:spawnId/stream — SSE tail of per-spawn ndjson
|
|
398
|
+
const streamMatch = url.match(/^\/transcript\/([^/]+)\/stream$/);
|
|
399
|
+
if (streamMatch) return handleTranscriptStream(req, res, decodeURIComponent(streamMatch[1]), projDir);
|
|
400
|
+
// /transcript/:spawnId — HTML viewer page
|
|
401
|
+
const pageMatch = url.match(/^\/transcript\/([^/]+)$/);
|
|
402
|
+
if (pageMatch) return handleTranscriptPage(req, res, decodeURIComponent(pageMatch[1]), tHtmlPath);
|
|
136
403
|
res.writeHead(404); res.end("Not found");
|
|
137
404
|
});
|
|
138
405
|
server.listen(port);
|
|
139
406
|
return { server, url: `http://localhost:${port}` };
|
|
140
407
|
}
|
|
141
408
|
|
|
142
|
-
module.exports = {
|
|
409
|
+
module.exports = {
|
|
410
|
+
startServer,
|
|
411
|
+
tailEventsFile,
|
|
412
|
+
readExistingEvents,
|
|
413
|
+
parseEventLine,
|
|
414
|
+
findEventsDir,
|
|
415
|
+
readMetricsData,
|
|
416
|
+
readTranscriptsIndex,
|
|
417
|
+
writeTranscriptsIndex,
|
|
418
|
+
readIndexEntry,
|
|
419
|
+
isValidSpawnId,
|
|
420
|
+
handleTranscriptsList,
|
|
421
|
+
handleTranscriptStream,
|
|
422
|
+
handleTranscriptPage,
|
|
423
|
+
handleTranscriptKill,
|
|
424
|
+
transcriptsDir,
|
|
425
|
+
DEFAULT_PORT,
|
|
426
|
+
projectScopedDefaultPort,
|
|
427
|
+
resolvePort,
|
|
428
|
+
};
|
|
143
429
|
|
|
144
430
|
if (require.main === module) {
|
|
145
431
|
const argv = process.argv.slice(2);
|
|
146
432
|
const getArg = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
|
|
147
433
|
const hasFlag = (f) => argv.includes(f);
|
|
148
|
-
const port = parseInt(getArg("--port") || DEFAULT_PORT, 10);
|
|
149
434
|
const projectDir = process.env.GSD_T_PROJECT_DIR || process.cwd();
|
|
435
|
+
const port = resolvePort({ argPort: getArg("--port"), projectDir });
|
|
150
436
|
const eventsDir = getArg("--events") || findEventsDir(projectDir);
|
|
151
437
|
const pidFile = path.join(projectDir, ".gsd-t", "dashboard.pid");
|
|
152
438
|
const htmlPath = path.join(__dirname, "gsd-t-dashboard.html");
|
|
439
|
+
const transcriptHtmlPath = path.join(__dirname, "gsd-t-transcript.html");
|
|
153
440
|
|
|
154
441
|
if (hasFlag("--stop")) {
|
|
155
442
|
try { const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10); process.kill(pid); fs.unlinkSync(pidFile); }
|
|
@@ -163,7 +450,7 @@ if (require.main === module) {
|
|
|
163
450
|
fs.writeFileSync(pidFile, String(child.pid));
|
|
164
451
|
process.exit(0);
|
|
165
452
|
}
|
|
166
|
-
const { server, url } = startServer(port, eventsDir, htmlPath);
|
|
453
|
+
const { server, url } = startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath);
|
|
167
454
|
process.stdout.write("GSD-T Dashboard: " + url + "\n");
|
|
168
455
|
function cleanup() { try { fs.unlinkSync(pidFile); } catch { /* ok */ } server.close(() => process.exit(0)); }
|
|
169
456
|
process.on("SIGTERM", cleanup);
|
|
@@ -26,6 +26,11 @@ body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:12
|
|
|
26
26
|
.status.wait{background:var(--yellow-bg);border-color:var(--yellow);color:var(--yellow);}
|
|
27
27
|
.dot{width:6px;height:6px;border-radius:50%;background:currentColor;animation:pdot 1.5s ease-in-out infinite;}
|
|
28
28
|
@keyframes pdot{0%,100%{opacity:1}50%{opacity:.3}}
|
|
29
|
+
.livestream-btn{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:4px;
|
|
30
|
+
background:var(--blue-bg);border:1px solid var(--blue);color:var(--blue);text-decoration:none;
|
|
31
|
+
font-size:10px;font-weight:500;font-family:var(--font);cursor:pointer;transition:opacity .15s;}
|
|
32
|
+
.livestream-btn:hover{opacity:.85;}
|
|
33
|
+
.livestream-btn.disabled{background:transparent;border-color:var(--border);color:var(--muted);cursor:not-allowed;pointer-events:none;}
|
|
29
34
|
.hright{margin-left:auto;color:var(--muted);font-size:11px;}.main{display:flex;flex:1;overflow:hidden;}
|
|
30
35
|
.garea{flex:1;position:relative;background:var(--bg);}.rfwrap{width:100%;height:100%;}
|
|
31
36
|
.react-flow__background{background:var(--bg);}.react-flow__node{font-family:var(--font);font-size:11px;}
|
|
@@ -66,6 +71,7 @@ body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:12
|
|
|
66
71
|
<div class="hdr">
|
|
67
72
|
<span class="logo">GSD-T Agent Dashboard</span>
|
|
68
73
|
<div id="status" class="status wait"><span class="dot"></span><span id="status-txt">Connecting...</span></div>
|
|
74
|
+
<a id="livestream-btn" class="livestream-btn disabled" href="#" title="Open the latest live spawn transcript">▶ Live Stream</a>
|
|
69
75
|
<div class="hright"><span id="ev-count">0 events</span></div>
|
|
70
76
|
</div>
|
|
71
77
|
<div class="main">
|
|
@@ -91,7 +97,7 @@ const {useState,useEffect,useCallback,useRef}=React;
|
|
|
91
97
|
const {ReactFlow,useNodesState,useEdgesState,Background,Controls,MarkerType}=window.ReactFlow||{};
|
|
92
98
|
const dagre=window.dagre;
|
|
93
99
|
const params=new URLSearchParams(location.search);
|
|
94
|
-
const PORT=params.get('port')||'7433';
|
|
100
|
+
const PORT=params.get('port')||location.port||'7433';
|
|
95
101
|
const MAX_EVENTS=200;
|
|
96
102
|
const GRAPH_W=160,GRAPH_H=60;
|
|
97
103
|
|
|
@@ -257,6 +263,30 @@ ReactDOM.render(React.createElement(Dashboard),document.getElementById('rf-root'
|
|
|
257
263
|
}
|
|
258
264
|
fetchMetrics();setInterval(fetchMetrics,30000);
|
|
259
265
|
})();
|
|
266
|
+
(function(){
|
|
267
|
+
const btn=document.getElementById('livestream-btn');
|
|
268
|
+
if(!btn)return;
|
|
269
|
+
function pickLatest(spawns){
|
|
270
|
+
if(!Array.isArray(spawns)||spawns.length===0)return null;
|
|
271
|
+
const live=spawns.filter(s=>s.status&&!['done','stopped','failed','crashed'].includes(s.status));
|
|
272
|
+
const pool=live.length?live:spawns;
|
|
273
|
+
return pool.slice().sort((a,b)=>(Date.parse(b.startedAt)||0)-(Date.parse(a.startedAt)||0))[0];
|
|
274
|
+
}
|
|
275
|
+
function refresh(){
|
|
276
|
+
fetch(`http://localhost:${PORT}/transcripts`,{cache:'no-store'}).then(r=>r.ok?r.json():null).then(d=>{
|
|
277
|
+
const latest=d&&pickLatest(d.spawns);
|
|
278
|
+
if(latest){
|
|
279
|
+
btn.href=`/transcript/${encodeURIComponent(latest.spawnId)}`;
|
|
280
|
+
btn.classList.remove('disabled');
|
|
281
|
+
const live=latest.status&&!['done','stopped','failed','crashed'].includes(latest.status);
|
|
282
|
+
btn.textContent=(live?'▶ Live Stream':'▶ Latest Transcript')+` · ${latest.spawnId.slice(0,10)}`;
|
|
283
|
+
}else{
|
|
284
|
+
btn.href='#';btn.classList.add('disabled');btn.textContent='▶ Live Stream (no spawns yet)';
|
|
285
|
+
}
|
|
286
|
+
}).catch(()=>{});
|
|
287
|
+
}
|
|
288
|
+
refresh();setInterval(refresh,10000);
|
|
289
|
+
})();
|
|
260
290
|
</script>
|
|
261
291
|
</body>
|
|
262
292
|
</html>
|