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-html.ts
CHANGED
|
@@ -20,26 +20,47 @@ import type {
|
|
|
20
20
|
RunIndexVm,
|
|
21
21
|
RunOverviewVm,
|
|
22
22
|
SessionsVm,
|
|
23
|
+
SwarmGraphVm,
|
|
24
|
+
SwarmHorde,
|
|
25
|
+
SwarmLedgerVm,
|
|
26
|
+
SwarmVm,
|
|
23
27
|
TranscriptEntryVm,
|
|
24
28
|
TranscriptVm,
|
|
25
29
|
} from "./dashboard-data.js";
|
|
26
|
-
import { RUN_FILES } from "./dashboard-data.js";
|
|
30
|
+
import { RUN_FILES, SWARM_FILES } from "./dashboard-data.js";
|
|
27
31
|
import { buildInfo } from "./build-info.js";
|
|
28
32
|
import { detailKey, formatClock, formatElapsed, formatTokens, oneLine } from "./format.js";
|
|
29
33
|
import { renderRunGraphMermaid } from "./run-graph-mermaid.js";
|
|
30
34
|
import { GRAPH_CSS, graphLegendHtml, renderRunGraphSvg } from "./run-graph-svg.js";
|
|
31
35
|
import type { TranscriptEntry, TranscriptPart } from "./session-log.js";
|
|
32
36
|
|
|
37
|
+
/**
|
|
38
|
+
* What a page is about, fixed by its URL: the ledgers single runs share (`/`, `/findings`), one run
|
|
39
|
+
* (`/runs/<id>/...`), or a whole swarm (`/swarms/<id>/...`). A horde of a swarm is a run scope that
|
|
40
|
+
* also knows its swarm, so the nav can offer the sibling hordes. Every scoped link derives from this.
|
|
41
|
+
*/
|
|
42
|
+
export type Scope =
|
|
43
|
+
| { kind: "project" }
|
|
44
|
+
| { kind: "run"; runId: string; swarm?: { id: string; hordes: SwarmHorde[] } | undefined }
|
|
45
|
+
| { kind: "swarm"; swarmId: string; hordes: SwarmHorde[] };
|
|
46
|
+
|
|
47
|
+
/** The page types every scope offers in the page row; `other` is a run-only page such as a transcript. */
|
|
48
|
+
export type PageKind =
|
|
49
|
+
| "overview" | "ideas" | "board" | "findings" | "claims" | "facts" | "resources" | "collaboration" | "graph" | "other";
|
|
50
|
+
|
|
33
51
|
/** Per-request facts the renderers need beyond the view model. */
|
|
34
52
|
export interface RenderContext {
|
|
35
53
|
href?: string;
|
|
36
|
-
|
|
37
|
-
|
|
54
|
+
scope?: Scope | undefined;
|
|
55
|
+
pageKind?: PageKind | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* A swarm in memory, set on project pages only: its hordes write their own ledgers, so the
|
|
58
|
+
* project page points at the same page in the swarm. `finishing` once no horde is running.
|
|
59
|
+
*/
|
|
60
|
+
liveSwarm?: { id: string; finishing: boolean } | undefined;
|
|
38
61
|
refresh?: { on: boolean; toggleHref: string; seconds: number } | undefined;
|
|
39
62
|
/** Scheme and host of the request, for exports that need absolute links. */
|
|
40
63
|
origin?: string | undefined;
|
|
41
|
-
/** The run the collaboration and graph links target: this page's run, else the newest. */
|
|
42
|
-
navRun?: string | undefined;
|
|
43
64
|
/** Rendering for the static export: no refresh, no export control, native disclosure. */
|
|
44
65
|
export?: boolean | undefined;
|
|
45
66
|
}
|
|
@@ -54,7 +75,7 @@ const CSS =
|
|
|
54
75
|
"table{border-collapse:collapse;margin:4px 0}" +
|
|
55
76
|
"th,td{border:1px solid #000;padding:2px 6px;vertical-align:top;text-align:left}" +
|
|
56
77
|
"th{background:#eee}pre{white-space:pre-wrap;word-break:break-word;margin:0}" +
|
|
57
|
-
"nav a{margin-right:8px}.r{text-align:right}h1,h2{font-size:13px;margin:8px 0 4px}" +
|
|
78
|
+
"nav{margin:2px 0}nav a,nav b{margin-right:8px}.scope{margin:2px 0}.scope a,.scope b{margin-right:6px}.pointer{border:1px solid #000;padding:2px 6px;display:inline-block}.r{text-align:right}h1,h2{font-size:13px;margin:8px 0 4px}" +
|
|
58
79
|
"details>summary{cursor:pointer}a{color:#000}footer{margin-top:16px;color:#666}footer a{color:#666}" +
|
|
59
80
|
".export{position:absolute;top:8px;right:8px;line-height:0}" +
|
|
60
81
|
".modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7)}" +
|
|
@@ -123,21 +144,92 @@ function memberHref(runId: string, member: string): string {
|
|
|
123
144
|
return `${runHref(runId)}/members/${encodeURIComponent(member)}`;
|
|
124
145
|
}
|
|
125
146
|
|
|
126
|
-
function
|
|
127
|
-
|
|
128
|
-
return link(`${runHref(run)}/collaboration`, "collaboration") + link(`${runHref(run)}/graph`, "graph");
|
|
147
|
+
function swarmHref(swarmId: string): string {
|
|
148
|
+
return `/swarms/${encodeURIComponent(swarmId)}`;
|
|
129
149
|
}
|
|
130
150
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
151
|
+
/** The path every scoped link starts from: `` for the project, `/runs/<id>`, or `/swarms/<id>`. */
|
|
152
|
+
function scopeBase(scope: Scope | undefined): string {
|
|
153
|
+
if (scope?.kind === "run") return runHref(scope.runId);
|
|
154
|
+
if (scope?.kind === "swarm") return swarmHref(scope.swarmId);
|
|
155
|
+
return "";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The run a page's member names link into; none in the project or swarm scope. */
|
|
159
|
+
function scopeRunId(ctx: RenderContext): string | undefined {
|
|
160
|
+
return ctx.scope?.kind === "run" ? ctx.scope.runId : undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const LEDGER_PAGES = ["ideas", "board", "findings", "claims", "facts", "resources"] as const;
|
|
164
|
+
|
|
165
|
+
/** The project scope has its ledgers only; a run or swarm also has an overview, collaboration and graph. */
|
|
166
|
+
function pagesOf(scope: Scope | undefined): PageKind[] {
|
|
167
|
+
return scope === undefined || scope.kind === "project"
|
|
168
|
+
? [...LEDGER_PAGES]
|
|
169
|
+
: ["overview", ...LEDGER_PAGES, "collaboration", "graph"];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The same page type under another base; pages a scope does not share fall back to its overview. */
|
|
173
|
+
function pageHref(base: string, kind: PageKind | undefined): string {
|
|
174
|
+
if (kind === undefined || kind === "overview" || kind === "other") return base || "/";
|
|
175
|
+
return `${base}/${kind}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The current item is shown, not linked. */
|
|
179
|
+
function navItem(href: string, text: string, current: boolean): string {
|
|
180
|
+
return current ? `<b>${e(text)}</b>` : link(href, text);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** `all | h1 | h2 ...` for a swarm or one of its hordes; each tab keeps the page type. */
|
|
184
|
+
function hordeTabs(swarmId: string, hordes: SwarmHorde[], current: string | undefined, kind: PageKind | undefined): string {
|
|
185
|
+
const tabs = [
|
|
186
|
+
navItem(pageHref(swarmHref(swarmId), kind), "all", current === undefined),
|
|
187
|
+
...hordes.map((horde) => navItem(pageHref(runHref(horde.runId), kind), horde.label, horde.runId === current)),
|
|
188
|
+
];
|
|
189
|
+
return ` hordes: ${tabs.join(" ")}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** On a project page while a swarm is live: where the swarm's activity is, on the same page type. */
|
|
193
|
+
function swarmPointer(ctx: RenderContext): string {
|
|
194
|
+
const swarm = ctx.liveSwarm;
|
|
195
|
+
if (!swarm) return "";
|
|
196
|
+
const target = ctx.pageKind === undefined || ctx.pageKind === "overview" || ctx.pageKind === "other"
|
|
197
|
+
? "the swarm"
|
|
198
|
+
: `the swarm's ${ctx.pageKind}`;
|
|
134
199
|
return (
|
|
135
|
-
`<
|
|
136
|
-
|
|
137
|
-
`${link("/resources", "resources")}${runLinks(ctx.navRun ?? run)}</nav>`
|
|
200
|
+
`<p class="pointer">swarm ${e(swarm.id)} is ${swarm.finishing ? "finishing" : "running"} in its own ledgers, ` +
|
|
201
|
+
`not these: ${link(pageHref(swarmHref(swarm.id), ctx.pageKind), target)}</p>`
|
|
138
202
|
);
|
|
139
203
|
}
|
|
140
204
|
|
|
205
|
+
/** The scope line: what this page is about, and inside a swarm the tabs to move between hordes. */
|
|
206
|
+
function scopeLine(ctx: RenderContext): string {
|
|
207
|
+
const scope = ctx.scope;
|
|
208
|
+
if (scope === undefined || scope.kind === "project") {
|
|
209
|
+
return `<p class="scope">scope: <b>project</b></p>${swarmPointer(ctx)}`;
|
|
210
|
+
}
|
|
211
|
+
if (scope.kind === "swarm") {
|
|
212
|
+
return `<p class="scope">scope: <b>swarm ${e(scope.swarmId)}</b>${hordeTabs(scope.swarmId, scope.hordes, undefined, ctx.pageKind)}</p>`;
|
|
213
|
+
}
|
|
214
|
+
if (!scope.swarm) return `<p class="scope">scope: <b>run ${e(scope.runId)}</b></p>`;
|
|
215
|
+
const horde = scope.swarm.hordes.find((h) => h.runId === scope.runId)?.label ?? "horde";
|
|
216
|
+
return (
|
|
217
|
+
`<p class="scope">scope: ${link(swarmHref(scope.swarm.id), `swarm ${scope.swarm.id}`)} › <b>${e(horde)}</b>` +
|
|
218
|
+
`${hordeTabs(scope.swarm.id, scope.swarm.hordes, scope.runId, ctx.pageKind)}</p>`
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Three rows: `runs` to the index, the scope line, and the page row. Every link in the page row is
|
|
224
|
+
* built from the page's own scope, so nothing silently points at some other run.
|
|
225
|
+
*/
|
|
226
|
+
function nav(ctx: RenderContext): string {
|
|
227
|
+
const base = scopeBase(ctx.scope);
|
|
228
|
+
const pages = pagesOf(ctx.scope).map((kind) => navItem(pageHref(base, kind), kind, kind === ctx.pageKind));
|
|
229
|
+
const onIndex = (ctx.href ?? "").split("?")[0] === "/";
|
|
230
|
+
return `<nav>${navItem("/", "runs", onIndex)}</nav>${scopeLine(ctx)}<nav>${pages.join("")}</nav>`;
|
|
231
|
+
}
|
|
232
|
+
|
|
141
233
|
/** An id cell that deep links, including the run graph, can target. */
|
|
142
234
|
function anchor(id: string): string {
|
|
143
235
|
return `<span id="${e(id)}">${e(id)}</span>`;
|
|
@@ -155,7 +247,8 @@ function messageThread(m: PeerMessage): string {
|
|
|
155
247
|
return lines.join("\n");
|
|
156
248
|
}
|
|
157
249
|
|
|
158
|
-
|
|
250
|
+
/** Acceptance, requests, artifacts, reviews, resources and metrics; the event audit only on a run's own page. */
|
|
251
|
+
function collaborationBody(vm: CollaborationVm, withAudit: boolean): string {
|
|
159
252
|
const { report } = vm;
|
|
160
253
|
const { state } = report;
|
|
161
254
|
const messages = table(["request", "owner", "state / age", "target", "question / response"], state.messages.map((m) => [
|
|
@@ -164,12 +257,27 @@ export function renderCollaboration(vm: CollaborationVm, ctx: RenderContext): st
|
|
|
164
257
|
]));
|
|
165
258
|
const reviews = table(["review", "target revision", "verdict", "scope / evidence"], state.reviews.map((r) => [`${anchor(r.id)} @${e(r.actor)}`, e(`${r.target}@${r.revision}`), e(`${r.verdict} [${r.support}]`), pre(`Checked: ${r.checked}\nUnchecked: ${r.unchecked || "none declared"}\nLimits: ${r.limits || "none disclosed"}\nEvidence: ${r.evidence.join(", ")}${r.reattests ? `\nReattests: ${r.reattests}` : ""}\n${r.text}`)]));
|
|
166
259
|
const artifacts = table(["artifact", "hash / dependencies", "acceptance"], state.artifacts.map((a) => [`${anchor(a.id)} ${e(`${a.path} @${a.owner} (${a.kind ?? "runnable"})`)}`, pre(`${a.hash}\n${JSON.stringify(a.dependencies)}`), e(report.acceptedArtifacts.includes(a.id) ? "accepted" : "excluded / needs review")]));
|
|
167
|
-
const
|
|
168
|
-
return page(`Collaboration ${report.runId}`, [{ text: "runs", href: "/" }, { text: report.runId, href: runHref(report.runId) }, { text: "collaboration" }],
|
|
260
|
+
const summary =
|
|
169
261
|
(vm.historical ? "<p>Historical run: no delivery/revision history was recorded.</p>" : "") +
|
|
170
262
|
`<h2>Acceptance issues</h2>${pre(report.issues.join("\n") || "none recorded")}<h2>Requests</h2>${messages}<h2>Artifacts</h2>${artifacts}<h2>Reviews</h2>${reviews}` +
|
|
171
|
-
`<h2>Resource readiness and leases</h2>${pre(JSON.stringify(state.resources, null, 2))}<h2>Metrics</h2>${pre(JSON.stringify(report.metrics, null, 2))}
|
|
172
|
-
|
|
263
|
+
`<h2>Resource readiness and leases</h2>${pre(JSON.stringify(state.resources, null, 2))}<h2>Metrics</h2>${pre(JSON.stringify(report.metrics, null, 2))}`;
|
|
264
|
+
if (!withAudit) {
|
|
265
|
+
return `${summary}<p>${link(`${runHref(report.runId)}/collaboration`, "event audit")} on the horde's own page</p>`;
|
|
266
|
+
}
|
|
267
|
+
const events = vm.events.map((event) => `<div id="${e(event.eventId)}">${details(`${event.eventId} ${event.at} ${event.actor}/${event.attempt} ${event.action} ${event.outcome}`, pre(JSON.stringify(event, null, 2)), `event:${report.runId}:${event.eventId}`)}</div>`).join("");
|
|
268
|
+
return `${summary}<h2>Event audit</h2>${events}` +
|
|
269
|
+
(vm.next !== undefined ? link(`${runHref(report.runId)}/collaboration?from=${vm.next}`, "next 100 events") : "");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function renderCollaboration(vm: CollaborationVm, ctx: RenderContext): string {
|
|
273
|
+
const runId = vm.report.runId;
|
|
274
|
+
const crumbs = [{ text: "runs", href: "/" }, { text: runId, href: runHref(runId) }, { text: "collaboration" }];
|
|
275
|
+
return page(`Collaboration ${runId}`, crumbs, collaborationBody(vm, true), ctx);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Each horde's acceptance, requests and reviews, under per-horde headings. */
|
|
279
|
+
export function renderSwarmCollaboration(vm: SwarmLedgerVm<CollaborationVm>, ctx: RenderContext): string {
|
|
280
|
+
return swarmLedgerPage("collaboration", vm, (section) => collaborationBody(section, false), ctx);
|
|
173
281
|
}
|
|
174
282
|
|
|
175
283
|
function crumbLine(crumbs: Crumb[]): string {
|
|
@@ -269,10 +377,31 @@ function cost(value: number): string {
|
|
|
269
377
|
return `$${value.toFixed(4)}`;
|
|
270
378
|
}
|
|
271
379
|
|
|
380
|
+
/** The state column: a live run stands out; pending, finalizing, finished and not started read as written. */
|
|
381
|
+
function stateCell(state: string): string {
|
|
382
|
+
return state === "active" ? "<b>ACTIVE</b>" : e(state);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function swarmIndexTable(vm: RunIndexVm): string {
|
|
386
|
+
if (vm.swarms.length === 0) return "";
|
|
387
|
+
const rows = vm.swarms.map((swarm) => [
|
|
388
|
+
link(swarmHref(swarm.id), swarm.id),
|
|
389
|
+
stateCell(swarm.state),
|
|
390
|
+
e(swarm.started ?? ""),
|
|
391
|
+
`${swarm.hordes} x ${num(swarm.count) || "?"}`,
|
|
392
|
+
e(swarm.outcomes.map((outcome, index) => `h${index + 1} ${outcome || "?"}`).join(", ")),
|
|
393
|
+
e(oneLine(swarm.prompt, 100)),
|
|
394
|
+
...SWARM_FILES.map((name) => yn(swarm.files[name])),
|
|
395
|
+
]);
|
|
396
|
+
const headers = ["swarm", "state", "started", "hordes", "outcomes", "prompt", ...SWARM_FILES.map((n) => n.toLowerCase())];
|
|
397
|
+
return `<h2>swarms</h2>${table(headers, rows)}<h2>runs</h2>`;
|
|
398
|
+
}
|
|
399
|
+
|
|
272
400
|
export function renderRunIndex(vm: RunIndexVm, ctx: RenderContext): string {
|
|
273
401
|
const rows = vm.runs.map((run) => [
|
|
274
402
|
link(runHref(run.id), run.id),
|
|
275
|
-
run.
|
|
403
|
+
run.swarm ? link(swarmHref(run.swarm), run.swarm) : "",
|
|
404
|
+
stateCell(run.state),
|
|
276
405
|
e(run.started ?? ""),
|
|
277
406
|
num(run.count),
|
|
278
407
|
e(run.model ?? ""),
|
|
@@ -280,8 +409,8 @@ export function renderRunIndex(vm: RunIndexVm, ctx: RenderContext): string {
|
|
|
280
409
|
e(oneLine(run.prompt, 100)),
|
|
281
410
|
...RUN_FILES.map((name) => yn(run.files[name])),
|
|
282
411
|
]);
|
|
283
|
-
const headers = ["run", "state", "started", "members", "model", "outcome", "prompt", ...RUN_FILES.map((n) => n.toLowerCase())];
|
|
284
|
-
return page("horde runs", [{ text: "runs" }], table(headers, rows, "no runs in .horde/runs"), ctx);
|
|
412
|
+
const headers = ["run", "swarm", "state", "started", "members", "model", "outcome", "prompt", ...RUN_FILES.map((n) => n.toLowerCase())];
|
|
413
|
+
return page("horde runs", [{ text: "runs" }], swarmIndexTable(vm) + table(headers, rows, "no runs in .horde/runs"), ctx);
|
|
285
414
|
}
|
|
286
415
|
|
|
287
416
|
function memberRow(runId: string, m: MemberRowVm): string[] {
|
|
@@ -531,7 +660,8 @@ export function renderSessions(vm: SessionsVm, ctx: RenderContext): string {
|
|
|
531
660
|
}
|
|
532
661
|
|
|
533
662
|
export function renderFile(vm: FileVm, ctx: RenderContext): string {
|
|
534
|
-
const
|
|
663
|
+
const parent = vm.swarm ? swarmHref(vm.runId) : runHref(vm.runId);
|
|
664
|
+
const crumbs = [{ text: "runs", href: "/" }, { text: vm.runId, href: parent }, { text: `${vm.name}.md` }];
|
|
535
665
|
const note = vm.truncated ? `<p>showing the first ${vm.content.length} of ${vm.bytes} characters</p>` : "";
|
|
536
666
|
return page(`${vm.name}.md`, crumbs, note + pre(vm.content), ctx);
|
|
537
667
|
}
|
|
@@ -548,51 +678,50 @@ function ideaRows(ideas: Idea[], ctx: RenderContext): string[][] {
|
|
|
548
678
|
return ideas.map((idea) => [
|
|
549
679
|
`<span id="idea-${idea.id}">#${idea.id}</span>`,
|
|
550
680
|
e(idea.text),
|
|
551
|
-
idea.owner ? authorLink(idea.owner, ctx
|
|
681
|
+
idea.owner ? authorLink(idea.owner, scopeRunId(ctx)) : "",
|
|
552
682
|
e(idea.note ?? ""),
|
|
553
683
|
]);
|
|
554
684
|
}
|
|
555
685
|
|
|
556
|
-
|
|
557
|
-
const filters = [
|
|
558
|
-
vm.owner ? link("/ideas", "all") : "all",
|
|
559
|
-
...vm.owners.map((o) => (o === vm.owner ? e(o) : link(`/ideas${query({ owner: o })}`, o))),
|
|
560
|
-
];
|
|
686
|
+
function ideasBody(vm: IdeasVm, ctx: RenderContext): string {
|
|
561
687
|
const sections: Array<[string, Idea[]]> = [
|
|
562
688
|
["in progress", vm.inProgress], ["open", vm.open], ["done", vm.done], ["ruled out", vm.ruledOut],
|
|
563
689
|
];
|
|
564
|
-
|
|
565
|
-
`<
|
|
566
|
-
|
|
567
|
-
.map(([title, ideas]) => `<h2>${e(title)} (${ideas.length})</h2>${table(["id", "idea", "owner", "note"], ideaRows(ideas, ctx))}`)
|
|
568
|
-
.join("");
|
|
569
|
-
return page("ideas", [{ text: "ideas" }], body, ctx);
|
|
690
|
+
return sections
|
|
691
|
+
.map(([title, ideas]) => `<h2>${e(title)} (${ideas.length})</h2>${table(["id", "idea", "owner", "note"], ideaRows(ideas, ctx))}`)
|
|
692
|
+
.join("");
|
|
570
693
|
}
|
|
571
694
|
|
|
572
|
-
function
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
695
|
+
export function renderIdeas(vm: IdeasVm, ctx: RenderContext): string {
|
|
696
|
+
const base = `${scopeBase(ctx.scope)}/ideas`;
|
|
697
|
+
const filters = [
|
|
698
|
+
vm.owner ? link(base, "all") : "all",
|
|
699
|
+
...vm.owners.map((o) => (o === vm.owner ? e(o) : link(`${base}${query({ owner: o })}`, o))),
|
|
700
|
+
];
|
|
701
|
+
return page("ideas", [{ text: "ideas" }], `<p>owner: ${filters.join(" ")}</p>${ideasBody(vm, ctx)}`, ctx);
|
|
577
702
|
}
|
|
578
703
|
|
|
579
|
-
|
|
580
|
-
const other = vm.order === "newest" ? "oldest" : "newest";
|
|
704
|
+
function boardBody(vm: BoardVm, ctx: RenderContext): string {
|
|
581
705
|
const rows = vm.entries.map((entry: BoardEntry) => [
|
|
582
706
|
entry.runId ? link(runHref(entry.runId), entry.runId) : "",
|
|
583
707
|
e(entry.time),
|
|
584
|
-
authorLink(entry.author, entry.runId || ctx
|
|
708
|
+
authorLink(entry.author, entry.runId || scopeRunId(ctx)),
|
|
585
709
|
pre(entry.text),
|
|
586
710
|
]);
|
|
711
|
+
return table(["run", "time", "author", "message"], rows, "no posts");
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export function renderBoard(vm: BoardVm, ctx: RenderContext): string {
|
|
715
|
+
const other = vm.order === "newest" ? "oldest" : "newest";
|
|
587
716
|
const body =
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
table(["run", "time", "author", "message"], rows, "no posts");
|
|
717
|
+
`<p>order: ${vm.order} ${link(`${scopeBase(ctx.scope)}/board${query({ order: other })}`, `[${other} first]`)}</p>` +
|
|
718
|
+
boardBody(vm, ctx);
|
|
591
719
|
return page("board", [{ text: "board" }], body, ctx);
|
|
592
720
|
}
|
|
593
721
|
|
|
594
|
-
|
|
595
|
-
|
|
722
|
+
/** The findings page of the scope this finding is shown in, with a body opened or closed. */
|
|
723
|
+
function findingsHref(vm: FindingsVm, ctx: RenderContext, open: number[]): string {
|
|
724
|
+
return `${scopeBase(ctx.scope)}/findings${query({ view: vm.view, open: open.length > 0 ? open.join(",") : undefined })}`;
|
|
596
725
|
}
|
|
597
726
|
|
|
598
727
|
/** The body is expanded when its id is in the URL, so the state survives auto-refresh; the export shows it inline. */
|
|
@@ -600,13 +729,14 @@ function findingBody(f: FindingEntry, vm: FindingsVm, ctx: RenderContext): strin
|
|
|
600
729
|
if (!f.body) return "";
|
|
601
730
|
if (ctx.export) return pre(f.body);
|
|
602
731
|
if (vm.open.includes(f.id)) {
|
|
603
|
-
return pre(f.body) + link(findingsHref(vm, vm.open.filter((id) => id !== f.id)), "[hide body]");
|
|
732
|
+
return pre(f.body) + link(findingsHref(vm, ctx, vm.open.filter((id) => id !== f.id)), "[hide body]");
|
|
604
733
|
}
|
|
605
|
-
return ` ${link(findingsHref(vm, [...vm.open, f.id]), "[show body]")}`;
|
|
734
|
+
return ` ${link(findingsHref(vm, ctx, [...vm.open, f.id]), "[show body]")}`;
|
|
606
735
|
}
|
|
607
736
|
|
|
608
737
|
function findingRow(f: FindingEntry, vm: FindingsVm, ctx: RenderContext): string[] {
|
|
609
|
-
const
|
|
738
|
+
const ideas = f.runId ? `${runHref(f.runId)}/ideas` : "/ideas";
|
|
739
|
+
const idea = f.ideaId === undefined ? "" : link(`${ideas}#idea-${f.ideaId}`, `#${f.ideaId}`);
|
|
610
740
|
const authors = [f.author, ...f.corroborators].map((a) => authorLink(a, f.runId)).join(", ");
|
|
611
741
|
const title = (f.supersededBy !== undefined ? "<b>SUPERSEDED</b> " : "") + e(f.title);
|
|
612
742
|
return [
|
|
@@ -631,83 +761,138 @@ const FINDING_HEADERS = [
|
|
|
631
761
|
"superseded by", "review status", "title",
|
|
632
762
|
];
|
|
633
763
|
|
|
764
|
+
function findingsBody(vm: FindingsVm, ctx: RenderContext): string {
|
|
765
|
+
if (!vm.clusters) return table(FINDING_HEADERS, vm.findings.map((f) => findingRow(f, vm, ctx)), "no findings");
|
|
766
|
+
if (vm.clusters.length === 0) return "<p>no findings</p>";
|
|
767
|
+
return vm.clusters
|
|
768
|
+
.map((cluster, i) => {
|
|
769
|
+
const members = cluster.members.join(", ") || "none";
|
|
770
|
+
const deltas = cluster.deltaMembers.length > 0 ? `; deltas ${cluster.deltaMembers.join(", ")}` : "";
|
|
771
|
+
return (
|
|
772
|
+
`<h2>cluster ${i + 1}: F${cluster.representative}, members ${e(members)}${e(deltas)}</h2>` +
|
|
773
|
+
table(FINDING_HEADERS, cluster.findings.map((f) => findingRow(f, vm, ctx)))
|
|
774
|
+
);
|
|
775
|
+
})
|
|
776
|
+
.join("");
|
|
777
|
+
}
|
|
778
|
+
|
|
634
779
|
export function renderFindings(vm: FindingsVm, ctx: RenderContext): string {
|
|
635
780
|
const other = vm.view === "list" ? "clusters" : "list";
|
|
636
781
|
const open = vm.open.length > 0 ? vm.open.join(",") : undefined;
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
if (vm.clusters) {
|
|
641
|
-
body += vm.clusters
|
|
642
|
-
.map((cluster, i) => {
|
|
643
|
-
const members = cluster.members.join(", ") || "none";
|
|
644
|
-
const deltas = cluster.deltaMembers.length > 0 ? `; deltas ${cluster.deltaMembers.join(", ")}` : "";
|
|
645
|
-
return (
|
|
646
|
-
`<h2>cluster ${i + 1}: F${cluster.representative}, members ${e(members)}${e(deltas)}</h2>` +
|
|
647
|
-
table(FINDING_HEADERS, cluster.findings.map((f) => findingRow(f, vm, ctx)))
|
|
648
|
-
);
|
|
649
|
-
})
|
|
650
|
-
.join("");
|
|
651
|
-
if (vm.clusters.length === 0) body += "<p>no findings</p>";
|
|
652
|
-
} else {
|
|
653
|
-
body += table(FINDING_HEADERS, vm.findings.map((f) => findingRow(f, vm, ctx)), "no findings");
|
|
654
|
-
}
|
|
782
|
+
const body =
|
|
783
|
+
`<p>view: ${vm.view} ${link(`${scopeBase(ctx.scope)}/findings${query({ view: other, open })}`, `[${other}]`)}</p>` +
|
|
784
|
+
findingsBody(vm, ctx);
|
|
655
785
|
return page("findings", [{ text: "findings" }], body, ctx);
|
|
656
786
|
}
|
|
657
787
|
|
|
658
|
-
|
|
788
|
+
function claimsBody(vm: ClaimsVm, ctx: RenderContext): string {
|
|
659
789
|
const body = vm.groups
|
|
660
790
|
.map(
|
|
661
791
|
(group) =>
|
|
662
792
|
`<h2>${e(group.state)} (${group.claims.length})</h2>` +
|
|
663
793
|
table(
|
|
664
794
|
["id", "statement", "owner", "notes"],
|
|
665
|
-
group.claims.map((c: ClaimEntry) => [anchor(`C${c.id}`), e(c.statement), authorLink(c.owner, ctx
|
|
795
|
+
group.claims.map((c: ClaimEntry) => [anchor(`C${c.id}`), e(c.statement), authorLink(c.owner, scopeRunId(ctx)), e(c.notes.join(" | "))]),
|
|
666
796
|
),
|
|
667
797
|
)
|
|
668
798
|
.join("");
|
|
669
|
-
return
|
|
799
|
+
return body || "<p>no claims</p>";
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
export function renderClaims(vm: ClaimsVm, ctx: RenderContext): string {
|
|
803
|
+
return page("claims", [{ text: "claims" }], claimsBody(vm, ctx), ctx);
|
|
670
804
|
}
|
|
671
805
|
|
|
672
806
|
function factRows(facts: FactEntry[], ctx: RenderContext): string[][] {
|
|
673
807
|
return facts.map((f) => [
|
|
674
808
|
anchor(`T${f.id}`),
|
|
675
809
|
e(f.text),
|
|
676
|
-
authorLink(f.author, ctx
|
|
810
|
+
authorLink(f.author, scopeRunId(ctx)),
|
|
677
811
|
f.deltaOf === undefined ? "" : `T${f.deltaOf}`,
|
|
678
812
|
]);
|
|
679
813
|
}
|
|
680
814
|
|
|
681
|
-
|
|
815
|
+
function factsBody(vm: FactsVm, ctx: RenderContext): string {
|
|
682
816
|
const headers = ["id", "fact", "author", "delta of"];
|
|
683
|
-
|
|
684
|
-
vm.
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
table(headers, factRows(vm.all, ctx), "no facts");
|
|
689
|
-
return page("facts", [{ text: "facts" }], body, ctx);
|
|
817
|
+
return vm.attribution === "split"
|
|
818
|
+
? `<h2>this run (${vm.thisRun.length})</h2>${table(headers, factRows(vm.thisRun, ctx))}` +
|
|
819
|
+
`<h2>carried from earlier runs (${vm.carried.length})</h2>${table(headers, factRows(vm.carried, ctx))}`
|
|
820
|
+
: `<p>${vm.run ? "per-run attribution is only available for the active run; " : ""}all facts:</p>` +
|
|
821
|
+
table(headers, factRows(vm.all, ctx), "no facts");
|
|
690
822
|
}
|
|
691
823
|
|
|
692
|
-
export function
|
|
824
|
+
export function renderFacts(vm: FactsVm, ctx: RenderContext): string {
|
|
825
|
+
return page("facts", [{ text: "facts" }], factsBody(vm, ctx), ctx);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function resourcesBody(vm: ResourcesVm, ctx: RenderContext): string {
|
|
693
829
|
const rows = vm.resources.map((resource) => [
|
|
694
830
|
anchor(`R${resource.id}`),
|
|
695
831
|
e(resource.name),
|
|
696
|
-
authorLink(resource.owner, ctx
|
|
832
|
+
authorLink(resource.owner, scopeRunId(ctx)),
|
|
697
833
|
e(resource.note ?? ""),
|
|
698
|
-
resource.reusers.map((name) => authorLink(name, ctx
|
|
699
|
-
resource.citedBy.map((name) => authorLink(name, ctx
|
|
834
|
+
resource.reusers.map((name) => authorLink(name, scopeRunId(ctx))).join(", "),
|
|
835
|
+
resource.citedBy.map((name) => authorLink(name, scopeRunId(ctx))).join(", "),
|
|
700
836
|
e(resource.cleanup ?? ""),
|
|
701
837
|
]);
|
|
702
838
|
const scope = vm.run
|
|
703
839
|
? `<p>cited by: non-owners whose findings, evidence, reviews or leases name the resource in run ${e(vm.run)}</p>`
|
|
704
840
|
: "<p>cited by: needs an active run; only explicit reclaims are shown</p>";
|
|
705
|
-
return
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
841
|
+
return scope + table(["id", "name", "owner", "note", "reused by", "cited by", "cleanup"], rows, "no shared resources claimed");
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
export function renderResources(vm: ResourcesVm, ctx: RenderContext): string {
|
|
845
|
+
return page("resources", [{ text: "resources" }], resourcesBody(vm, ctx), ctx);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// ---------------------------------------------------------------------------
|
|
849
|
+
// Swarm ledgers: every horde's ledger on one page, under per-horde headings
|
|
850
|
+
// ---------------------------------------------------------------------------
|
|
851
|
+
|
|
852
|
+
/** Render a ledger aggregated across a swarm: one `<h2>hN</h2>` section per horde, ids stay unambiguous. */
|
|
853
|
+
function swarmLedgerPage<T>(
|
|
854
|
+
kind: string,
|
|
855
|
+
vm: SwarmLedgerVm<T>,
|
|
856
|
+
body: (sectionVm: T, ctx: RenderContext) => string,
|
|
857
|
+
ctx: RenderContext,
|
|
858
|
+
): string {
|
|
859
|
+
const crumbs: Crumb[] = [{ text: "runs", href: "/" }, { text: `swarm ${vm.swarmId}`, href: swarmHref(vm.swarmId) }, { text: kind }];
|
|
860
|
+
// Links inside a section (member names, finding bodies, idea anchors) belong to that horde.
|
|
861
|
+
const sectionCtx = (runId: string): RenderContext => ({ ...ctx, scope: { kind: "run", runId } });
|
|
862
|
+
const inner =
|
|
863
|
+
vm.sections.length === 0
|
|
864
|
+
? "<p>this swarm has no hordes</p>"
|
|
865
|
+
: `<p>${kind} across ${vm.sections.length} hordes; ids repeat per horde, so each section is one horde's ledger</p>` +
|
|
866
|
+
vm.sections
|
|
867
|
+
.map((section) =>
|
|
868
|
+
`<h2>${e(section.label)} — ${link(pageHref(runHref(section.runId), ctx.pageKind), section.runId)}</h2>` +
|
|
869
|
+
body(section.vm, sectionCtx(section.runId)))
|
|
870
|
+
.join("");
|
|
871
|
+
return page(kind, crumbs, inner, ctx);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
export function renderSwarmIdeas(vm: SwarmLedgerVm<IdeasVm>, ctx: RenderContext): string {
|
|
875
|
+
return swarmLedgerPage("ideas", vm, ideasBody, ctx);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
export function renderSwarmBoard(vm: SwarmLedgerVm<BoardVm>, ctx: RenderContext): string {
|
|
879
|
+
return swarmLedgerPage("board", vm, boardBody, ctx);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
export function renderSwarmFindings(vm: SwarmLedgerVm<FindingsVm>, ctx: RenderContext): string {
|
|
883
|
+
return swarmLedgerPage("findings", vm, findingsBody, ctx);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
export function renderSwarmClaims(vm: SwarmLedgerVm<ClaimsVm>, ctx: RenderContext): string {
|
|
887
|
+
return swarmLedgerPage("claims", vm, claimsBody, ctx);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
export function renderSwarmFacts(vm: SwarmLedgerVm<FactsVm>, ctx: RenderContext): string {
|
|
891
|
+
return swarmLedgerPage("facts", vm, factsBody, ctx);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
export function renderSwarmResources(vm: SwarmLedgerVm<ResourcesVm>, ctx: RenderContext): string {
|
|
895
|
+
return swarmLedgerPage("resources", vm, resourcesBody, ctx);
|
|
711
896
|
}
|
|
712
897
|
|
|
713
898
|
// ---------------------------------------------------------------------------
|
|
@@ -754,3 +939,105 @@ export function renderGraph(vm: RunGraphVm, ctx: RenderContext): string {
|
|
|
754
939
|
export function renderGraphMermaid(vm: RunGraphVm, ctx: RenderContext): string {
|
|
755
940
|
return renderRunGraphMermaid(vm.graph, ctx.origin ?? "");
|
|
756
941
|
}
|
|
942
|
+
|
|
943
|
+
// ---------------------------------------------------------------------------
|
|
944
|
+
// Swarms
|
|
945
|
+
// ---------------------------------------------------------------------------
|
|
946
|
+
|
|
947
|
+
const SWARM_HORDE_HEADERS = [
|
|
948
|
+
"horde", "run", "state", "outcome", "members", "running", "done", "failed", "stopped", "turns", "in", "out",
|
|
949
|
+
"cost", "findings", "accepted artifacts", "elapsed", "workstream",
|
|
950
|
+
];
|
|
951
|
+
|
|
952
|
+
function swarmHordeRow(row: SwarmVm["rows"][number]): string[] {
|
|
953
|
+
return [
|
|
954
|
+
e(row.label),
|
|
955
|
+
link(runHref(row.runId), row.runId),
|
|
956
|
+
stateCell(row.state),
|
|
957
|
+
e(row.outcome ?? ""),
|
|
958
|
+
String(row.count),
|
|
959
|
+
String(row.counts.running + row.counts.starting),
|
|
960
|
+
String(row.counts.done),
|
|
961
|
+
String(row.counts.failed),
|
|
962
|
+
String(row.counts.stopped),
|
|
963
|
+
String(row.totals.turns),
|
|
964
|
+
formatTokens(row.totals.input),
|
|
965
|
+
formatTokens(row.totals.output),
|
|
966
|
+
cost(row.totals.cost),
|
|
967
|
+
String(row.findings),
|
|
968
|
+
String(row.acceptedArtifacts),
|
|
969
|
+
row.elapsedMs === undefined ? "" : formatElapsed(row.elapsedMs),
|
|
970
|
+
e(oneLine(row.slice ?? "", 120)),
|
|
971
|
+
];
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function swarmHeader(vm: SwarmVm): string {
|
|
975
|
+
const rows: Array<[string, string]> = [
|
|
976
|
+
["state", stateCell(vm.state) + (vm.state === "pending" ? " (the planner is partitioning the task, or the launch did not reach the hordes)" : "")],
|
|
977
|
+
["prompt", pre(vm.prompt)],
|
|
978
|
+
["hordes", `${vm.hordes} x ${vm.count ?? "?"} members`],
|
|
979
|
+
["started", e(vm.started ?? "")],
|
|
980
|
+
["elapsed", vm.elapsedMs === undefined ? "" : formatElapsed(vm.elapsedMs)],
|
|
981
|
+
["until stop", vm.untilStop ? "yes" : "no"],
|
|
982
|
+
["merge", e(vm.merge)],
|
|
983
|
+
];
|
|
984
|
+
if (vm.notStarted) rows.push(["not started", e(vm.notStarted)]);
|
|
985
|
+
if (vm.partition.length > 0) {
|
|
986
|
+
rows.push(["partition", pre(vm.partition.map((slice, index) => `h${index + 1}: ${slice}`).join("\n"))]);
|
|
987
|
+
}
|
|
988
|
+
return grid(rows);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function swarmFiles(vm: SwarmVm): string {
|
|
992
|
+
const cells = SWARM_FILES.map((name) =>
|
|
993
|
+
vm.files[name] ? link(`${swarmHref(vm.id)}/files/${name}`, `${name}.md`) : `${name}.md: not yet`,
|
|
994
|
+
);
|
|
995
|
+
return `<p>${cells.join(" | ")} | ${link(`${swarmHref(vm.id)}/graph`, "swarm graph")}</p>`;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
export function renderSwarm(vm: SwarmVm, ctx: RenderContext): string {
|
|
999
|
+
const totals = [
|
|
1000
|
+
"<b>total</b>", "", "", "",
|
|
1001
|
+
String(vm.rows.reduce((n, r) => n + r.count, 0)),
|
|
1002
|
+
String(vm.rows.reduce((n, r) => n + r.counts.running + r.counts.starting, 0)),
|
|
1003
|
+
String(vm.rows.reduce((n, r) => n + r.counts.done, 0)),
|
|
1004
|
+
String(vm.rows.reduce((n, r) => n + r.counts.failed, 0)),
|
|
1005
|
+
String(vm.rows.reduce((n, r) => n + r.counts.stopped, 0)),
|
|
1006
|
+
String(vm.rows.reduce((n, r) => n + r.totals.turns, 0)),
|
|
1007
|
+
formatTokens(vm.rows.reduce((n, r) => n + r.totals.input, 0)),
|
|
1008
|
+
formatTokens(vm.rows.reduce((n, r) => n + r.totals.output, 0)),
|
|
1009
|
+
cost(vm.rows.reduce((n, r) => n + r.totals.cost, 0)),
|
|
1010
|
+
String(vm.rows.reduce((n, r) => n + r.findings, 0)),
|
|
1011
|
+
String(vm.rows.reduce((n, r) => n + r.acceptedArtifacts, 0)),
|
|
1012
|
+
"", "",
|
|
1013
|
+
];
|
|
1014
|
+
const body =
|
|
1015
|
+
swarmHeader(vm) +
|
|
1016
|
+
`<h2>hordes</h2>` +
|
|
1017
|
+
table(SWARM_HORDE_HEADERS, [...vm.rows.map(swarmHordeRow), totals], "no hordes started") +
|
|
1018
|
+
`<h2>files</h2>${swarmFiles(vm)}`;
|
|
1019
|
+
return page(`swarm ${vm.id}`, [{ text: "runs", href: "/" }, { text: `swarm ${vm.id}` }], body, ctx);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function swarmGraphHref(swarmId: string, view: SwarmGraphVm["view"]): string {
|
|
1023
|
+
const seconds = (ms: number): string | undefined => (ms > 0 ? String(Math.round(ms / 1000)) : undefined);
|
|
1024
|
+
return `${swarmHref(swarmId)}/graph${query({ w: String(view.width), from: seconds(view.from), to: seconds(view.to) })}`;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** One lane per horde with its phases, and the merge step in the swarm lane. */
|
|
1028
|
+
export function renderSwarmGraph(vm: SwarmGraphVm, ctx: RenderContext): string {
|
|
1029
|
+
const { graph, view } = vm;
|
|
1030
|
+
const crumbs: Crumb[] = [{ text: "runs", href: "/" }, { text: `swarm ${vm.swarmId}`, href: swarmHref(vm.swarmId) }, { text: "graph" }];
|
|
1031
|
+
if (vm.empty) return page(`swarm graph ${vm.swarmId}`, crumbs, "<p>no events recorded for this swarm's hordes</p>", ctx);
|
|
1032
|
+
const widths = GRAPH_WIDTHS.map((width) =>
|
|
1033
|
+
width === view.width ? String(width) : link(swarmGraphHref(vm.swarmId, { ...view, width }), String(width)),
|
|
1034
|
+
);
|
|
1035
|
+
const summary =
|
|
1036
|
+
`${graph.lanes.length - 1} hordes, ${graph.nodes.length} lifecycle marks, ${formatElapsed(graph.end - graph.start)} elapsed; ` +
|
|
1037
|
+
"each horde's own graph is on its run page";
|
|
1038
|
+
const body =
|
|
1039
|
+
`<p>${e(summary)}</p>` +
|
|
1040
|
+
`<p>width: ${widths.join(" ")}</p>` +
|
|
1041
|
+
`<div class="graph">${renderRunGraphSvg(graph, view)}</div>`;
|
|
1042
|
+
return page(`swarm graph ${vm.swarmId}`, crumbs, body, ctx);
|
|
1043
|
+
}
|