agent-horde 0.1.5 → 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.
@@ -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
- getActive(): LiveRun | undefined;
58
- /** The run still writing DELIVERABLE.md / POSTMORTEM.md, if any. */
59
- getFinalizing?(): LiveRun | undefined;
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 RUN_ID_RE = /^\d{8}-\d{6}(?:-\d+)?$/;
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(src.hordeDir, runId);
119
- const all = readEvents(src.hordeDir, runId);
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.hordeDir, runId);
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
- const active = src.getActive();
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
- const run = src.getFinalizing?.();
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.hordeDir).runs;
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.hordeDir).runs, runId);
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
- /** Run ids under runs/, newest first. */
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 runs = hordePaths(src.hordeDir).runs;
256
- return fs.existsSync(runs) ? fs.readdirSync(runs).filter((name) => RUN_ID_RE.test(name)).sort().reverse() : [];
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
- /** The run the nav's per-run links point at when the page itself names none: live first, then newest. */
260
- export function latestRunId(src: DashboardSource): string | undefined {
261
- return src.getActive()?.id ?? src.getFinalizing?.()?.id ?? runIdsOnDisk(src)[0];
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 { activeId: src.getActive()?.id, runs: runIdsOnDisk(src).map((id) => runIndexRow(src, id)) };
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
- export function loadRunFile(src: DashboardSource, runId: string, name: string): FileVm {
678
- const file = validRunFile(name);
679
- const content = readOptional(path.join(runDirFor(src, runId), `${file}.md`));
680
- if (content === undefined) return { runId, name: file, present: false, content: "", bytes: 0, truncated: false };
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: file,
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
- export function loadIdeas(src: DashboardSource, owner?: string): IdeasVm {
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
- const paths = hordePaths(src.hordeDir);
708
- const all = fs.existsSync(paths.ideas) ? listIdeas(src.hordeDir) : [];
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.hordeDir);
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, runs, entries: order === "newest" ? [...entries].reverse() : entries };
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 paths = hordePaths(src.hordeDir);
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(src.hordeDir, id)]));
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, runs, findings, clusters, open, reviewStates };
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
- const paths = hordePaths(src.hordeDir);
796
- const all = fs.existsSync(paths.claims) ? listClaims(src.hordeDir) : [];
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
- const paths = hordePaths(src.hordeDir);
818
- const resources = fs.existsSync(paths.resources) ? listResources(src.hordeDir) : [];
819
- const live = src.getActive();
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(src.hordeDir, live.id), collaborationState(src.hordeDir, live.id))
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 paths = hordePaths(src.hordeDir);
838
- const all = fs.existsSync(paths.facts) ? listFacts(src.hordeDir) : [];
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
+ }