agent-horde 0.1.6 → 0.2.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 +22 -3
- package/docs/REFERENCE.md +86 -11
- package/package.json +1 -1
- package/src/args.ts +107 -47
- package/src/dashboard-data.ts +480 -49
- package/src/dashboard-html.ts +379 -92
- package/src/dashboard.ts +205 -96
- package/src/ledger/files.ts +6 -1
- package/src/member.ts +47 -0
- package/src/prompt.ts +29 -5
- package/src/run/command.ts +68 -37
- package/src/run/completions.ts +26 -14
- package/src/run/controls.ts +74 -15
- package/src/run/finalize.ts +24 -14
- package/src/run/launch.ts +179 -34
- package/src/run/setup.ts +35 -25
- package/src/run/supervise.ts +3 -1
- package/src/run/swarm-finalize.ts +121 -0
- package/src/run/swarm-report.ts +152 -0
- package/src/run/swarm.ts +156 -0
- package/src/run/synthesis.ts +141 -20
- package/src/run/types.ts +64 -5
- package/src/run/ui.ts +73 -28
- package/src/run-graph.ts +7 -7
- package/src/swarm-graph.ts +127 -0
- package/src/tool/actions.ts +61 -32
- package/src/tool/register.ts +3 -1
- package/src/widget.ts +80 -8
package/src/dashboard-data.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { MAX_MEMBERS } from "./args.js";
|
|
4
4
|
import { resourceCitingMembers } from "./citations.js";
|
|
5
|
+
import { formatDateTime } from "./format.js";
|
|
5
6
|
import { loadCollaborationReport } from "./collaboration-report.js";
|
|
6
7
|
import type { CollaborationReport } from "./collaboration-report.js";
|
|
7
8
|
import { readEvents } from "./ledger/events.js";
|
|
@@ -22,6 +23,7 @@ import type { Idea } from "./ledger/ideas.js";
|
|
|
22
23
|
import { listResources } from "./ledger/resources.js";
|
|
23
24
|
import type { ResourceEntry } from "./ledger/resources.js";
|
|
24
25
|
import { buildRunGraph } from "./run-graph.js";
|
|
26
|
+
import { buildSwarmGraph } from "./swarm-graph.js";
|
|
25
27
|
import type { RunGraph } from "./run-graph.js";
|
|
26
28
|
import { readCleanStop } from "./ledger/run-signals.js";
|
|
27
29
|
import { memberSessionDir, parseMemberLog } from "./member.js";
|
|
@@ -50,13 +52,40 @@ interface LiveRun extends RunSummaryInput {
|
|
|
50
52
|
dashboardUrl?: string | undefined;
|
|
51
53
|
/** Synthesis steps still running after the member slot was freed. */
|
|
52
54
|
finalizing?: string[] | undefined;
|
|
55
|
+
/** Position in the swarm and the swarm's size; a plain run is horde 1 of 1. */
|
|
56
|
+
horde?: number | undefined;
|
|
57
|
+
hordes?: number | undefined;
|
|
58
|
+
slice?: string | undefined;
|
|
59
|
+
outcome?: string | undefined;
|
|
60
|
+
reported?: boolean | undefined;
|
|
61
|
+
finalized?: boolean | undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The in-memory swarm the parent exposes; `Swarm` satisfies it. */
|
|
65
|
+
export interface LiveSwarm {
|
|
66
|
+
id: string;
|
|
67
|
+
dir: string | undefined;
|
|
68
|
+
hordes: number;
|
|
69
|
+
count: number;
|
|
70
|
+
prompt: string;
|
|
71
|
+
startedAt: number;
|
|
72
|
+
runs: LiveRun[];
|
|
73
|
+
partition?: string[] | undefined;
|
|
74
|
+
untilStop?: boolean | undefined;
|
|
75
|
+
/** The swarm step still running after every horde finalized. */
|
|
76
|
+
finalizing?: string[] | undefined;
|
|
77
|
+
deliverable?: string | undefined;
|
|
53
78
|
}
|
|
54
79
|
|
|
55
80
|
export interface DashboardSource {
|
|
81
|
+
/** The project's `.horde`; a swarm's hordes live under its `swarms/<id>/h<N>/`. */
|
|
56
82
|
hordeDir: string;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
83
|
+
/** Runs whose members are live or whose report is not yet written. */
|
|
84
|
+
getActive(): LiveRun[];
|
|
85
|
+
/** Runs still writing DELIVERABLE.md / POSTMORTEM.md. */
|
|
86
|
+
getFinalizing?(): LiveRun[];
|
|
87
|
+
/** The swarm those runs belong to, while it is in memory. */
|
|
88
|
+
getSwarm?(): LiveSwarm | undefined;
|
|
60
89
|
now?: () => number;
|
|
61
90
|
}
|
|
62
91
|
|
|
@@ -68,8 +97,41 @@ export class DashboardError extends Error {
|
|
|
68
97
|
}
|
|
69
98
|
}
|
|
70
99
|
|
|
71
|
-
export const
|
|
100
|
+
export const SWARM_ID_RE = /^\d{8}-\d{6}(?:-\d+)?$/;
|
|
101
|
+
/** A plain run id, or a swarm run id `<swarm>-h<N>`. */
|
|
102
|
+
export const RUN_ID_RE = /^\d{8}-\d{6}(?:-\d+)?(?:-h\d{1,2})?$/;
|
|
103
|
+
const SWARM_RUN_RE = /^(\d{8}-\d{6}(?:-\d+)?)-h(\d{1,2})$/;
|
|
72
104
|
export const MEMBER_RE = /^member-(\d{1,2})$/;
|
|
105
|
+
export const SWARM_FILES = ["SWARM", "REPORT", "DELIVERABLE"] as const;
|
|
106
|
+
type SwarmFile = (typeof SWARM_FILES)[number];
|
|
107
|
+
|
|
108
|
+
/** The swarm a run id belongs to, from its `-h<N>` suffix; undefined for a plain run. */
|
|
109
|
+
export function swarmOfRun(runId: string): { swarmId: string; horde: number } | undefined {
|
|
110
|
+
const match = SWARM_RUN_RE.exec(runId);
|
|
111
|
+
return match ? { swarmId: match[1]!, horde: Number(match[2]) } : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function validSwarmId(swarmId: string): string {
|
|
115
|
+
if (!SWARM_ID_RE.test(swarmId)) throw new DashboardError(400, `invalid swarm id: ${swarmId}`);
|
|
116
|
+
return swarmId;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function swarmDir(src: DashboardSource, swarmId: string): string {
|
|
120
|
+
return path.join(src.hordeDir, "swarms", swarmId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The horde directory a run's ledgers live in: the project's `.horde`, or the horde's own under `swarms/`. */
|
|
124
|
+
export function ledgerDir(src: DashboardSource, runId: string): string {
|
|
125
|
+
const live = activeIf(src, runId) ?? finalizingIf(src, runId);
|
|
126
|
+
if (live?.hordeDir) return live.hordeDir;
|
|
127
|
+
const swarm = swarmOfRun(runId);
|
|
128
|
+
return swarm ? path.join(swarmDir(src, swarm.swarmId), `h${swarm.horde}`) : src.hordeDir;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The ledgers a page filtered to `run` reads: that horde's for a swarm run, else the project's. */
|
|
132
|
+
function ledgerDirForFilter(src: DashboardSource, run: string | undefined): string {
|
|
133
|
+
return run === undefined || run === "all" ? src.hordeDir : ledgerDir(src, run);
|
|
134
|
+
}
|
|
73
135
|
const SESSION_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.jsonl$/;
|
|
74
136
|
export const RUN_FILES = ["TASK", "SYSTEM", "PLAN", "SUMMARY", "REPORT", "DELIVERABLE", "POSTMORTEM", "ANSWER", "ACCEPTANCE", "COLLABORATION", "GRAPH"] as const;
|
|
75
137
|
type RunFile = (typeof RUN_FILES)[number];
|
|
@@ -114,9 +176,10 @@ export interface CollaborationVm {
|
|
|
114
176
|
|
|
115
177
|
export function loadCollaboration(src: DashboardSource, runId: string, from = 0): CollaborationVm {
|
|
116
178
|
const runDir = resolveRunDir(src, runId);
|
|
179
|
+
const ledgers = ledgerDir(src, runId);
|
|
117
180
|
const frozen = path.join(runDir, "ACCEPTANCE.json");
|
|
118
|
-
const report = fs.existsSync(frozen) ? JSON.parse(fs.readFileSync(frozen, "utf8")).report as CollaborationReport : loadCollaborationReport(
|
|
119
|
-
const all = readEvents(
|
|
181
|
+
const report = fs.existsSync(frozen) ? JSON.parse(fs.readFileSync(frozen, "utf8")).report as CollaborationReport : loadCollaborationReport(ledgers, runId);
|
|
182
|
+
const all = readEvents(ledgers, runId);
|
|
120
183
|
return { report, events: all.slice(from, from + 100), next: all.length > from + 100 ? from + 100 : undefined, historical: all.length === 0 };
|
|
121
184
|
}
|
|
122
185
|
|
|
@@ -147,7 +210,7 @@ function taskStartedAt(runDir: string): number | undefined {
|
|
|
147
210
|
export function loadRunGraph(src: DashboardSource, runId: string, view: GraphView): RunGraphVm {
|
|
148
211
|
const runDir = runDirFor(src, runId);
|
|
149
212
|
const live = activeIf(src, runId) ?? finalizingIf(src, runId);
|
|
150
|
-
const events = readEvents(src
|
|
213
|
+
const events = readEvents(ledgerDir(src, runId), runId);
|
|
151
214
|
const members = live
|
|
152
215
|
? live.members.map((m) => ({ name: m.name, status: m.status }))
|
|
153
216
|
: memberNamesOnDisk(runDir).map((name) => ({
|
|
@@ -169,19 +232,17 @@ function nowOf(src: DashboardSource): number {
|
|
|
169
232
|
}
|
|
170
233
|
|
|
171
234
|
function activeIf(src: DashboardSource, runId: string): LiveRun | undefined {
|
|
172
|
-
|
|
173
|
-
return active && active.id === runId ? active : undefined;
|
|
235
|
+
return src.getActive().find((run) => run.id === runId);
|
|
174
236
|
}
|
|
175
237
|
|
|
176
238
|
function finalizingIf(src: DashboardSource, runId: string): LiveRun | undefined {
|
|
177
|
-
|
|
178
|
-
return run && run.id === runId ? run : undefined;
|
|
239
|
+
return src.getFinalizing?.().find((run) => run.id === runId);
|
|
179
240
|
}
|
|
180
241
|
|
|
181
242
|
/** Path of an existing run dir; the id is validated and the real path must stay under runs/. */
|
|
182
243
|
function resolveRunDir(src: DashboardSource, runId: string): string {
|
|
183
244
|
validRunId(runId);
|
|
184
|
-
const runs = hordePaths(src
|
|
245
|
+
const runs = hordePaths(ledgerDir(src, runId)).runs;
|
|
185
246
|
const dir = path.join(runs, runId);
|
|
186
247
|
if (!fs.existsSync(dir)) throw new DashboardError(404, `no run ${runId}`);
|
|
187
248
|
if (!fs.realpathSync(dir).startsWith(fs.realpathSync(runs) + path.sep)) {
|
|
@@ -211,6 +272,23 @@ function diskOutcome(runDir: string): string | undefined {
|
|
|
211
272
|
return fs.existsSync(path.join(runDir, "SUMMARY.md")) ? "finished" : undefined;
|
|
212
273
|
}
|
|
213
274
|
|
|
275
|
+
/**
|
|
276
|
+
* What a run is doing. `pending`: its directory is allocated but TASK.md is not written yet, as
|
|
277
|
+
* for a swarm's hordes while the planner partitions the task. `finished`: not in memory and past
|
|
278
|
+
* launch, whatever its REPORT.md says.
|
|
279
|
+
*/
|
|
280
|
+
export type RunState = "active" | "finalizing" | "pending" | "finished";
|
|
281
|
+
|
|
282
|
+
function runStateOnDisk(runDir: string): "pending" | "finished" {
|
|
283
|
+
return fs.existsSync(path.join(runDir, "TASK.md")) ? "finished" : "pending";
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function runStateOf(live: LiveRun | undefined, fin: LiveRun | undefined, runDir: string): RunState {
|
|
287
|
+
if (live) return "active";
|
|
288
|
+
if (fin) return "finalizing";
|
|
289
|
+
return runStateOnDisk(runDir);
|
|
290
|
+
}
|
|
291
|
+
|
|
214
292
|
// ---------------------------------------------------------------------------
|
|
215
293
|
// Run index
|
|
216
294
|
// ---------------------------------------------------------------------------
|
|
@@ -225,44 +303,162 @@ interface RunIndexRow {
|
|
|
225
303
|
outcome?: string | undefined;
|
|
226
304
|
prompt: string;
|
|
227
305
|
files: Record<RunFile, boolean>;
|
|
306
|
+
/** The swarm this run is one horde of, when it is. */
|
|
307
|
+
swarm?: string | undefined;
|
|
308
|
+
state: RunState;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** A swarm's state adds `not started`: the planner could not partition the task. */
|
|
312
|
+
export type SwarmState = RunState | "not started";
|
|
313
|
+
|
|
314
|
+
interface SwarmIndexRow {
|
|
315
|
+
id: string;
|
|
316
|
+
active: boolean;
|
|
317
|
+
finalizing: boolean;
|
|
318
|
+
state: SwarmState;
|
|
319
|
+
started?: string | undefined;
|
|
320
|
+
hordes: number;
|
|
321
|
+
count?: number | undefined;
|
|
322
|
+
prompt: string;
|
|
323
|
+
/** Per-horde outcomes, `h1` first. */
|
|
324
|
+
outcomes: string[];
|
|
325
|
+
files: Record<SwarmFile, boolean>;
|
|
228
326
|
}
|
|
229
327
|
|
|
230
328
|
export interface RunIndexVm {
|
|
231
329
|
activeId?: string | undefined;
|
|
232
330
|
runs: RunIndexRow[];
|
|
331
|
+
swarms: SwarmIndexRow[];
|
|
233
332
|
}
|
|
234
333
|
|
|
235
334
|
function runIndexRow(src: DashboardSource, runId: string): RunIndexRow {
|
|
236
|
-
const runDir = path.join(hordePaths(src
|
|
335
|
+
const runDir = path.join(hordePaths(ledgerDir(src, runId)).runs, runId);
|
|
237
336
|
const task = parseTaskMarkdown(readOptional(path.join(runDir, "TASK.md")) ?? "");
|
|
238
337
|
const live = activeIf(src, runId);
|
|
239
338
|
const fin = live ? undefined : finalizingIf(src, runId);
|
|
339
|
+
const state = runStateOf(live, fin, runDir);
|
|
240
340
|
return {
|
|
241
341
|
id: runId,
|
|
242
342
|
active: live !== undefined,
|
|
243
343
|
finalizing: fin !== undefined,
|
|
344
|
+
state,
|
|
244
345
|
started: task.started,
|
|
245
346
|
count: live?.count ?? task.count,
|
|
246
347
|
model: live?.model ?? task.model,
|
|
247
|
-
outcome: live ? liveOutcome(live) : fin ? "synthesizing" : diskOutcome(runDir),
|
|
348
|
+
outcome: live ? liveOutcome(live) : fin ? "synthesizing" : state === "pending" ? "pending" : diskOutcome(runDir),
|
|
248
349
|
prompt: live?.prompt ?? task.prompt,
|
|
249
350
|
files: runFilePresence(runDir),
|
|
351
|
+
swarm: swarmOfRun(runId)?.swarmId,
|
|
250
352
|
};
|
|
251
353
|
}
|
|
252
354
|
|
|
253
|
-
|
|
355
|
+
function listDir(dir: string, accept: RegExp): string[] {
|
|
356
|
+
return fs.existsSync(dir) ? fs.readdirSync(dir).filter((name) => accept.test(name)).sort() : [];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Swarm ids under swarms/, newest first. */
|
|
360
|
+
function swarmIdsOnDisk(src: DashboardSource): string[] {
|
|
361
|
+
return listDir(path.join(src.hordeDir, "swarms"), SWARM_ID_RE).reverse();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Run ids under runs/ and under every swarm's horde directories, newest first. */
|
|
254
365
|
function runIdsOnDisk(src: DashboardSource): string[] {
|
|
255
|
-
const
|
|
256
|
-
|
|
366
|
+
const own = listDir(hordePaths(src.hordeDir).runs, RUN_ID_RE);
|
|
367
|
+
const swarmRuns = swarmIdsOnDisk(src).flatMap((swarmId) =>
|
|
368
|
+
listDir(swarmDir(src, swarmId), /^h\d{1,2}$/).flatMap((horde) =>
|
|
369
|
+
listDir(hordePaths(path.join(swarmDir(src, swarmId), horde)).runs, RUN_ID_RE)));
|
|
370
|
+
return [...new Set([...own, ...swarmRuns])].sort().reverse();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function swarmFilePresence(dir: string): Record<SwarmFile, boolean> {
|
|
374
|
+
const files = {} as Record<SwarmFile, boolean>;
|
|
375
|
+
for (const name of SWARM_FILES) files[name] = fs.existsSync(path.join(dir, `${name}.md`));
|
|
376
|
+
return files;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** A swarm's `SWARM.json`, as the parent wrote it; absent for a swarm that never started. */
|
|
380
|
+
interface SwarmRecordOnDisk {
|
|
381
|
+
prompt?: string;
|
|
382
|
+
hordes?: number;
|
|
383
|
+
count?: number;
|
|
384
|
+
startedAt?: number;
|
|
385
|
+
untilStop?: boolean;
|
|
386
|
+
partition?: string[];
|
|
387
|
+
runs?: Array<{ label?: string; runId?: string; startedAt?: number; endedAt?: number; outcome?: string }>;
|
|
388
|
+
merge?: { startedAt?: number; endedAt?: number; written?: boolean };
|
|
389
|
+
endedAt?: number;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function readSwarmRecord(dir: string): SwarmRecordOnDisk | undefined {
|
|
393
|
+
const raw = readOptional(path.join(dir, "SWARM.json"));
|
|
394
|
+
if (raw === undefined) return undefined;
|
|
395
|
+
try {
|
|
396
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
397
|
+
return typeof parsed === "object" && parsed !== null ? (parsed as SwarmRecordOnDisk) : undefined;
|
|
398
|
+
} catch {
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
257
401
|
}
|
|
258
402
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
return
|
|
403
|
+
function liveSwarmIf(src: DashboardSource, swarmId: string): LiveSwarm | undefined {
|
|
404
|
+
const swarm = src.getSwarm?.();
|
|
405
|
+
return swarm && swarm.id === swarmId && swarm.dir !== undefined ? swarm : undefined;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function swarmRunIds(src: DashboardSource, swarmId: string, live: LiveSwarm | undefined, record: SwarmRecordOnDisk | undefined): string[] {
|
|
409
|
+
if (live) return live.runs.map((run) => run.id);
|
|
410
|
+
const recorded = (record?.runs ?? []).map((run) => run.runId).filter((id): id is string => typeof id === "string");
|
|
411
|
+
if (recorded.length > 0) return recorded;
|
|
412
|
+
return runIdsOnDisk(src).filter((id) => swarmOfRun(id)?.swarmId === swarmId).sort();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* A swarm is `pending` until its hordes have TASK.md (the planner is still partitioning, or the
|
|
417
|
+
* parent died before launch), `not started` when the planner failed, `active` or `finalizing`
|
|
418
|
+
* while in memory, and `finished` once launched and no longer in memory.
|
|
419
|
+
*/
|
|
420
|
+
function swarmStateOf(
|
|
421
|
+
live: LiveSwarm | undefined,
|
|
422
|
+
record: SwarmRecordOnDisk | undefined,
|
|
423
|
+
notStarted: string | undefined,
|
|
424
|
+
hordeStates: RunState[],
|
|
425
|
+
): SwarmState {
|
|
426
|
+
if (notStarted !== undefined) return "not started";
|
|
427
|
+
if (live) return hordeStates.includes("active") ? "active" : "finalizing";
|
|
428
|
+
// `every` is true for no hordes at all, which is pending too.
|
|
429
|
+
if (record === undefined || hordeStates.every((state) => state === "pending")) return "pending";
|
|
430
|
+
return "finished";
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function swarmIndexRow(src: DashboardSource, swarmId: string): SwarmIndexRow {
|
|
434
|
+
const dir = swarmDir(src, swarmId);
|
|
435
|
+
const live = liveSwarmIf(src, swarmId);
|
|
436
|
+
const record = readSwarmRecord(dir);
|
|
437
|
+
const runIds = swarmRunIds(src, swarmId, live, record);
|
|
438
|
+
const rows = runIds.map((id) => runIndexRow(src, id));
|
|
439
|
+
const startedAt = live?.startedAt ?? record?.startedAt;
|
|
440
|
+
const notStarted = /^Not started: (.*)$/m.exec(readOptional(path.join(dir, "SWARM.md")) ?? "")?.[1];
|
|
441
|
+
const active = live !== undefined && rows.some((row) => row.active);
|
|
442
|
+
return {
|
|
443
|
+
id: swarmId,
|
|
444
|
+
active,
|
|
445
|
+
finalizing: live !== undefined && !active,
|
|
446
|
+
state: swarmStateOf(live, record, notStarted, rows.map((row) => row.state)),
|
|
447
|
+
started: startedAt !== undefined ? formatDateTime(new Date(startedAt)) : undefined,
|
|
448
|
+
hordes: live?.hordes ?? record?.hordes ?? runIds.length,
|
|
449
|
+
count: live?.count ?? record?.count,
|
|
450
|
+
prompt: live?.prompt ?? record?.prompt ?? (notStarted ? `not started: ${notStarted}` : ""),
|
|
451
|
+
outcomes: rows.map((row) => row.outcome ?? ""),
|
|
452
|
+
files: swarmFilePresence(dir),
|
|
453
|
+
};
|
|
262
454
|
}
|
|
263
455
|
|
|
264
456
|
export function listRuns(src: DashboardSource): RunIndexVm {
|
|
265
|
-
return {
|
|
457
|
+
return {
|
|
458
|
+
activeId: src.getActive()[0]?.id,
|
|
459
|
+
runs: runIdsOnDisk(src).map((id) => runIndexRow(src, id)),
|
|
460
|
+
swarms: swarmIdsOnDisk(src).map((id) => swarmIndexRow(src, id)),
|
|
461
|
+
};
|
|
266
462
|
}
|
|
267
463
|
|
|
268
464
|
// ---------------------------------------------------------------------------
|
|
@@ -460,7 +656,7 @@ function diskOverview(runId: string, runDir: string): RunOverviewVm {
|
|
|
460
656
|
count: task.count ?? members.length,
|
|
461
657
|
started: task.started,
|
|
462
658
|
elapsedMs: elapsed.length > 0 ? Math.max(...elapsed) : undefined,
|
|
463
|
-
outcome: diskOutcome(runDir),
|
|
659
|
+
outcome: runStateOnDisk(runDir) === "pending" ? "pending" : diskOutcome(runDir),
|
|
464
660
|
cleanStop: cleanStop ? { members: cleanStop.members } : undefined,
|
|
465
661
|
plan: readOptional(path.join(runDir, "PLAN.md"))
|
|
466
662
|
?.split("\n")
|
|
@@ -502,6 +698,11 @@ function runDirFor(src: DashboardSource, runId: string): string {
|
|
|
502
698
|
return activeIf(src, runId)?.runDir ?? resolveRunDir(src, runId);
|
|
503
699
|
}
|
|
504
700
|
|
|
701
|
+
/** Throws 400 for a malformed run id and 404 for one that is neither live nor on disk. */
|
|
702
|
+
export function requireRun(src: DashboardSource, runId: string): void {
|
|
703
|
+
runDirFor(src, runId);
|
|
704
|
+
}
|
|
705
|
+
|
|
505
706
|
function logLines(runDir: string, name: string): string[] {
|
|
506
707
|
const content = readOptional(path.join(runDir, `${name}.log`)) ?? "";
|
|
507
708
|
return content === "" ? [] : content.replace(/\n$/, "").split("\n");
|
|
@@ -666,29 +867,37 @@ export function loadSessionEntry(
|
|
|
666
867
|
// ---------------------------------------------------------------------------
|
|
667
868
|
|
|
668
869
|
export interface FileVm {
|
|
870
|
+
/** The run, or the swarm when `swarm` is set. */
|
|
669
871
|
runId: string;
|
|
670
|
-
name: RunFile;
|
|
872
|
+
name: RunFile | SwarmFile;
|
|
671
873
|
present: boolean;
|
|
672
874
|
content: string;
|
|
673
875
|
bytes: number;
|
|
674
876
|
truncated: boolean;
|
|
877
|
+
swarm?: boolean | undefined;
|
|
675
878
|
}
|
|
676
879
|
|
|
677
|
-
|
|
678
|
-
const
|
|
679
|
-
const
|
|
680
|
-
if (content === undefined) return { runId, name
|
|
880
|
+
function fileVm(runId: string, name: RunFile | SwarmFile, filePath: string, swarm: boolean): FileVm {
|
|
881
|
+
const content = readOptional(filePath);
|
|
882
|
+
const tag = swarm ? { swarm: true } : {};
|
|
883
|
+
if (content === undefined) return { runId, name, present: false, content: "", bytes: 0, truncated: false, ...tag };
|
|
681
884
|
const truncated = content.length > FILE_BYTES_MAX;
|
|
682
885
|
return {
|
|
683
886
|
runId,
|
|
684
|
-
name
|
|
887
|
+
name,
|
|
685
888
|
present: true,
|
|
686
889
|
content: truncated ? content.slice(0, FILE_BYTES_MAX) : content,
|
|
687
890
|
bytes: content.length,
|
|
688
891
|
truncated,
|
|
892
|
+
...tag,
|
|
689
893
|
};
|
|
690
894
|
}
|
|
691
895
|
|
|
896
|
+
export function loadRunFile(src: DashboardSource, runId: string, name: string): FileVm {
|
|
897
|
+
const file = validRunFile(name);
|
|
898
|
+
return fileVm(runId, file, path.join(runDirFor(src, runId), `${file}.md`), false);
|
|
899
|
+
}
|
|
900
|
+
|
|
692
901
|
// ---------------------------------------------------------------------------
|
|
693
902
|
// Shared boards
|
|
694
903
|
// ---------------------------------------------------------------------------
|
|
@@ -702,10 +911,13 @@ export interface IdeasVm {
|
|
|
702
911
|
ruledOut: Idea[];
|
|
703
912
|
}
|
|
704
913
|
|
|
705
|
-
|
|
914
|
+
/** `run` picks whose ledger to read: a swarm run's horde, else the project's. */
|
|
915
|
+
export function loadIdeas(src: DashboardSource, owner?: string, run?: string): IdeasVm {
|
|
706
916
|
if (owner !== undefined && owner !== "parent") validMember(owner);
|
|
707
|
-
|
|
708
|
-
const
|
|
917
|
+
if (run !== undefined) validRunId(run);
|
|
918
|
+
const ledgers = ledgerDirForFilter(src, run);
|
|
919
|
+
const paths = hordePaths(ledgers);
|
|
920
|
+
const all = fs.existsSync(paths.ideas) ? listIdeas(ledgers) : [];
|
|
709
921
|
const owners = Array.from(new Set(all.map((idea) => idea.owner).filter((o): o is string => !!o))).sort();
|
|
710
922
|
const ideas = owner ? all.filter((idea) => idea.owner === owner) : all;
|
|
711
923
|
return {
|
|
@@ -727,16 +939,14 @@ function validRunFilter(run: RunFilter): RunFilter {
|
|
|
727
939
|
export interface BoardVm {
|
|
728
940
|
run: RunFilter;
|
|
729
941
|
order: "newest" | "oldest";
|
|
730
|
-
runs: string[];
|
|
731
942
|
entries: BoardEntry[];
|
|
732
943
|
}
|
|
733
944
|
|
|
734
945
|
export function loadBoard(src: DashboardSource, run: RunFilter, order: "newest" | "oldest"): BoardVm {
|
|
735
946
|
validRunFilter(run);
|
|
736
|
-
const all = listBoard(src
|
|
737
|
-
const runs = Array.from(new Set(all.map((entry) => entry.runId).filter(Boolean)));
|
|
947
|
+
const all = listBoard(ledgerDirForFilter(src, run));
|
|
738
948
|
const entries = run === "all" ? all : all.filter((entry) => entry.runId === run);
|
|
739
|
-
return { run, order,
|
|
949
|
+
return { run, order, entries: order === "newest" ? [...entries].reverse() : entries };
|
|
740
950
|
}
|
|
741
951
|
|
|
742
952
|
interface FindingClusterVm {
|
|
@@ -749,7 +959,6 @@ interface FindingClusterVm {
|
|
|
749
959
|
export interface FindingsVm {
|
|
750
960
|
run: RunFilter;
|
|
751
961
|
view: "list" | "clusters";
|
|
752
|
-
runs: string[];
|
|
753
962
|
findings: FindingEntry[];
|
|
754
963
|
clusters?: FindingClusterVm[] | undefined;
|
|
755
964
|
reviewStates?: Record<number, string>;
|
|
@@ -764,7 +973,8 @@ export function loadFindings(
|
|
|
764
973
|
open: number[],
|
|
765
974
|
): FindingsVm {
|
|
766
975
|
validRunFilter(run);
|
|
767
|
-
const
|
|
976
|
+
const ledgers = ledgerDirForFilter(src, run);
|
|
977
|
+
const paths = hordePaths(ledgers);
|
|
768
978
|
const all = fs.existsSync(paths.findings) ? parseFindings(fs.readFileSync(paths.findings, "utf-8")) : [];
|
|
769
979
|
const runs = Array.from(new Set(all.map((finding) => finding.runId).filter(Boolean)));
|
|
770
980
|
const findings = run === "all" ? all : all.filter((finding) => finding.runId === run);
|
|
@@ -777,13 +987,13 @@ export function loadFindings(
|
|
|
777
987
|
findings: cluster.findings,
|
|
778
988
|
}))
|
|
779
989
|
: undefined;
|
|
780
|
-
const states = new Map(runs.filter((id) => /^[\w-]+$/.test(id) && findings.some((f) => f.runId === id)).map((id) => [id, collaborationState(
|
|
990
|
+
const states = new Map(runs.filter((id) => /^[\w-]+$/.test(id) && findings.some((f) => f.runId === id)).map((id) => [id, collaborationState(ledgers, id)]));
|
|
781
991
|
const reviewStates = Object.fromEntries(findings.map((f) => {
|
|
782
992
|
const state = states.get(f.runId);
|
|
783
993
|
const check = findingReviewState(f, state?.reviews, state?.messages);
|
|
784
994
|
return [f.id, `${check.status}${check.reviews.length ? ` (${check.reviews.map((r) => `${r.id}:${r.support}`).join(", ")})` : ""}`];
|
|
785
995
|
}));
|
|
786
|
-
return { run, view,
|
|
996
|
+
return { run, view, findings, clusters, open, reviewStates };
|
|
787
997
|
}
|
|
788
998
|
|
|
789
999
|
const CLAIM_STATE_ORDER: ClaimState[] = ["open", "confirmed", "contested", "unverified", "retracted", "superseded"];
|
|
@@ -791,9 +1001,11 @@ const CLAIM_STATE_ORDER: ClaimState[] = ["open", "confirmed", "contested", "unve
|
|
|
791
1001
|
export interface ClaimsVm {
|
|
792
1002
|
groups: Array<{ state: ClaimState; claims: ClaimEntry[] }>;
|
|
793
1003
|
}
|
|
794
|
-
export function loadClaims(src: DashboardSource): ClaimsVm {
|
|
795
|
-
|
|
796
|
-
const
|
|
1004
|
+
export function loadClaims(src: DashboardSource, run?: string): ClaimsVm {
|
|
1005
|
+
if (run !== undefined) validRunId(run);
|
|
1006
|
+
const ledgers = ledgerDirForFilter(src, run);
|
|
1007
|
+
const paths = hordePaths(ledgers);
|
|
1008
|
+
const all = fs.existsSync(paths.claims) ? listClaims(ledgers) : [];
|
|
797
1009
|
return {
|
|
798
1010
|
groups: CLAIM_STATE_ORDER.map((state) => ({
|
|
799
1011
|
state,
|
|
@@ -813,12 +1025,14 @@ export interface ResourcesVm {
|
|
|
813
1025
|
* counted the way REPORT.md counts them, from the active run's ledgers; without an active run
|
|
814
1026
|
* only explicit reclaims are known.
|
|
815
1027
|
*/
|
|
816
|
-
export function loadResources(src: DashboardSource): ResourcesVm {
|
|
817
|
-
|
|
818
|
-
const
|
|
819
|
-
const
|
|
1028
|
+
export function loadResources(src: DashboardSource, run?: string): ResourcesVm {
|
|
1029
|
+
if (run !== undefined) validRunId(run);
|
|
1030
|
+
const ledgers = ledgerDirForFilter(src, run);
|
|
1031
|
+
const paths = hordePaths(ledgers);
|
|
1032
|
+
const resources = fs.existsSync(paths.resources) ? listResources(ledgers) : [];
|
|
1033
|
+
const live = run !== undefined ? activeIf(src, run) : src.getActive().find((candidate) => candidate.hordeDir === ledgers);
|
|
820
1034
|
const citing = live
|
|
821
|
-
? resourceCitingMembers(resources, listFindingsForRun(
|
|
1035
|
+
? resourceCitingMembers(resources, listFindingsForRun(live.hordeDir, live.id), collaborationState(live.hordeDir, live.id))
|
|
822
1036
|
: new Map<number, string[]>();
|
|
823
1037
|
return { run: live?.id, resources: resources.map((resource) => ({ ...resource, citedBy: citing.get(resource.id) ?? [] })) };
|
|
824
1038
|
}
|
|
@@ -834,8 +1048,9 @@ export interface FactsVm {
|
|
|
834
1048
|
/** Facts split into this run / carried for the active run; other runs cannot attribute. */
|
|
835
1049
|
export function loadFacts(src: DashboardSource, run?: string): FactsVm {
|
|
836
1050
|
if (run !== undefined) validRunId(run);
|
|
837
|
-
const
|
|
838
|
-
const
|
|
1051
|
+
const ledgers = ledgerDirForFilter(src, run);
|
|
1052
|
+
const paths = hordePaths(ledgers);
|
|
1053
|
+
const all = fs.existsSync(paths.facts) ? listFacts(ledgers) : [];
|
|
839
1054
|
const live = run !== undefined ? activeIf(src, run) : undefined;
|
|
840
1055
|
if (!live) return { run, attribution: "unavailable", thisRun: [], carried: [], all };
|
|
841
1056
|
return {
|
|
@@ -846,3 +1061,219 @@ export function loadFacts(src: DashboardSource, run?: string): FactsVm {
|
|
|
846
1061
|
all,
|
|
847
1062
|
};
|
|
848
1063
|
}
|
|
1064
|
+
|
|
1065
|
+
// ---------------------------------------------------------------------------
|
|
1066
|
+
// Swarms
|
|
1067
|
+
// ---------------------------------------------------------------------------
|
|
1068
|
+
|
|
1069
|
+
export interface SwarmHordeVm {
|
|
1070
|
+
label: string;
|
|
1071
|
+
runId: string;
|
|
1072
|
+
active: boolean;
|
|
1073
|
+
finalizing: boolean;
|
|
1074
|
+
state: RunState;
|
|
1075
|
+
outcome?: string | undefined;
|
|
1076
|
+
slice?: string | undefined;
|
|
1077
|
+
count: number;
|
|
1078
|
+
counts: Record<MemberStatus, number>;
|
|
1079
|
+
totals: UsageTotals;
|
|
1080
|
+
findings: number;
|
|
1081
|
+
acceptedArtifacts: number;
|
|
1082
|
+
elapsedMs?: number | undefined;
|
|
1083
|
+
files: Record<RunFile, boolean>;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
export interface SwarmVm {
|
|
1087
|
+
id: string;
|
|
1088
|
+
active: boolean;
|
|
1089
|
+
finalizing: boolean;
|
|
1090
|
+
state: SwarmState;
|
|
1091
|
+
notStarted?: string | undefined;
|
|
1092
|
+
prompt: string;
|
|
1093
|
+
hordes: number;
|
|
1094
|
+
count?: number | undefined;
|
|
1095
|
+
started?: string | undefined;
|
|
1096
|
+
elapsedMs?: number | undefined;
|
|
1097
|
+
untilStop: boolean;
|
|
1098
|
+
partition: string[];
|
|
1099
|
+
/** What the merge step is doing or did: "pending", "merging", "merged", "no deliverable". */
|
|
1100
|
+
merge: string;
|
|
1101
|
+
rows: SwarmHordeVm[];
|
|
1102
|
+
files: Record<SwarmFile, boolean>;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function acceptedArtifactCount(runDir: string): number {
|
|
1106
|
+
const raw = readOptional(path.join(runDir, "ACCEPTANCE.json"));
|
|
1107
|
+
if (raw === undefined) return 0;
|
|
1108
|
+
try {
|
|
1109
|
+
const accepted = (JSON.parse(raw) as { report?: { acceptedArtifacts?: unknown[] } }).report?.acceptedArtifacts;
|
|
1110
|
+
return Array.isArray(accepted) ? accepted.length : 0;
|
|
1111
|
+
} catch {
|
|
1112
|
+
return 0;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function swarmHordeVm(src: DashboardSource, runId: string, slice: string | undefined): SwarmHordeVm {
|
|
1117
|
+
const overview = loadRunOverview(src, runId);
|
|
1118
|
+
const ledgers = ledgerDir(src, runId);
|
|
1119
|
+
const runDir = path.join(hordePaths(ledgers).runs, runId);
|
|
1120
|
+
let findings = 0;
|
|
1121
|
+
try {
|
|
1122
|
+
findings = listFindingsForRun(ledgers, runId).length;
|
|
1123
|
+
} catch {
|
|
1124
|
+
// A horde whose FINDINGS.md is unreadable reports none.
|
|
1125
|
+
}
|
|
1126
|
+
const finalizing = (overview.finalizing?.length ?? 0) > 0;
|
|
1127
|
+
return {
|
|
1128
|
+
label: `h${swarmOfRun(runId)?.horde ?? "?"}`,
|
|
1129
|
+
runId,
|
|
1130
|
+
active: overview.active,
|
|
1131
|
+
finalizing,
|
|
1132
|
+
state: overview.active ? "active" : finalizing ? "finalizing" : runStateOnDisk(runDir),
|
|
1133
|
+
outcome: overview.outcome,
|
|
1134
|
+
slice,
|
|
1135
|
+
count: overview.count,
|
|
1136
|
+
counts: overview.counts,
|
|
1137
|
+
totals: overview.totals,
|
|
1138
|
+
findings,
|
|
1139
|
+
acceptedArtifacts: acceptedArtifactCount(runDir),
|
|
1140
|
+
elapsedMs: overview.elapsedMs,
|
|
1141
|
+
files: overview.files,
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function mergeLabel(live: LiveSwarm | undefined, record: SwarmRecordOnDisk | undefined, rows: SwarmHordeVm[]): string {
|
|
1146
|
+
if (live?.finalizing?.length) return "merging";
|
|
1147
|
+
const merge = record?.merge;
|
|
1148
|
+
if (merge?.endedAt !== undefined) return merge.written === false ? "no deliverable" : "merged";
|
|
1149
|
+
if (merge?.startedAt !== undefined) return "merging";
|
|
1150
|
+
return rows.every((row) => !row.active && !row.finalizing) && !live ? "not run" : "pending";
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/** One row per horde, linking to the run pages, plus the partition and the merge step. */
|
|
1154
|
+
export function loadSwarm(src: DashboardSource, swarmId: string): SwarmVm {
|
|
1155
|
+
validSwarmId(swarmId);
|
|
1156
|
+
const dir = swarmDir(src, swarmId);
|
|
1157
|
+
const live = liveSwarmIf(src, swarmId);
|
|
1158
|
+
if (!live && !fs.existsSync(dir)) throw new DashboardError(404, `no swarm ${swarmId}`);
|
|
1159
|
+
const record = readSwarmRecord(dir);
|
|
1160
|
+
const partition = live?.partition ?? record?.partition ?? [];
|
|
1161
|
+
const rows = swarmRunIds(src, swarmId, live, record).map((runId, index) => swarmHordeVm(src, runId, partition[index]));
|
|
1162
|
+
const startedAt = live?.startedAt ?? record?.startedAt;
|
|
1163
|
+
const endedAt = live ? undefined : record?.endedAt;
|
|
1164
|
+
const notStarted = /^Not started: (.*)$/m.exec(readOptional(path.join(dir, "SWARM.md")) ?? "")?.[1];
|
|
1165
|
+
const active = live !== undefined && rows.some((row) => row.active);
|
|
1166
|
+
return {
|
|
1167
|
+
id: swarmId,
|
|
1168
|
+
active,
|
|
1169
|
+
finalizing: live !== undefined && !active,
|
|
1170
|
+
state: swarmStateOf(live, record, notStarted, rows.map((row) => row.state)),
|
|
1171
|
+
notStarted,
|
|
1172
|
+
prompt: live?.prompt ?? record?.prompt ?? "",
|
|
1173
|
+
hordes: live?.hordes ?? record?.hordes ?? rows.length,
|
|
1174
|
+
count: live?.count ?? record?.count,
|
|
1175
|
+
started: startedAt !== undefined ? formatDateTime(new Date(startedAt)) : undefined,
|
|
1176
|
+
elapsedMs: startedAt !== undefined ? (endedAt ?? nowOf(src)) - startedAt : undefined,
|
|
1177
|
+
untilStop: live?.untilStop === true || record?.untilStop === true,
|
|
1178
|
+
partition,
|
|
1179
|
+
merge: mergeLabel(live, record, rows),
|
|
1180
|
+
rows,
|
|
1181
|
+
files: swarmFilePresence(dir),
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/** One horde of a swarm: its tab label and its run. */
|
|
1186
|
+
export interface SwarmHorde {
|
|
1187
|
+
label: string;
|
|
1188
|
+
runId: string;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/** The swarm's hordes in order, `h1` first; throws when the swarm does not exist. */
|
|
1192
|
+
export function swarmHordes(src: DashboardSource, swarmId: string): SwarmHorde[] {
|
|
1193
|
+
validSwarmId(swarmId);
|
|
1194
|
+
const live = liveSwarmIf(src, swarmId);
|
|
1195
|
+
if (!live && !fs.existsSync(swarmDir(src, swarmId))) throw new DashboardError(404, `no swarm ${swarmId}`);
|
|
1196
|
+
const runIds = swarmRunIds(src, swarmId, live, readSwarmRecord(swarmDir(src, swarmId)));
|
|
1197
|
+
return runIds.map((runId) => ({ label: `h${swarmOfRun(runId)?.horde ?? "?"}`, runId }));
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/** A ledger page aggregated across a swarm: one section per horde, each the normal per-horde view. */
|
|
1201
|
+
export interface SwarmLedgerVm<T> {
|
|
1202
|
+
swarmId: string;
|
|
1203
|
+
sections: Array<SwarmHorde & { vm: T }>;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function swarmSections<T>(src: DashboardSource, swarmId: string, perHorde: (runId: string) => T): SwarmLedgerVm<T> {
|
|
1207
|
+
return { swarmId, sections: swarmHordes(src, swarmId).map((l) => ({ ...l, vm: perHorde(l.runId) })) };
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/** Each horde's collaboration state on one page; the event audit stays on the horde's own page. */
|
|
1211
|
+
export function loadSwarmCollaboration(src: DashboardSource, swarmId: string): SwarmLedgerVm<CollaborationVm> {
|
|
1212
|
+
return swarmSections(src, swarmId, (runId) => loadCollaboration(src, runId));
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
export function loadSwarmIdeas(src: DashboardSource, swarmId: string, owner?: string): SwarmLedgerVm<IdeasVm> {
|
|
1216
|
+
return swarmSections(src, swarmId, (runId) => loadIdeas(src, owner, runId));
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
export function loadSwarmBoard(src: DashboardSource, swarmId: string, order: "newest" | "oldest"): SwarmLedgerVm<BoardVm> {
|
|
1220
|
+
return swarmSections(src, swarmId, (runId) => loadBoard(src, runId, order));
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
export function loadSwarmFindings(src: DashboardSource, swarmId: string, view: "list" | "clusters", open: number[]): SwarmLedgerVm<FindingsVm> {
|
|
1224
|
+
return swarmSections(src, swarmId, (runId) => loadFindings(src, runId, view, open));
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
export function loadSwarmClaims(src: DashboardSource, swarmId: string): SwarmLedgerVm<ClaimsVm> {
|
|
1228
|
+
return swarmSections(src, swarmId, (runId) => loadClaims(src, runId));
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
export function loadSwarmFacts(src: DashboardSource, swarmId: string): SwarmLedgerVm<FactsVm> {
|
|
1232
|
+
return swarmSections(src, swarmId, (runId) => loadFacts(src, runId));
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
export function loadSwarmResources(src: DashboardSource, swarmId: string): SwarmLedgerVm<ResourcesVm> {
|
|
1236
|
+
return swarmSections(src, swarmId, (runId) => loadResources(src, runId));
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
export interface SwarmGraphVm {
|
|
1240
|
+
swarmId: string;
|
|
1241
|
+
graph: RunGraph;
|
|
1242
|
+
view: GraphView;
|
|
1243
|
+
active: boolean;
|
|
1244
|
+
empty: boolean;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/** One lane per horde built from each horde's lifecycle events, plus the merge step. */
|
|
1248
|
+
export function loadSwarmGraph(src: DashboardSource, swarmId: string, view: GraphView): SwarmGraphVm {
|
|
1249
|
+
validSwarmId(swarmId);
|
|
1250
|
+
const dir = swarmDir(src, swarmId);
|
|
1251
|
+
const live = liveSwarmIf(src, swarmId);
|
|
1252
|
+
if (!live && !fs.existsSync(dir)) throw new DashboardError(404, `no swarm ${swarmId}`);
|
|
1253
|
+
const record = readSwarmRecord(dir);
|
|
1254
|
+
const runIds = swarmRunIds(src, swarmId, live, record);
|
|
1255
|
+
const hordes = runIds.map((runId) => {
|
|
1256
|
+
const run = activeIf(src, runId) ?? finalizingIf(src, runId);
|
|
1257
|
+
const status = run ? (run.finalized ? "finished" : run.reported ? "finalizing" : "running") : diskOutcome(path.join(hordePaths(ledgerDir(src, runId)).runs, runId));
|
|
1258
|
+
return { label: `h${swarmOfRun(runId)?.horde ?? "?"}`, runId, events: readEvents(ledgerDir(src, runId), runId), status };
|
|
1259
|
+
});
|
|
1260
|
+
const merge = record?.merge?.startedAt !== undefined
|
|
1261
|
+
? { startedAt: record.merge.startedAt, endedAt: record.merge.endedAt, written: record.merge.written }
|
|
1262
|
+
: undefined;
|
|
1263
|
+
const graph = buildSwarmGraph({
|
|
1264
|
+
swarmId,
|
|
1265
|
+
startedAt: live?.startedAt ?? record?.startedAt ?? Math.min(...hordes.map((h) => Date.parse(h.events[0]?.at ?? "")).filter(Number.isFinite), Date.now()),
|
|
1266
|
+
hordes,
|
|
1267
|
+
merge,
|
|
1268
|
+
now: live ? nowOf(src) : undefined,
|
|
1269
|
+
});
|
|
1270
|
+
return { swarmId, graph, view, active: live !== undefined, empty: hordes.every((horde) => horde.events.length === 0) };
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
export function loadSwarmFile(src: DashboardSource, swarmId: string, name: string): FileVm {
|
|
1274
|
+
validSwarmId(swarmId);
|
|
1275
|
+
if (!(SWARM_FILES as readonly string[]).includes(name)) throw new DashboardError(400, `unknown swarm file: ${name}`);
|
|
1276
|
+
const dir = swarmDir(src, swarmId);
|
|
1277
|
+
if (!fs.existsSync(dir)) throw new DashboardError(404, `no swarm ${swarmId}`);
|
|
1278
|
+
return fileVm(swarmId, name as SwarmFile, path.join(dir, `${name}.md`), true);
|
|
1279
|
+
}
|