omp-conductor 0.18.2 → 0.19.0
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/README.md +105 -40
- package/REFERENCE.md +865 -30
- package/package.json +1 -1
- package/schema/config.schema.json +26 -0
- package/src/admission.ts +212 -26
- package/src/ask.ts +288 -1
- package/src/briefs/orchestrator.md +6 -5
- package/src/cli.ts +5 -1
- package/src/command-help.ts +9 -1
- package/src/command-manifest.ts +36 -3
- package/src/commands/arm.ts +5 -1
- package/src/commands/context.ts +2 -0
- package/src/commands/message.ts +26 -2
- package/src/commands/reconcile-units.ts +104 -0
- package/src/commands/release-composition.ts +232 -0
- package/src/commands/resume.ts +2 -27
- package/src/commands/setup.ts +101 -16
- package/src/commands/stats.ts +11 -30
- package/src/commands/tail.ts +31 -1
- package/src/commands/upgrade.ts +20 -3
- package/src/commands/verb.ts +2 -1
- package/src/config-schema.ts +19 -0
- package/src/config.ts +80 -0
- package/src/credential-class.ts +366 -0
- package/src/daemon.ts +1218 -288
- package/src/dashboard/app.js +504 -2
- package/src/dashboard/controls.ts +336 -0
- package/src/dashboard/index.html +30 -0
- package/src/dashboard/server.ts +271 -30
- package/src/dashboard/style.css +116 -0
- package/src/dashboard/transcript.ts +173 -0
- package/src/doctor.ts +377 -20
- package/src/failure-class.ts +59 -0
- package/src/fleet.ts +497 -15
- package/src/host.ts +6 -130
- package/src/omp.ts +29 -0
- package/src/orchestrator-tick.ts +343 -88
- package/src/pause.ts +233 -0
- package/src/settlement.ts +159 -2
- package/src/setup-answers.ts +97 -0
- package/src/setup-host.ts +321 -1155
- package/src/setup-install.ts +204 -27
- package/src/setup-wizard.ts +111 -50
- package/src/setup.ts +33 -0
- package/src/spend-telemetry.ts +117 -0
- package/src/stats.ts +35 -0
- package/src/status-render.ts +348 -19
- package/src/store.ts +1229 -55
- package/src/telegram-freshness.ts +269 -0
- package/src/to-spec.ts +27 -0
- package/src/types.ts +697 -4
- package/src/unblock.ts +22 -0
- package/src/unit-reconcile.ts +303 -0
- package/src/upgrade-verify.ts +8 -1
- package/src/upgrade.ts +299 -12
- package/src/verbs/actions.ts +124 -10
- package/src/verbs/protocol.ts +70 -2
- package/src/verbs/server.ts +447 -8
- package/src/wake.ts +48 -0
- package/src/worker.ts +403 -3
package/src/dashboard/server.ts
CHANGED
|
@@ -32,9 +32,32 @@ import { classifyDaemonProjectHealth, fleetLayers } from "../fleet.ts";
|
|
|
32
32
|
import type { FleetLayers } from "../status-render.ts";
|
|
33
33
|
import { statusSnapshot, type StatusSnapshot } from "../daemon.ts";
|
|
34
34
|
import { boardJson, boardSnapshotOnce } from "../board.ts";
|
|
35
|
+
import { computeStats, parseStatsWindow, type StatsReport } from "../stats.ts";
|
|
35
36
|
import { dbPath, openStore } from "../store.ts";
|
|
36
37
|
import type { ProjectConfig } from "../types.ts";
|
|
37
|
-
import
|
|
38
|
+
import {
|
|
39
|
+
dashboardAnswerDecision,
|
|
40
|
+
dashboardDisarm,
|
|
41
|
+
dashboardExtend,
|
|
42
|
+
dashboardHold,
|
|
43
|
+
dashboardPause,
|
|
44
|
+
dashboardResume,
|
|
45
|
+
dashboardUnblock,
|
|
46
|
+
dashboardWorkerControl,
|
|
47
|
+
type ControlDeps,
|
|
48
|
+
type ControlOutcome,
|
|
49
|
+
} from "./controls.ts";
|
|
50
|
+
import { transcriptStreamResponse } from "./transcript.ts";
|
|
51
|
+
import type {
|
|
52
|
+
BaseHealth,
|
|
53
|
+
DecisionRecord,
|
|
54
|
+
DigestBacklog,
|
|
55
|
+
HeldNotice,
|
|
56
|
+
ReportRecord,
|
|
57
|
+
RunRecord,
|
|
58
|
+
TurnOverride,
|
|
59
|
+
VerbLedgerEntry,
|
|
60
|
+
} from "../types.ts";
|
|
38
61
|
|
|
39
62
|
/** The dashboard's default bind address — loopback, like the daemon port. */
|
|
40
63
|
export const DASHBOARD_HOST = "127.0.0.1";
|
|
@@ -259,13 +282,22 @@ async function ledgerProducer(
|
|
|
259
282
|
* open reports, the digest backlog, and the held notices awaiting a digest. */
|
|
260
283
|
async function reportsProducer(
|
|
261
284
|
name: string,
|
|
262
|
-
): Promise<{
|
|
285
|
+
): Promise<{
|
|
286
|
+
openReports: ReportRecord[];
|
|
287
|
+
digestBacklog: DigestBacklog;
|
|
288
|
+
heldNotices: HeldNotice[];
|
|
289
|
+
openDecisions: DecisionRecord[];
|
|
290
|
+
}> {
|
|
263
291
|
const store = openStore(dbPath());
|
|
264
292
|
try {
|
|
265
293
|
return {
|
|
266
294
|
openReports: store.openReports(name),
|
|
267
295
|
digestBacklog: store.digestBacklog(name),
|
|
268
296
|
heldNotices: store.undigestedNotices(name),
|
|
297
|
+
// The questions waiting on an operator. Carried here so the answer verb
|
|
298
|
+
// #295 added has something to answer: an endpoint with no surface that
|
|
299
|
+
// lists what it can act on is an endpoint nobody uses.
|
|
300
|
+
openDecisions: store.openDecisions(name),
|
|
269
301
|
};
|
|
270
302
|
} finally {
|
|
271
303
|
store.close();
|
|
@@ -274,6 +306,37 @@ async function reportsProducer(
|
|
|
274
306
|
|
|
275
307
|
/** `/api/projects/:name/runs/:issue` — every run row for the issue: attempts,
|
|
276
308
|
* states, spend, failure classes, PR URLs. */
|
|
309
|
+
/**
|
|
310
|
+
* The analytics payload (#297): byte-for-byte what `stats --json` prints.
|
|
311
|
+
*
|
|
312
|
+
* One aggregation, two consumers — `computeStats` and `parseStatsWindow` are the
|
|
313
|
+
* same functions the CLI runs, fed by the same read-only store queries. That is
|
|
314
|
+
* the whole of the slice's first acceptance criterion ("numbers on the page equal
|
|
315
|
+
* `stats --json` for the same window"): it holds because there is nothing else
|
|
316
|
+
* here that could compute a different number.
|
|
317
|
+
*
|
|
318
|
+
* Store-only by construction, which is the second criterion: `statsRuns` and
|
|
319
|
+
* `ghCallsBetween` are local reads, and no GitHub client exists in this module.
|
|
320
|
+
*/
|
|
321
|
+
async function statsProducer(name: string, since: string): Promise<StatsReport | undefined> {
|
|
322
|
+
const window = parseStatsWindow(since, Date.now());
|
|
323
|
+
// An unparseable window is the caller's error, and the caller turns this into
|
|
324
|
+
// a 400. Defaulting it would measure a different week and say nothing — the
|
|
325
|
+
// exact failure the shared parser exists to prevent.
|
|
326
|
+
if (window === undefined) return undefined;
|
|
327
|
+
const store = openStore(dbPath());
|
|
328
|
+
try {
|
|
329
|
+
return computeStats({
|
|
330
|
+
project: name,
|
|
331
|
+
window,
|
|
332
|
+
ghCalls: store.ghCallsBetween(window.sinceDay, window.untilDay),
|
|
333
|
+
runs: store.statsRuns(name, window.sinceEpochMs),
|
|
334
|
+
});
|
|
335
|
+
} finally {
|
|
336
|
+
store.close();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
277
340
|
async function runsProducer(name: string, issue: number): Promise<{ runs: RunRecord[] }> {
|
|
278
341
|
const store = openStore(dbPath());
|
|
279
342
|
try {
|
|
@@ -302,8 +365,27 @@ export interface DashboardHttpDeps {
|
|
|
302
365
|
): Promise<{ verb: VerbLedgerEntry[]; turnOverrides: TurnOverride[] }>;
|
|
303
366
|
reports(
|
|
304
367
|
name: string,
|
|
305
|
-
): Promise<{
|
|
368
|
+
): Promise<{
|
|
369
|
+
openReports: ReportRecord[];
|
|
370
|
+
digestBacklog: DigestBacklog;
|
|
371
|
+
heldNotices: HeldNotice[];
|
|
372
|
+
openDecisions: DecisionRecord[];
|
|
373
|
+
}>;
|
|
306
374
|
runs(name: string, issue: number): Promise<{ runs: RunRecord[] }>;
|
|
375
|
+
/** The analytics payload (#297); `undefined` for an unparseable window. */
|
|
376
|
+
stats(name: string, since: string): Promise<StatsReport | undefined>;
|
|
377
|
+
/**
|
|
378
|
+
* The live transcript stream (#296). Returns the SSE response itself rather
|
|
379
|
+
* than data, because the whole point is a connection that stays open — a
|
|
380
|
+
* producer returning a value would have to buffer the run to completion first.
|
|
381
|
+
*/
|
|
382
|
+
transcript?: (name: string, issue: number, req: Request) => Response;
|
|
383
|
+
/**
|
|
384
|
+
* The mutating half (#295). Held as one injected object rather than eight
|
|
385
|
+
* function fields so a test overrides exactly the action it drives, and so the
|
|
386
|
+
* read surface above stays visibly read-only.
|
|
387
|
+
*/
|
|
388
|
+
controls?: Partial<ControlDeps>;
|
|
307
389
|
}
|
|
308
390
|
|
|
309
391
|
/** The deps bound to the real producers — what `startDashboard` serves with. */
|
|
@@ -317,6 +399,7 @@ export function defaultDashboardDeps(token: string): DashboardHttpDeps {
|
|
|
317
399
|
ledger: ledgerProducer,
|
|
318
400
|
reports: reportsProducer,
|
|
319
401
|
runs: runsProducer,
|
|
402
|
+
stats: statsProducer,
|
|
320
403
|
};
|
|
321
404
|
}
|
|
322
405
|
|
|
@@ -341,6 +424,32 @@ function bearerToken(req: Request): string | undefined {
|
|
|
341
424
|
return header.startsWith(prefix) ? header.slice(prefix.length).trim() : undefined;
|
|
342
425
|
}
|
|
343
426
|
|
|
427
|
+
/**
|
|
428
|
+
* The token from `?token=`, accepted on the SSE stream path and nowhere else
|
|
429
|
+
* (#296).
|
|
430
|
+
*
|
|
431
|
+
* `EventSource` is the browser API for Server-Sent Events and it cannot set an
|
|
432
|
+
* `Authorization` header — that is a documented limitation of the API, not
|
|
433
|
+
* something the UI is choosing. The two honest alternatives are a cookie or a
|
|
434
|
+
* query parameter, and a cookie is strictly worse here: it would ride on every
|
|
435
|
+
* request including the unauthenticated static ones, and it would need SameSite
|
|
436
|
+
* and expiry policy this product has no other use for.
|
|
437
|
+
*
|
|
438
|
+
* So the exposure is accepted, narrowed, and stated: a URL can reach a server
|
|
439
|
+
* log or a browser history entry, and this endpoint is the only place that can
|
|
440
|
+
* happen. It is scoped to the exact stream path so no other route can ever be
|
|
441
|
+
* reached with a URL-borne credential, and the dashboard binds loopback by
|
|
442
|
+
* default — the `--host` opt-in already carries its own warning about the token
|
|
443
|
+
* being the only thing between the network and the fleet.
|
|
444
|
+
*/
|
|
445
|
+
function streamQueryToken(url: URL): string | undefined {
|
|
446
|
+
if (!/^\/api\/projects\/[^/]+\/runs\/\d+\/transcript\/stream$/.test(url.pathname)) {
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
const token = url.searchParams.get("token");
|
|
450
|
+
return token === null || token === "" ? undefined : token;
|
|
451
|
+
}
|
|
452
|
+
|
|
344
453
|
/**
|
|
345
454
|
* The ledger's query params. `limit` defaults to 50 — the CLI's own default —
|
|
346
455
|
* so the endpoint and `omp-conductor ledger` cannot disagree. An explicit
|
|
@@ -361,6 +470,109 @@ function parseLedgerParams(url: URL): { issue?: number; limit: number } | null {
|
|
|
361
470
|
return { ...(issue === undefined ? {} : { issue }), limit };
|
|
362
471
|
}
|
|
363
472
|
|
|
473
|
+
/**
|
|
474
|
+
* The configured project a `/api/projects/:name/...` path names, or undefined.
|
|
475
|
+
*
|
|
476
|
+
* One resolver for reads and mutations both: an unknown name is a 404 rather
|
|
477
|
+
* than a producer's throw, and — the part that matters for #295 — an action can
|
|
478
|
+
* never be applied to a project this host does not configure.
|
|
479
|
+
*/
|
|
480
|
+
function projectFromPath(path: string): ProjectConfig | undefined {
|
|
481
|
+
const match = /^\/api\/projects\/([^/]+)(?:\/|$)/.exec(path);
|
|
482
|
+
if (match === null) return undefined;
|
|
483
|
+
let name: string;
|
|
484
|
+
try {
|
|
485
|
+
name = decodeURIComponent(match[1]!);
|
|
486
|
+
} catch {
|
|
487
|
+
return undefined;
|
|
488
|
+
}
|
|
489
|
+
return loadConfig().projects.find((p) => p.name === name);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* The JSON body of a mutating request, or the 400 that says why not.
|
|
494
|
+
*
|
|
495
|
+
* An absent body is `{}` rather than an error: `resume` and `disarm` take no
|
|
496
|
+
* arguments, and requiring an empty object from them would be ceremony. A body
|
|
497
|
+
* that is present and unparseable is always an error — that is a client bug, and
|
|
498
|
+
* treating it as "no arguments" is how a `stop` arrives with no reason.
|
|
499
|
+
*/
|
|
500
|
+
async function mutationBody(req: Request): Promise<unknown | { __malformed: true }> {
|
|
501
|
+
const raw = await req.text();
|
|
502
|
+
if (raw.trim() === "") return {};
|
|
503
|
+
try {
|
|
504
|
+
return JSON.parse(raw) as unknown;
|
|
505
|
+
} catch {
|
|
506
|
+
return { __malformed: true };
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The mutating surface (#295). Every branch delegates to `controls.ts`, which
|
|
512
|
+
* delegates to the CLI's own code or the owning daemon's own endpoint — there is
|
|
513
|
+
* no fleet logic in this function, only routing.
|
|
514
|
+
*/
|
|
515
|
+
async function mutatingResponse(
|
|
516
|
+
req: Request,
|
|
517
|
+
path: string,
|
|
518
|
+
project: ProjectConfig,
|
|
519
|
+
d: DashboardHttpDeps,
|
|
520
|
+
): Promise<Response> {
|
|
521
|
+
const body = await mutationBody(req);
|
|
522
|
+
if (body !== null && typeof body === "object" && Reflect.get(body, "__malformed") === true) {
|
|
523
|
+
return Response.json({ error: "request body must be valid JSON" }, { status: 400 });
|
|
524
|
+
}
|
|
525
|
+
const deps: ControlDeps = { project, ...d.controls };
|
|
526
|
+
const answer = async (): Promise<ControlOutcome | undefined> => {
|
|
527
|
+
const fleet = /^\/api\/projects\/[^/]+\/(pause|resume|hold|disarm)$/.exec(path);
|
|
528
|
+
if (fleet !== null) {
|
|
529
|
+
switch (fleet[1]) {
|
|
530
|
+
case "pause":
|
|
531
|
+
return dashboardPause(body, deps);
|
|
532
|
+
case "resume":
|
|
533
|
+
return dashboardResume(deps);
|
|
534
|
+
case "hold":
|
|
535
|
+
return dashboardHold(body, deps);
|
|
536
|
+
case "disarm":
|
|
537
|
+
return dashboardDisarm(deps);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
const run = /^\/api\/projects\/[^/]+\/runs\/(\d+)\/(extend|unblock)$/.exec(path);
|
|
541
|
+
if (run !== null) {
|
|
542
|
+
const issue = Number.parseInt(run[1]!, 10);
|
|
543
|
+
if (!Number.isSafeInteger(issue)) return undefined;
|
|
544
|
+
return run[2] === "extend"
|
|
545
|
+
? await dashboardExtend(issue, body, deps)
|
|
546
|
+
: await dashboardUnblock(issue, body, deps);
|
|
547
|
+
}
|
|
548
|
+
const worker = /^\/api\/projects\/[^/]+\/runs\/(\d+)\/worker\/(pause|resume|stop)$/.exec(path);
|
|
549
|
+
if (worker !== null) {
|
|
550
|
+
const issue = Number.parseInt(worker[1]!, 10);
|
|
551
|
+
if (!Number.isSafeInteger(issue)) return undefined;
|
|
552
|
+
return await dashboardWorkerControl(
|
|
553
|
+
issue,
|
|
554
|
+
worker[2] as "pause" | "resume" | "stop",
|
|
555
|
+
body,
|
|
556
|
+
deps,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
const decision = /^\/api\/projects\/[^/]+\/decisions\/([^/]+)\/answer$/.exec(path);
|
|
560
|
+
if (decision !== null) {
|
|
561
|
+
let id: string;
|
|
562
|
+
try {
|
|
563
|
+
id = decodeURIComponent(decision[1]!);
|
|
564
|
+
} catch {
|
|
565
|
+
return undefined;
|
|
566
|
+
}
|
|
567
|
+
return dashboardAnswerDecision(id, body, deps);
|
|
568
|
+
}
|
|
569
|
+
return undefined;
|
|
570
|
+
};
|
|
571
|
+
const outcome = await answer();
|
|
572
|
+
if (outcome === undefined) return new Response("", { status: 404 });
|
|
573
|
+
return Response.json(outcome.body, { status: outcome.status });
|
|
574
|
+
}
|
|
575
|
+
|
|
364
576
|
/**
|
|
365
577
|
* The dashboard's whole HTTP surface, separated from the socket exactly like
|
|
366
578
|
* `daemonHttpResponse` so a test can drive it without binding a port. The UI
|
|
@@ -370,58 +582,79 @@ export async function dashboardResponse(req: Request, d: DashboardHttpDeps): Pro
|
|
|
370
582
|
const url = new URL(req.url);
|
|
371
583
|
const path = url.pathname;
|
|
372
584
|
if (path.startsWith("/api/")) {
|
|
373
|
-
const presented = bearerToken(req);
|
|
585
|
+
const presented = bearerToken(req) ?? streamQueryToken(url);
|
|
374
586
|
if (presented === undefined || !verifyDashboardToken(presented, d.token)) {
|
|
375
587
|
// Bare 401: no body, no scheme hint — nothing about the token leaks.
|
|
376
588
|
return new Response("", { status: 401 });
|
|
377
589
|
}
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
if (req.method !== "GET"
|
|
590
|
+
// Two methods now (#295): GET reads, POST mutates. Anything else stays 404
|
|
591
|
+
// — after the auth check, so the surface cannot be probed unauthenticated.
|
|
592
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
593
|
+
return new Response("", { status: 404 });
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// The mutating routes first (#295), so a read route can never answer a POST.
|
|
597
|
+
// Reads are then explicitly GET-gated below: without that, `POST .../board`
|
|
598
|
+
// would have fallen through and returned the board, which is a mutating
|
|
599
|
+
// method quietly served by a read handler.
|
|
600
|
+
const project = projectFromPath(path);
|
|
601
|
+
if (req.method === "POST") {
|
|
602
|
+
if (project === undefined) return new Response("", { status: 404 });
|
|
603
|
+
return mutatingResponse(req, path, project, d);
|
|
604
|
+
}
|
|
381
605
|
|
|
382
606
|
if (path === "/api/projects") return Response.json(await d.projects());
|
|
383
607
|
if (path === "/api/overview") return Response.json(await d.overview());
|
|
384
608
|
|
|
385
|
-
|
|
386
|
-
// `/api/projects/:name/runs/:issue`. Project names are matched against the
|
|
387
|
-
// configured set so an unknown name is a 404, never the producer's throw.
|
|
388
|
-
const name = (m: RegExpExecArray): string | undefined => {
|
|
389
|
-
try {
|
|
390
|
-
return decodeURIComponent(m[1]!);
|
|
391
|
-
} catch {
|
|
392
|
-
return undefined;
|
|
393
|
-
}
|
|
394
|
-
};
|
|
395
|
-
const known = (project: string | undefined): project is string =>
|
|
396
|
-
project !== undefined && loadConfig().projects.some((p) => p.name === project);
|
|
397
|
-
|
|
398
|
-
const proj = /^\/api\/projects\/([^/]+)\/(board|status|ledger|reports)$/.exec(path);
|
|
609
|
+
const proj = /^\/api\/projects\/([^/]+)\/(board|status|ledger|reports|stats)$/.exec(path);
|
|
399
610
|
if (proj !== null) {
|
|
400
|
-
|
|
401
|
-
if (!known(project)) return new Response("", { status: 404 });
|
|
611
|
+
if (project === undefined) return new Response("", { status: 404 });
|
|
402
612
|
switch (proj[2]) {
|
|
403
613
|
case "board":
|
|
404
|
-
return Response.json(await d.board(project
|
|
614
|
+
return Response.json(await d.board(project.name));
|
|
405
615
|
case "status":
|
|
406
|
-
return Response.json(await d.status(project
|
|
616
|
+
return Response.json(await d.status(project.name));
|
|
407
617
|
case "ledger": {
|
|
408
618
|
const opts = parseLedgerParams(url);
|
|
409
619
|
if (opts === null) return new Response("", { status: 400 });
|
|
410
|
-
return Response.json(await d.ledger(project
|
|
620
|
+
return Response.json(await d.ledger(project.name, opts));
|
|
411
621
|
}
|
|
412
622
|
case "reports":
|
|
413
|
-
return Response.json(await d.reports(project
|
|
623
|
+
return Response.json(await d.reports(project.name));
|
|
624
|
+
case "stats": {
|
|
625
|
+
// `7d` is the CLI's own default, so an unqualified page and an
|
|
626
|
+
// unqualified `stats` describe the same window.
|
|
627
|
+
const report = await d.stats(project.name, url.searchParams.get("since") ?? "7d");
|
|
628
|
+
if (report === undefined) {
|
|
629
|
+
return Response.json(
|
|
630
|
+
{ error: "since must be 7d, 30d, any Nd, or YYYY-MM-DD" },
|
|
631
|
+
{ status: 400 },
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
return Response.json(report);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// The transcript stream (#296) is matched before the run-detail route so
|
|
640
|
+
// `/runs/42/transcript/stream` cannot be read as an issue named
|
|
641
|
+
// "42/transcript".
|
|
642
|
+
const stream = /^\/api\/projects\/([^/]+)\/runs\/(\d+)\/transcript\/stream$/.exec(path);
|
|
643
|
+
if (stream !== null) {
|
|
644
|
+
const issue = Number.parseInt(stream[2]!, 10);
|
|
645
|
+
if (project === undefined || !Number.isSafeInteger(issue)) {
|
|
646
|
+
return new Response("", { status: 404 });
|
|
414
647
|
}
|
|
648
|
+
return (d.transcript ?? transcriptStreamResponse)(project.name, issue, req);
|
|
415
649
|
}
|
|
416
650
|
|
|
417
651
|
const runs = /^\/api\/projects\/([^/]+)\/runs\/(\d+)$/.exec(path);
|
|
418
652
|
if (runs !== null) {
|
|
419
|
-
const project = name(runs);
|
|
420
653
|
const issue = Number.parseInt(runs[2]!, 10);
|
|
421
|
-
if (
|
|
654
|
+
if (project === undefined || !Number.isSafeInteger(issue)) {
|
|
422
655
|
return new Response("", { status: 404 });
|
|
423
656
|
}
|
|
424
|
-
return Response.json(await d.runs(project
|
|
657
|
+
return Response.json(await d.runs(project.name, issue));
|
|
425
658
|
}
|
|
426
659
|
|
|
427
660
|
return new Response("", { status: 404 });
|
|
@@ -461,6 +694,14 @@ export async function startDashboard(opts: DashboardStartOpts = {}): Promise<voi
|
|
|
461
694
|
const server = Bun.serve({
|
|
462
695
|
hostname: host,
|
|
463
696
|
port: opts.port ?? DASHBOARD_PORT,
|
|
697
|
+
// Long-lived by necessity (#296). `Bun.serve` defaults to a 10s idle
|
|
698
|
+
// timeout, which closes a transcript stream mid-run — a quiet worker is
|
|
699
|
+
// exactly the case where watching matters most, and no keepalive interval
|
|
700
|
+
// short enough to beat that default would be sane. Disabled here rather
|
|
701
|
+
// than tuned, because the streams are the only long connections this server
|
|
702
|
+
// has and each one already ends on its own: the follow returns when the run
|
|
703
|
+
// goes terminal, and the client vanishing aborts it.
|
|
704
|
+
idleTimeout: 0,
|
|
464
705
|
fetch: (req) => dashboardResponse(req, defaultDashboardDeps(token)),
|
|
465
706
|
});
|
|
466
707
|
// An IPv6 literal needs brackets to be a clickable URL.
|
package/src/dashboard/style.css
CHANGED
|
@@ -346,3 +346,119 @@ ul.reports li {
|
|
|
346
346
|
--down: #ff8a80;
|
|
347
347
|
}
|
|
348
348
|
}
|
|
349
|
+
|
|
350
|
+
/* The control surface (#295). Destructive actions are visually distinct because
|
|
351
|
+
the confirm dialog is the second line of defence, not the first. */
|
|
352
|
+
.controls {
|
|
353
|
+
display: flex;
|
|
354
|
+
flex-wrap: wrap;
|
|
355
|
+
gap: 0.5rem;
|
|
356
|
+
align-items: center;
|
|
357
|
+
margin: 0.75rem 0;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.controls button {
|
|
361
|
+
background: #1b1f27;
|
|
362
|
+
color: #d7dae0;
|
|
363
|
+
border: 1px solid #2f3542;
|
|
364
|
+
border-radius: 4px;
|
|
365
|
+
padding: 0.35rem 0.7rem;
|
|
366
|
+
font: inherit;
|
|
367
|
+
cursor: pointer;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.controls button:hover {
|
|
371
|
+
border-color: #4a5568;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.controls button.destructive {
|
|
375
|
+
border-color: #6b2f2f;
|
|
376
|
+
color: #e8b4b4;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.controls button.destructive:hover {
|
|
380
|
+
background: #2a1b1b;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.controls #control-result,
|
|
384
|
+
.controls > p.state {
|
|
385
|
+
flex-basis: 100%;
|
|
386
|
+
margin: 0.25rem 0 0;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/* Live transcript (#296). Fixed height with its own scroll so a long run does
|
|
390
|
+
not push the run controls off the page. */
|
|
391
|
+
.transcript-head {
|
|
392
|
+
display: flex;
|
|
393
|
+
gap: 0.75rem;
|
|
394
|
+
align-items: center;
|
|
395
|
+
flex-wrap: wrap;
|
|
396
|
+
margin: 0.75rem 0 0.25rem;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
.transcript-head label {
|
|
400
|
+
font-size: 0.85rem;
|
|
401
|
+
color: #9aa0aa;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.transcript {
|
|
405
|
+
max-height: 26rem;
|
|
406
|
+
overflow-y: auto;
|
|
407
|
+
margin: 0;
|
|
408
|
+
padding: 0.6rem 0.75rem;
|
|
409
|
+
background: #0f1115;
|
|
410
|
+
border: 1px solid #2f3542;
|
|
411
|
+
border-radius: 4px;
|
|
412
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
413
|
+
font-size: 0.8rem;
|
|
414
|
+
line-height: 1.45;
|
|
415
|
+
white-space: pre-wrap;
|
|
416
|
+
word-break: break-word;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/* Analytics (#297). Hand-rolled SVG, per the epic's no-dependency constraint. */
|
|
420
|
+
.chart {
|
|
421
|
+
margin: 1rem 0;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
.chart h4 {
|
|
425
|
+
margin: 0 0 0.4rem;
|
|
426
|
+
font-size: 0.9rem;
|
|
427
|
+
font-weight: 600;
|
|
428
|
+
color: #c8ccd4;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
.chart .bars {
|
|
432
|
+
width: 100%;
|
|
433
|
+
max-width: 40rem;
|
|
434
|
+
height: auto;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
.chart .bar {
|
|
438
|
+
fill: #3d6ea8;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
.chart .bar-label,
|
|
442
|
+
.chart .bar-value {
|
|
443
|
+
fill: #9aa0aa;
|
|
444
|
+
font-size: 11px;
|
|
445
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
.stat {
|
|
449
|
+
display: flex;
|
|
450
|
+
justify-content: space-between;
|
|
451
|
+
gap: 1rem;
|
|
452
|
+
padding: 0.2rem 0;
|
|
453
|
+
border-bottom: 1px solid #1e222a;
|
|
454
|
+
font-size: 0.85rem;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
.stat-label {
|
|
458
|
+
color: #9aa0aa;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
.stat-value {
|
|
462
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
463
|
+
color: #d7dae0;
|
|
464
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live transcript streaming for the dashboard (epic #292, slice 4/5 — #296).
|
|
3
|
+
*
|
|
4
|
+
* The run detail view could show what a run *did*; this makes it possible to
|
|
5
|
+
* watch one happening, which is the difference between reading a postmortem and
|
|
6
|
+
* noticing a worker is stuck at turn eight.
|
|
7
|
+
*
|
|
8
|
+
* ## It follows the file, not the daemon
|
|
9
|
+
*
|
|
10
|
+
* `tailRun` is the follow — the same function `omp-conductor tail` runs, with
|
|
11
|
+
* the same 1s re-stat cadence, the same read-from-byte-zero behaviour (attaching
|
|
12
|
+
* to a run ten turns in and showing nothing until turn eleven is not watching
|
|
13
|
+
* it), and the same advisor-transcript discovery. This module only turns its
|
|
14
|
+
* lines into Server-Sent Events.
|
|
15
|
+
*
|
|
16
|
+
* That is what makes the acceptance criterion "neither blocks the daemon" true by
|
|
17
|
+
* construction rather than by measurement: nothing here talks to the daemon at
|
|
18
|
+
* all. Two browsers on one run are two read-only file follows, and the daemon
|
|
19
|
+
* does not know either of them exists.
|
|
20
|
+
*
|
|
21
|
+
* ## Why SSE and not a websocket
|
|
22
|
+
*
|
|
23
|
+
* The data is one-directional, text, and append-only, which is exactly what SSE
|
|
24
|
+
* is. A websocket would add a handshake, a framing layer and a dependency for a
|
|
25
|
+
* feature whose whole payload is lines of text — and the epic's constraint is no
|
|
26
|
+
* runtime dependencies. `EventSource` is in every browser and reconnects on its
|
|
27
|
+
* own.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { tailRun } from "../commands/tail.ts";
|
|
31
|
+
import type { Store } from "../types.ts";
|
|
32
|
+
|
|
33
|
+
/** How often a comment frame is written when nothing else is. */
|
|
34
|
+
export const KEEPALIVE_MS = 15_000;
|
|
35
|
+
|
|
36
|
+
/** Injected so a test drives the whole stream with no file and no clock. */
|
|
37
|
+
export interface TranscriptStreamDeps {
|
|
38
|
+
/**
|
|
39
|
+
* The follow itself. Defaults to `tailRun`, and a test replaces it — the point
|
|
40
|
+
* of the seam is that this module owns the *framing*, not the following.
|
|
41
|
+
*/
|
|
42
|
+
follow?: (
|
|
43
|
+
project: string,
|
|
44
|
+
issue: number,
|
|
45
|
+
opts: { write: (line: string) => void; signal: AbortSignal; store?: Store },
|
|
46
|
+
) => Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* A store for the follow to read the run row through. Optional, and NOT closed
|
|
49
|
+
* here: `tailRun` closes whatever store it is given, so opening one in this
|
|
50
|
+
* module would double-close it. Production passes none and the follow opens
|
|
51
|
+
* (and closes) its own, exactly as `omp-conductor tail` does.
|
|
52
|
+
*/
|
|
53
|
+
store?: Store;
|
|
54
|
+
keepaliveMs?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One SSE frame.
|
|
59
|
+
*
|
|
60
|
+
* Exported because the framing is the contract a browser parses, and a test that
|
|
61
|
+
* asserts on assembled frames is asserting on the thing `EventSource` sees. Data
|
|
62
|
+
* is split per line so a payload containing a newline cannot terminate the frame
|
|
63
|
+
* early — the one way a text stream corrupts an SSE connection.
|
|
64
|
+
*/
|
|
65
|
+
export function sseFrame(event: string | undefined, data: string): string {
|
|
66
|
+
const lines = data.split("\n").map((line) => `data: ${line}`);
|
|
67
|
+
return `${event === undefined ? "" : `event: ${event}\n`}${lines.join("\n")}\n\n`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Stream one run's transcript as Server-Sent Events.
|
|
72
|
+
*
|
|
73
|
+
* Three terminal shapes, and each is said out loud rather than inferred from a
|
|
74
|
+
* closed connection:
|
|
75
|
+
*
|
|
76
|
+
* - **`error`** — there is no transcript to follow (no run, or a claimed run
|
|
77
|
+
* whose session never opened one). `tail` throws in exactly that case, and
|
|
78
|
+
* the message is its message. A silent empty stream would read as "a quiet
|
|
79
|
+
* worker", which is the wrong conclusion to invite.
|
|
80
|
+
* - **`end`** — the follow returned, which `tailRun` does once the run row has
|
|
81
|
+
* left the live states and stayed quiet. Its last written line is
|
|
82
|
+
* `run ended: <state>`, so the terminal state is already in the stream.
|
|
83
|
+
* - **the client vanishing** — the request's signal aborts the follow, which is
|
|
84
|
+
* why `tailRun` takes one: a closed tab must not leave a poll loop holding
|
|
85
|
+
* file descriptors until the run happens to finish.
|
|
86
|
+
*/
|
|
87
|
+
export function transcriptStreamResponse(
|
|
88
|
+
project: string,
|
|
89
|
+
issue: number,
|
|
90
|
+
req: Request,
|
|
91
|
+
deps: TranscriptStreamDeps = {},
|
|
92
|
+
): Response {
|
|
93
|
+
const follow = deps.follow ?? defaultFollow;
|
|
94
|
+
const keepaliveMs = deps.keepaliveMs ?? KEEPALIVE_MS;
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
// Both directions matter: the client going away aborts the follow, and the
|
|
97
|
+
// follow finishing means nobody is waiting on the client any more.
|
|
98
|
+
req.signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
99
|
+
|
|
100
|
+
const encoder = new TextEncoder();
|
|
101
|
+
const body = new ReadableStream<Uint8Array>({
|
|
102
|
+
start(sink) {
|
|
103
|
+
let closed = false;
|
|
104
|
+
const send = (frame: string): void => {
|
|
105
|
+
if (closed) return;
|
|
106
|
+
try {
|
|
107
|
+
sink.enqueue(encoder.encode(frame));
|
|
108
|
+
} catch {
|
|
109
|
+
// The client hung up between our abort check and this write. Stop
|
|
110
|
+
// following rather than throwing inside a stream nobody reads.
|
|
111
|
+
closed = true;
|
|
112
|
+
controller.abort();
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
// A comment frame — `EventSource` ignores it, proxies and load balancers
|
|
116
|
+
// do not, which is the whole reason it exists.
|
|
117
|
+
const keepalive = setInterval(() => send(": keepalive\n\n"), keepaliveMs);
|
|
118
|
+
const finish = (): void => {
|
|
119
|
+
if (closed) return;
|
|
120
|
+
closed = true;
|
|
121
|
+
clearInterval(keepalive);
|
|
122
|
+
try {
|
|
123
|
+
sink.close();
|
|
124
|
+
} catch {
|
|
125
|
+
// Already closed by the client; nothing to do.
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
void (async () => {
|
|
130
|
+
try {
|
|
131
|
+
await follow(project, issue, {
|
|
132
|
+
write: (line) => send(sseFrame(undefined, line)),
|
|
133
|
+
signal: controller.signal,
|
|
134
|
+
...(deps.store === undefined ? {} : { store: deps.store }),
|
|
135
|
+
});
|
|
136
|
+
if (!controller.signal.aborted) send(sseFrame("end", "stream closed"));
|
|
137
|
+
} catch (err) {
|
|
138
|
+
// `tail`'s own wording: "no run recorded for #N", or
|
|
139
|
+
// "no transcript yet (state: claimed)". Both are answers, not faults.
|
|
140
|
+
send(sseFrame("error", err instanceof Error ? err.message : String(err)));
|
|
141
|
+
} finally {
|
|
142
|
+
finish();
|
|
143
|
+
}
|
|
144
|
+
})();
|
|
145
|
+
},
|
|
146
|
+
cancel() {
|
|
147
|
+
controller.abort();
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return new Response(body, {
|
|
152
|
+
headers: {
|
|
153
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
154
|
+
// No buffering anywhere in between, or the 1s cadence becomes minutes.
|
|
155
|
+
"cache-control": "no-cache, no-transform",
|
|
156
|
+
connection: "keep-alive",
|
|
157
|
+
"x-accel-buffering": "no",
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** `tailRun` with the stream's store, so one connection opens one reader. */
|
|
163
|
+
async function defaultFollow(
|
|
164
|
+
project: string,
|
|
165
|
+
issue: number,
|
|
166
|
+
opts: { write: (line: string) => void; signal: AbortSignal; store?: Store },
|
|
167
|
+
): Promise<void> {
|
|
168
|
+
await tailRun(project, issue, {
|
|
169
|
+
write: opts.write,
|
|
170
|
+
signal: opts.signal,
|
|
171
|
+
...(opts.store === undefined ? {} : { store: opts.store }),
|
|
172
|
+
});
|
|
173
|
+
}
|