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.
- package/README.md +42 -10
- package/docs/REFERENCE.md +141 -27
- package/package.json +1 -1
- package/src/args.ts +125 -50
- package/src/build-info.ts +65 -0
- package/src/collaboration-report.ts +23 -9
- package/src/dashboard-data.ts +480 -49
- package/src/dashboard-html.ts +390 -95
- package/src/dashboard.ts +205 -96
- package/src/ledger/collaboration.ts +36 -6
- package/src/ledger/facts.ts +52 -5
- package/src/ledger/files.ts +6 -1
- package/src/ledger/findings.ts +91 -1
- package/src/ledger/run-signals.ts +17 -2
- package/src/member.ts +62 -3
- package/src/prompt.ts +102 -21
- package/src/run/command.ts +68 -37
- package/src/run/completions.ts +32 -15
- package/src/run/controls.ts +74 -15
- package/src/run/finalize.ts +30 -21
- package/src/run/launch.ts +179 -34
- package/src/run/repair.ts +15 -7
- package/src/run/schedule.ts +43 -13
- package/src/run/setup.ts +36 -25
- package/src/run/solved.ts +125 -15
- package/src/run/supervise.ts +77 -10
- 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 +70 -5
- package/src/run/ui.ts +73 -28
- package/src/run-graph.ts +16 -9
- package/src/swarm-graph.ts +127 -0
- package/src/tool/actions.ts +206 -41
- package/src/tool/collaboration-actions.ts +75 -25
- package/src/tool/params.ts +10 -7
- package/src/tool/register.ts +13 -6
- package/src/widget.ts +80 -8
package/src/dashboard-html.ts
CHANGED
|
@@ -20,25 +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";
|
|
31
|
+
import { buildInfo } from "./build-info.js";
|
|
27
32
|
import { detailKey, formatClock, formatElapsed, formatTokens, oneLine } from "./format.js";
|
|
28
33
|
import { renderRunGraphMermaid } from "./run-graph-mermaid.js";
|
|
29
34
|
import { GRAPH_CSS, graphLegendHtml, renderRunGraphSvg } from "./run-graph-svg.js";
|
|
30
35
|
import type { TranscriptEntry, TranscriptPart } from "./session-log.js";
|
|
31
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
|
+
|
|
32
51
|
/** Per-request facts the renderers need beyond the view model. */
|
|
33
52
|
export interface RenderContext {
|
|
34
53
|
href?: string;
|
|
35
|
-
|
|
36
|
-
|
|
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;
|
|
37
61
|
refresh?: { on: boolean; toggleHref: string; seconds: number } | undefined;
|
|
38
62
|
/** Scheme and host of the request, for exports that need absolute links. */
|
|
39
63
|
origin?: string | undefined;
|
|
40
|
-
/** The run the collaboration and graph links target: this page's run, else the newest. */
|
|
41
|
-
navRun?: string | undefined;
|
|
42
64
|
/** Rendering for the static export: no refresh, no export control, native disclosure. */
|
|
43
65
|
export?: boolean | undefined;
|
|
44
66
|
}
|
|
@@ -53,8 +75,8 @@ const CSS =
|
|
|
53
75
|
"table{border-collapse:collapse;margin:4px 0}" +
|
|
54
76
|
"th,td{border:1px solid #000;padding:2px 6px;vertical-align:top;text-align:left}" +
|
|
55
77
|
"th{background:#eee}pre{white-space:pre-wrap;word-break:break-word;margin:0}" +
|
|
56
|
-
"nav a{margin-right:8px}.r{text-align:right}h1,h2{font-size:13px;margin:8px 0 4px}" +
|
|
57
|
-
"details>summary{cursor:pointer}a{color:#000}" +
|
|
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}" +
|
|
79
|
+
"details>summary{cursor:pointer}a{color:#000}footer{margin-top:16px;color:#666}footer a{color:#666}" +
|
|
58
80
|
".export{position:absolute;top:8px;right:8px;line-height:0}" +
|
|
59
81
|
".modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7)}" +
|
|
60
82
|
".modal:target{display:flex;align-items:center;justify-content:center}" +
|
|
@@ -122,21 +144,92 @@ function memberHref(runId: string, member: string): string {
|
|
|
122
144
|
return `${runHref(runId)}/members/${encodeURIComponent(member)}`;
|
|
123
145
|
}
|
|
124
146
|
|
|
125
|
-
function
|
|
126
|
-
|
|
127
|
-
return link(`${runHref(run)}/collaboration`, "collaboration") + link(`${runHref(run)}/graph`, "graph");
|
|
147
|
+
function swarmHref(swarmId: string): string {
|
|
148
|
+
return `/swarms/${encodeURIComponent(swarmId)}`;
|
|
128
149
|
}
|
|
129
150
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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}`;
|
|
133
199
|
return (
|
|
134
|
-
`<
|
|
135
|
-
|
|
136
|
-
`${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>`
|
|
137
202
|
);
|
|
138
203
|
}
|
|
139
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
|
+
|
|
140
233
|
/** An id cell that deep links, including the run graph, can target. */
|
|
141
234
|
function anchor(id: string): string {
|
|
142
235
|
return `<span id="${e(id)}">${e(id)}</span>`;
|
|
@@ -154,21 +247,37 @@ function messageThread(m: PeerMessage): string {
|
|
|
154
247
|
return lines.join("\n");
|
|
155
248
|
}
|
|
156
249
|
|
|
157
|
-
|
|
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 {
|
|
158
252
|
const { report } = vm;
|
|
159
253
|
const { state } = report;
|
|
160
254
|
const messages = table(["request", "owner", "state / age", "target", "question / response"], state.messages.map((m) => [
|
|
161
255
|
anchor(m.id), e(`${m.sender} -> ${m.to}`), e(`${m.state}${m.blocking ? " BLOCKING" : ""} / ${formatElapsed(Date.now() - Date.parse(m.createdAt))}`),
|
|
162
256
|
e(`${m.target}@${m.revision}`), pre(messageThread(m)),
|
|
163
257
|
]));
|
|
164
|
-
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"}\nEvidence: ${r.evidence.join(", ")}\n${r.text}`)]));
|
|
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}`)]));
|
|
165
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")]));
|
|
166
|
-
const
|
|
167
|
-
return page(`Collaboration ${report.runId}`, [{ text: "runs", href: "/" }, { text: report.runId, href: runHref(report.runId) }, { text: "collaboration" }],
|
|
260
|
+
const summary =
|
|
168
261
|
(vm.historical ? "<p>Historical run: no delivery/revision history was recorded.</p>" : "") +
|
|
169
262
|
`<h2>Acceptance issues</h2>${pre(report.issues.join("\n") || "none recorded")}<h2>Requests</h2>${messages}<h2>Artifacts</h2>${artifacts}<h2>Reviews</h2>${reviews}` +
|
|
170
|
-
`<h2>Resource readiness and leases</h2>${pre(JSON.stringify(state.resources, null, 2))}<h2>Metrics</h2>${pre(JSON.stringify(report.metrics, null, 2))}
|
|
171
|
-
|
|
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);
|
|
172
281
|
}
|
|
173
282
|
|
|
174
283
|
function crumbLine(crumbs: Crumb[]): string {
|
|
@@ -230,13 +339,20 @@ function nativeDetails(body: string): string {
|
|
|
230
339
|
return body.replace(/<details data-expand="[a-f0-9]+">/g, "<details>");
|
|
231
340
|
}
|
|
232
341
|
|
|
342
|
+
/** Which agent-horde produced the page: the package version and, from a checkout, the commit. */
|
|
343
|
+
function footer(): string {
|
|
344
|
+
const { name, version, commit, repository } = buildInfo();
|
|
345
|
+
const at = commit ? ` at ${repository ? link(`${repository}/commit/${commit.replace(/-dirty$/, "")}`, commit) : e(commit)}` : "";
|
|
346
|
+
return `<footer>${e(name)} ${e(version)}${at}</footer>`;
|
|
347
|
+
}
|
|
348
|
+
|
|
233
349
|
export function page(title: string, crumbs: Crumb[], body: string, ctx: RenderContext, head = ""): string {
|
|
234
350
|
body = ctx.export ? nativeDetails(body) : persistentDetails(body, ctx.href);
|
|
235
351
|
const meta = ctx.refresh?.on ? `<meta http-equiv="refresh" content="${ctx.refresh.seconds}">` : head;
|
|
236
352
|
return (
|
|
237
353
|
`<!doctype html><html><head><meta charset="utf-8"><title>${e(title)}</title>${meta}` +
|
|
238
354
|
`<style>${CSS}</style></head><body>${exportControl(ctx)}${nav(ctx)}${crumbLine(crumbs)}${refreshLine(ctx)}` +
|
|
239
|
-
`<h1>${e(title)}</h1>${body}</body></html>`
|
|
355
|
+
`<h1>${e(title)}</h1>${body}${footer()}</body></html>`
|
|
240
356
|
);
|
|
241
357
|
}
|
|
242
358
|
|
|
@@ -261,10 +377,31 @@ function cost(value: number): string {
|
|
|
261
377
|
return `$${value.toFixed(4)}`;
|
|
262
378
|
}
|
|
263
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
|
+
|
|
264
400
|
export function renderRunIndex(vm: RunIndexVm, ctx: RenderContext): string {
|
|
265
401
|
const rows = vm.runs.map((run) => [
|
|
266
402
|
link(runHref(run.id), run.id),
|
|
267
|
-
run.
|
|
403
|
+
run.swarm ? link(swarmHref(run.swarm), run.swarm) : "",
|
|
404
|
+
stateCell(run.state),
|
|
268
405
|
e(run.started ?? ""),
|
|
269
406
|
num(run.count),
|
|
270
407
|
e(run.model ?? ""),
|
|
@@ -272,8 +409,8 @@ export function renderRunIndex(vm: RunIndexVm, ctx: RenderContext): string {
|
|
|
272
409
|
e(oneLine(run.prompt, 100)),
|
|
273
410
|
...RUN_FILES.map((name) => yn(run.files[name])),
|
|
274
411
|
]);
|
|
275
|
-
const headers = ["run", "state", "started", "members", "model", "outcome", "prompt", ...RUN_FILES.map((n) => n.toLowerCase())];
|
|
276
|
-
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);
|
|
277
414
|
}
|
|
278
415
|
|
|
279
416
|
function memberRow(runId: string, m: MemberRowVm): string[] {
|
|
@@ -523,7 +660,8 @@ export function renderSessions(vm: SessionsVm, ctx: RenderContext): string {
|
|
|
523
660
|
}
|
|
524
661
|
|
|
525
662
|
export function renderFile(vm: FileVm, ctx: RenderContext): string {
|
|
526
|
-
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` }];
|
|
527
665
|
const note = vm.truncated ? `<p>showing the first ${vm.content.length} of ${vm.bytes} characters</p>` : "";
|
|
528
666
|
return page(`${vm.name}.md`, crumbs, note + pre(vm.content), ctx);
|
|
529
667
|
}
|
|
@@ -540,51 +678,50 @@ function ideaRows(ideas: Idea[], ctx: RenderContext): string[][] {
|
|
|
540
678
|
return ideas.map((idea) => [
|
|
541
679
|
`<span id="idea-${idea.id}">#${idea.id}</span>`,
|
|
542
680
|
e(idea.text),
|
|
543
|
-
idea.owner ? authorLink(idea.owner, ctx
|
|
681
|
+
idea.owner ? authorLink(idea.owner, scopeRunId(ctx)) : "",
|
|
544
682
|
e(idea.note ?? ""),
|
|
545
683
|
]);
|
|
546
684
|
}
|
|
547
685
|
|
|
548
|
-
|
|
549
|
-
const filters = [
|
|
550
|
-
vm.owner ? link("/ideas", "all") : "all",
|
|
551
|
-
...vm.owners.map((o) => (o === vm.owner ? e(o) : link(`/ideas${query({ owner: o })}`, o))),
|
|
552
|
-
];
|
|
686
|
+
function ideasBody(vm: IdeasVm, ctx: RenderContext): string {
|
|
553
687
|
const sections: Array<[string, Idea[]]> = [
|
|
554
688
|
["in progress", vm.inProgress], ["open", vm.open], ["done", vm.done], ["ruled out", vm.ruledOut],
|
|
555
689
|
];
|
|
556
|
-
|
|
557
|
-
`<
|
|
558
|
-
|
|
559
|
-
.map(([title, ideas]) => `<h2>${e(title)} (${ideas.length})</h2>${table(["id", "idea", "owner", "note"], ideaRows(ideas, ctx))}`)
|
|
560
|
-
.join("");
|
|
561
|
-
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("");
|
|
562
693
|
}
|
|
563
694
|
|
|
564
|
-
function
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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);
|
|
569
702
|
}
|
|
570
703
|
|
|
571
|
-
|
|
572
|
-
const other = vm.order === "newest" ? "oldest" : "newest";
|
|
704
|
+
function boardBody(vm: BoardVm, ctx: RenderContext): string {
|
|
573
705
|
const rows = vm.entries.map((entry: BoardEntry) => [
|
|
574
706
|
entry.runId ? link(runHref(entry.runId), entry.runId) : "",
|
|
575
707
|
e(entry.time),
|
|
576
|
-
authorLink(entry.author, entry.runId || ctx
|
|
708
|
+
authorLink(entry.author, entry.runId || scopeRunId(ctx)),
|
|
577
709
|
pre(entry.text),
|
|
578
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";
|
|
579
716
|
const body =
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
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);
|
|
583
719
|
return page("board", [{ text: "board" }], body, ctx);
|
|
584
720
|
}
|
|
585
721
|
|
|
586
|
-
|
|
587
|
-
|
|
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 })}`;
|
|
588
725
|
}
|
|
589
726
|
|
|
590
727
|
/** The body is expanded when its id is in the URL, so the state survives auto-refresh; the export shows it inline. */
|
|
@@ -592,13 +729,14 @@ function findingBody(f: FindingEntry, vm: FindingsVm, ctx: RenderContext): strin
|
|
|
592
729
|
if (!f.body) return "";
|
|
593
730
|
if (ctx.export) return pre(f.body);
|
|
594
731
|
if (vm.open.includes(f.id)) {
|
|
595
|
-
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]");
|
|
596
733
|
}
|
|
597
|
-
return ` ${link(findingsHref(vm, [...vm.open, f.id]), "[show body]")}`;
|
|
734
|
+
return ` ${link(findingsHref(vm, ctx, [...vm.open, f.id]), "[show body]")}`;
|
|
598
735
|
}
|
|
599
736
|
|
|
600
737
|
function findingRow(f: FindingEntry, vm: FindingsVm, ctx: RenderContext): string[] {
|
|
601
|
-
const
|
|
738
|
+
const ideas = f.runId ? `${runHref(f.runId)}/ideas` : "/ideas";
|
|
739
|
+
const idea = f.ideaId === undefined ? "" : link(`${ideas}#idea-${f.ideaId}`, `#${f.ideaId}`);
|
|
602
740
|
const authors = [f.author, ...f.corroborators].map((a) => authorLink(a, f.runId)).join(", ");
|
|
603
741
|
const title = (f.supersededBy !== undefined ? "<b>SUPERSEDED</b> " : "") + e(f.title);
|
|
604
742
|
return [
|
|
@@ -623,83 +761,138 @@ const FINDING_HEADERS = [
|
|
|
623
761
|
"superseded by", "review status", "title",
|
|
624
762
|
];
|
|
625
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
|
+
|
|
626
779
|
export function renderFindings(vm: FindingsVm, ctx: RenderContext): string {
|
|
627
780
|
const other = vm.view === "list" ? "clusters" : "list";
|
|
628
781
|
const open = vm.open.length > 0 ? vm.open.join(",") : undefined;
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
if (vm.clusters) {
|
|
633
|
-
body += vm.clusters
|
|
634
|
-
.map((cluster, i) => {
|
|
635
|
-
const members = cluster.members.join(", ") || "none";
|
|
636
|
-
const deltas = cluster.deltaMembers.length > 0 ? `; deltas ${cluster.deltaMembers.join(", ")}` : "";
|
|
637
|
-
return (
|
|
638
|
-
`<h2>cluster ${i + 1}: F${cluster.representative}, members ${e(members)}${e(deltas)}</h2>` +
|
|
639
|
-
table(FINDING_HEADERS, cluster.findings.map((f) => findingRow(f, vm, ctx)))
|
|
640
|
-
);
|
|
641
|
-
})
|
|
642
|
-
.join("");
|
|
643
|
-
if (vm.clusters.length === 0) body += "<p>no findings</p>";
|
|
644
|
-
} else {
|
|
645
|
-
body += table(FINDING_HEADERS, vm.findings.map((f) => findingRow(f, vm, ctx)), "no findings");
|
|
646
|
-
}
|
|
782
|
+
const body =
|
|
783
|
+
`<p>view: ${vm.view} ${link(`${scopeBase(ctx.scope)}/findings${query({ view: other, open })}`, `[${other}]`)}</p>` +
|
|
784
|
+
findingsBody(vm, ctx);
|
|
647
785
|
return page("findings", [{ text: "findings" }], body, ctx);
|
|
648
786
|
}
|
|
649
787
|
|
|
650
|
-
|
|
788
|
+
function claimsBody(vm: ClaimsVm, ctx: RenderContext): string {
|
|
651
789
|
const body = vm.groups
|
|
652
790
|
.map(
|
|
653
791
|
(group) =>
|
|
654
792
|
`<h2>${e(group.state)} (${group.claims.length})</h2>` +
|
|
655
793
|
table(
|
|
656
794
|
["id", "statement", "owner", "notes"],
|
|
657
|
-
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(" | "))]),
|
|
658
796
|
),
|
|
659
797
|
)
|
|
660
798
|
.join("");
|
|
661
|
-
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);
|
|
662
804
|
}
|
|
663
805
|
|
|
664
806
|
function factRows(facts: FactEntry[], ctx: RenderContext): string[][] {
|
|
665
807
|
return facts.map((f) => [
|
|
666
808
|
anchor(`T${f.id}`),
|
|
667
809
|
e(f.text),
|
|
668
|
-
authorLink(f.author, ctx
|
|
810
|
+
authorLink(f.author, scopeRunId(ctx)),
|
|
669
811
|
f.deltaOf === undefined ? "" : `T${f.deltaOf}`,
|
|
670
812
|
]);
|
|
671
813
|
}
|
|
672
814
|
|
|
673
|
-
|
|
815
|
+
function factsBody(vm: FactsVm, ctx: RenderContext): string {
|
|
674
816
|
const headers = ["id", "fact", "author", "delta of"];
|
|
675
|
-
|
|
676
|
-
vm.
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
table(headers, factRows(vm.all, ctx), "no facts");
|
|
681
|
-
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");
|
|
682
822
|
}
|
|
683
823
|
|
|
684
|
-
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 {
|
|
685
829
|
const rows = vm.resources.map((resource) => [
|
|
686
830
|
anchor(`R${resource.id}`),
|
|
687
831
|
e(resource.name),
|
|
688
|
-
authorLink(resource.owner, ctx
|
|
832
|
+
authorLink(resource.owner, scopeRunId(ctx)),
|
|
689
833
|
e(resource.note ?? ""),
|
|
690
|
-
resource.reusers.map((name) => authorLink(name, ctx
|
|
691
|
-
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(", "),
|
|
692
836
|
e(resource.cleanup ?? ""),
|
|
693
837
|
]);
|
|
694
838
|
const scope = vm.run
|
|
695
839
|
? `<p>cited by: non-owners whose findings, evidence, reviews or leases name the resource in run ${e(vm.run)}</p>`
|
|
696
840
|
: "<p>cited by: needs an active run; only explicit reclaims are shown</p>";
|
|
697
|
-
return
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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);
|
|
703
896
|
}
|
|
704
897
|
|
|
705
898
|
// ---------------------------------------------------------------------------
|
|
@@ -746,3 +939,105 @@ export function renderGraph(vm: RunGraphVm, ctx: RenderContext): string {
|
|
|
746
939
|
export function renderGraphMermaid(vm: RunGraphVm, ctx: RenderContext): string {
|
|
747
940
|
return renderRunGraphMermaid(vm.graph, ctx.origin ?? "");
|
|
748
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
|
+
}
|