agent-horde 0.1.4 → 0.1.6

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.
@@ -0,0 +1,192 @@
1
+ import * as path from "node:path";
2
+ import * as zlib from "node:zlib";
3
+
4
+ /** One rendered page; `text` pages (the Mermaid export) carry no links to rewrite. */
5
+ export interface ExportPage {
6
+ body: string;
7
+ text?: boolean | undefined;
8
+ }
9
+
10
+ /** Renders the dashboard page at a URL; must return an error page rather than throw. */
11
+ export type ExportRenderer = (url: URL) => ExportPage;
12
+
13
+ export interface ZipEntry {
14
+ path: string;
15
+ data: Buffer;
16
+ }
17
+
18
+ const BASE = "http://127.0.0.1";
19
+ const MAX_PAGES = 100_000;
20
+ const ZIP_MAX_ENTRIES = 0xffff;
21
+ const ZIP_MAX_BYTES = 0xffff_ffff;
22
+
23
+ /** One name component: no separators, and never a dot-only segment, so the zip cannot escape its folder. */
24
+ function safeName(text: string): string {
25
+ const safe = text.replace(/[^A-Za-z0-9._=~-]/g, "_");
26
+ return /^\.+$/.test(safe) ? safe.replace(/\./g, "_") : safe;
27
+ }
28
+
29
+ /** The file a dashboard URL becomes: `/` is index.html, query parameters are sorted into the name. */
30
+ export function exportFile(url: URL): string {
31
+ const segments = url.pathname.split("/").filter(Boolean).map((segment) => safeName(decodeURIComponent(segment)));
32
+ const stem = segments.length === 0 ? "index" : segments.join("/");
33
+ const params = [...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => safeName(`${k}=${v}`));
34
+ const name = `${stem}${params.map((pair) => `~${pair}`).join("")}`;
35
+ return url.pathname.endsWith(".mmd") ? name : `${name}.html`;
36
+ }
37
+
38
+ function canonical(href: string): URL {
39
+ const url = new URL(href, BASE);
40
+ url.hash = "";
41
+ const sorted = [...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b));
42
+ url.search = "";
43
+ for (const [k, v] of sorted) url.searchParams.append(k, v);
44
+ return url;
45
+ }
46
+
47
+ function unescapeHtml(text: string): string {
48
+ return text.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'");
49
+ }
50
+
51
+ function escapeHtml(text: string): string {
52
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
53
+ }
54
+
55
+ /** Rewrites every dashboard-absolute href in a page to a path relative to the page's own file. */
56
+ function rewriteLinks(body: string, file: string, visit: (url: URL) => string): string {
57
+ const dir = path.posix.dirname(file);
58
+ return body.replace(/href="([^"]*)"/g, (match, raw: string) => {
59
+ const href = unescapeHtml(raw);
60
+ if (!href.startsWith("/")) return match;
61
+ const hashAt = href.indexOf("#");
62
+ const hash = hashAt >= 0 ? href.slice(hashAt) : "";
63
+ const target = visit(canonical(hashAt >= 0 ? href.slice(0, hashAt) : href));
64
+ return `href="${escapeHtml(path.posix.relative(dir, target) + hash)}"`;
65
+ });
66
+ }
67
+
68
+ const yieldToLoop = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
69
+
70
+ /** Renders every page reachable from `/`, as files whose links point at each other, one page per event-loop turn. */
71
+ export async function* crawlDashboard(render: ExportRenderer): AsyncGenerator<ZipEntry> {
72
+ const files = new Map<string, string>();
73
+ const queue: URL[] = [];
74
+ const visit = (url: URL): string => {
75
+ const key = url.pathname + url.search;
76
+ const known = files.get(key);
77
+ if (known !== undefined) return known;
78
+ if (files.size >= MAX_PAGES) throw new Error(`dashboard export exceeds ${MAX_PAGES} pages`);
79
+ const file = exportFile(url);
80
+ files.set(key, file);
81
+ queue.push(url);
82
+ return file;
83
+ };
84
+ visit(new URL("/", BASE));
85
+ for (let i = 0; i < queue.length; i++) {
86
+ const url = queue[i] as URL;
87
+ const file = files.get(url.pathname + url.search) as string;
88
+ const page = render(url);
89
+ const body = page.text ? page.body : rewriteLinks(page.body, file, visit);
90
+ yield { path: file, data: Buffer.from(body, "utf8") };
91
+ await yieldToLoop();
92
+ }
93
+ }
94
+
95
+ function dosDateTime(date: Date): { time: number; date: number } {
96
+ const year = Math.min(Math.max(date.getUTCFullYear(), 1980), 2107);
97
+ return {
98
+ time: (date.getUTCHours() << 11) | (date.getUTCMinutes() << 5) | (date.getUTCSeconds() >> 1),
99
+ date: ((year - 1980) << 9) | ((date.getUTCMonth() + 1) << 5) | date.getUTCDate(),
100
+ };
101
+ }
102
+
103
+ interface ZipRecord {
104
+ name: Buffer;
105
+ method: number;
106
+ crc: number;
107
+ compressedSize: number;
108
+ size: number;
109
+ offset: number;
110
+ }
111
+
112
+ function zipHeader(signature: number, record: ZipRecord, stamp: { time: number; date: number }): Buffer {
113
+ const header = Buffer.alloc(26);
114
+ header.writeUInt16LE(20, 0);
115
+ header.writeUInt16LE(0x0800, 2);
116
+ header.writeUInt16LE(record.method, 4);
117
+ header.writeUInt16LE(stamp.time, 6);
118
+ header.writeUInt16LE(stamp.date, 8);
119
+ header.writeUInt32LE(record.crc, 10);
120
+ header.writeUInt32LE(record.compressedSize, 14);
121
+ header.writeUInt32LE(record.size, 18);
122
+ header.writeUInt16LE(record.name.length, 22);
123
+ header.writeUInt16LE(0, 24);
124
+ const sig = Buffer.alloc(4);
125
+ sig.writeUInt32LE(signature, 0);
126
+ return Buffer.concat([sig, header]);
127
+ }
128
+
129
+ function centralEntry(record: ZipRecord, stamp: { time: number; date: number }): Buffer {
130
+ const madeBy = Buffer.alloc(2);
131
+ madeBy.writeUInt16LE(20, 0);
132
+ const tail = Buffer.alloc(14);
133
+ tail.writeUInt16LE(0, 0);
134
+ tail.writeUInt16LE(0, 2);
135
+ tail.writeUInt16LE(0, 4);
136
+ tail.writeUInt32LE(0, 6);
137
+ tail.writeUInt32LE(record.offset, 10);
138
+ const header = zipHeader(0x02014b50, record, stamp);
139
+ return Buffer.concat([header.subarray(0, 4), madeBy, header.subarray(4), tail, record.name]);
140
+ }
141
+
142
+ /** A zip archive streamed one entry at a time: deflated where smaller, UTF-8 names, the given modification time. */
143
+ export async function* zipArchive(entries: AsyncIterable<ZipEntry> | Iterable<ZipEntry>, mtime = new Date()): AsyncGenerator<Buffer> {
144
+ const stamp = dosDateTime(mtime);
145
+ const records: ZipRecord[] = [];
146
+ let offset = 0;
147
+ for await (const entry of entries) {
148
+ if (records.length >= ZIP_MAX_ENTRIES) throw new Error(`dashboard export exceeds ${ZIP_MAX_ENTRIES} files; zip64 is not supported`);
149
+ const deflated = zlib.deflateRawSync(entry.data);
150
+ const compressed = deflated.length < entry.data.length ? deflated : entry.data;
151
+ const record: ZipRecord = {
152
+ name: Buffer.from(entry.path, "utf8"),
153
+ method: compressed === deflated ? 8 : 0,
154
+ crc: zlib.crc32(entry.data),
155
+ compressedSize: compressed.length,
156
+ size: entry.data.length,
157
+ offset,
158
+ };
159
+ const local = Buffer.concat([zipHeader(0x04034b50, record, stamp), record.name, compressed]);
160
+ records.push(record);
161
+ offset += local.length;
162
+ if (offset > ZIP_MAX_BYTES) throw new Error("dashboard export exceeds 4 GiB; zip64 is not supported");
163
+ yield local;
164
+ }
165
+ const central = Buffer.concat(records.map((record) => centralEntry(record, stamp)));
166
+ const end = Buffer.alloc(22);
167
+ end.writeUInt32LE(0x06054b50, 0);
168
+ end.writeUInt16LE(0, 4);
169
+ end.writeUInt16LE(0, 6);
170
+ end.writeUInt16LE(records.length, 8);
171
+ end.writeUInt16LE(records.length, 10);
172
+ end.writeUInt32LE(central.length, 12);
173
+ end.writeUInt32LE(offset, 16);
174
+ end.writeUInt16LE(0, 20);
175
+ yield Buffer.concat([central, end]);
176
+ }
177
+
178
+ async function* underFolder(entries: AsyncIterable<ZipEntry>, folder: string): AsyncGenerator<ZipEntry> {
179
+ for await (const entry of entries) yield { ...entry, path: `${folder}/${entry.path}` };
180
+ }
181
+
182
+ /** Crawls the dashboard into a zip, streamed as it is built, whose top-level folder holds index.html. */
183
+ export function exportDashboard(render: ExportRenderer, folder: string, now = new Date()): AsyncGenerator<Buffer> {
184
+ return zipArchive(underFolder(crawlDashboard(render), folder), now);
185
+ }
186
+
187
+ /** The whole stream in one buffer, for callers that want the finished archive. */
188
+ export async function collect(chunks: AsyncIterable<Buffer>): Promise<Buffer> {
189
+ const parts: Buffer[] = [];
190
+ for await (const chunk of chunks) parts.push(chunk);
191
+ return Buffer.concat(parts);
192
+ }
@@ -24,6 +24,7 @@ import type {
24
24
  TranscriptVm,
25
25
  } from "./dashboard-data.js";
26
26
  import { RUN_FILES } from "./dashboard-data.js";
27
+ import { buildInfo } from "./build-info.js";
27
28
  import { detailKey, formatClock, formatElapsed, formatTokens, oneLine } from "./format.js";
28
29
  import { renderRunGraphMermaid } from "./run-graph-mermaid.js";
29
30
  import { GRAPH_CSS, graphLegendHtml, renderRunGraphSvg } from "./run-graph-svg.js";
@@ -39,6 +40,8 @@ export interface RenderContext {
39
40
  origin?: string | undefined;
40
41
  /** The run the collaboration and graph links target: this page's run, else the newest. */
41
42
  navRun?: string | undefined;
43
+ /** Rendering for the static export: no refresh, no export control, native disclosure. */
44
+ export?: boolean | undefined;
42
45
  }
43
46
 
44
47
  interface Crumb {
@@ -52,9 +55,26 @@ const CSS =
52
55
  "th,td{border:1px solid #000;padding:2px 6px;vertical-align:top;text-align:left}" +
53
56
  "th{background:#eee}pre{white-space:pre-wrap;word-break:break-word;margin:0}" +
54
57
  "nav a{margin-right:8px}.r{text-align:right}h1,h2{font-size:13px;margin:8px 0 4px}" +
55
- "details>summary{cursor:pointer}a{color:#000}" +
58
+ "details>summary{cursor:pointer}a{color:#000}footer{margin-top:16px;color:#666}footer a{color:#666}" +
59
+ ".export{position:absolute;top:8px;right:8px;line-height:0}" +
60
+ ".modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7)}" +
61
+ ".modal:target{display:flex;align-items:center;justify-content:center}" +
62
+ ".modal-box{background:#fff;border:3px solid #000;padding:32px;width:min(90vw,760px);font-size:18px;line-height:1.5}" +
63
+ ".modal-box h2{font-size:24px;margin:0 0 16px}" +
64
+ ".modal-box .btn{display:inline-block;border:2px solid #000;padding:8px 16px;margin-right:12px}" +
56
65
  GRAPH_CSS;
57
66
 
67
+ const EXPORT_ICON =
68
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">' +
69
+ '<path d="M12 3v12m0 0l-5-5m5 5l5-5M4 17v3h16v-3" fill="none" stroke="currentColor" stroke-width="2" ' +
70
+ 'stroke-linecap="round" stroke-linejoin="round"/></svg>';
71
+
72
+ const EXPORT_WARNING =
73
+ "The zip holds every page of this dashboard as static HTML: every run, every member transcript and log, " +
74
+ "the run documents, the shared ledgers and the event audit. Transcripts include everything members read " +
75
+ "and produced on this machine: file contents, command output, paths, and any secrets or credentials they " +
76
+ "encountered. Review the export before sharing it with anyone who should not see all of that.";
77
+
58
78
  const ESCAPES: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
59
79
 
60
80
  function escapeHtml(text: string): string {
@@ -142,7 +162,7 @@ export function renderCollaboration(vm: CollaborationVm, ctx: RenderContext): st
142
162
  anchor(m.id), e(`${m.sender} -> ${m.to}`), e(`${m.state}${m.blocking ? " BLOCKING" : ""} / ${formatElapsed(Date.now() - Date.parse(m.createdAt))}`),
143
163
  e(`${m.target}@${m.revision}`), pre(messageThread(m)),
144
164
  ]));
145
- 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}`)]));
165
+ 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}`)]));
146
166
  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")]));
147
167
  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("");
148
168
  return page(`Collaboration ${report.runId}`, [{ text: "runs", href: "/" }, { text: report.runId, href: runHref(report.runId) }, { text: "collaboration" }],
@@ -182,13 +202,49 @@ function persistentDetails(body: string, href = "/"): string {
182
202
  });
183
203
  }
184
204
 
185
- export function page(title: string, crumbs: Crumb[], body: string, ctx: RenderContext): string {
186
- body = persistentDetails(body, ctx.href);
187
- const meta = ctx.refresh?.on ? `<meta http-equiv="refresh" content="${ctx.refresh.seconds}">` : "";
205
+ /** The export opens the modal on the paused page, so a refresh cannot close it; cancel restores the page. */
206
+ function exportControl(ctx: RenderContext): string {
207
+ if (ctx.export) return "";
208
+ const current = ctx.href ?? "/";
209
+ const paused = ctx.refresh?.on ? ctx.refresh.toggleHref : current;
210
+ return (
211
+ `<a class="export" href="${e(`${paused}#export`)}" title="export dashboard" aria-label="export dashboard">${EXPORT_ICON}</a>` +
212
+ `<div id="export" class="modal"><div class="modal-box"><h2>Export this dashboard?</h2><p>${EXPORT_WARNING}</p>` +
213
+ `<p><a class="btn" href="${e(`/export${query({ back: current })}`)}">download zip</a><a class="btn" href="${e(current)}">cancel</a></p></div></div>`
214
+ );
215
+ }
216
+
217
+ /** Shown once the download is confirmed; it starts the download itself and says what the wait is. */
218
+ export function renderExportStart(zipHref: string, backHref: string, ctx: RenderContext): string {
219
+ const body =
220
+ "<p>The archive is being packed one page at a time and streamed to your browser, so it appears in the " +
221
+ "browser's download list right away and grows until it is complete. A dashboard with long transcripts takes " +
222
+ "a while; the dashboard stays usable meanwhile.</p>" +
223
+ `<p>If no download appears, ${link(zipHref, "download it directly")}.</p>` +
224
+ `<p>${link(backHref, "back to the dashboard")}</p>`;
225
+ const start = `<meta http-equiv="refresh" content="0;url=${e(zipHref)}">`;
226
+ return page("exporting dashboard", [{ text: "runs", href: "/" }, { text: "export" }], body, { ...ctx, refresh: undefined }, start);
227
+ }
228
+
229
+ /** The static export has no server, so disclosure falls back to the browser's own details element. */
230
+ function nativeDetails(body: string): string {
231
+ return body.replace(/<details data-expand="[a-f0-9]+">/g, "<details>");
232
+ }
233
+
234
+ /** Which agent-horde produced the page: the package version and, from a checkout, the commit. */
235
+ function footer(): string {
236
+ const { name, version, commit, repository } = buildInfo();
237
+ const at = commit ? ` at ${repository ? link(`${repository}/commit/${commit.replace(/-dirty$/, "")}`, commit) : e(commit)}` : "";
238
+ return `<footer>${e(name)} ${e(version)}${at}</footer>`;
239
+ }
240
+
241
+ export function page(title: string, crumbs: Crumb[], body: string, ctx: RenderContext, head = ""): string {
242
+ body = ctx.export ? nativeDetails(body) : persistentDetails(body, ctx.href);
243
+ const meta = ctx.refresh?.on ? `<meta http-equiv="refresh" content="${ctx.refresh.seconds}">` : head;
188
244
  return (
189
245
  `<!doctype html><html><head><meta charset="utf-8"><title>${e(title)}</title>${meta}` +
190
- `<style>${CSS}</style></head><body>${nav(ctx)}${crumbLine(crumbs)}${refreshLine(ctx)}` +
191
- `<h1>${e(title)}</h1>${body}</body></html>`
246
+ `<style>${CSS}</style></head><body>${exportControl(ctx)}${nav(ctx)}${crumbLine(crumbs)}${refreshLine(ctx)}` +
247
+ `<h1>${e(title)}</h1>${body}${footer()}</body></html>`
192
248
  );
193
249
  }
194
250
 
@@ -331,7 +387,7 @@ export function renderMemberDetail(vm: MemberDetailVm, ctx: RenderContext): stri
331
387
  ["session file", vm.sessionFile ? e(vm.sessionFile) : "none mapped; see " + link(`${runHref(vm.runId)}/sessions`, "session files")],
332
388
  ];
333
389
  const transcript = vm.sessionFile
334
- ? `<p>${link(`${base}/transcript?from=last&n=40`, "transcript (tail)")} | ${link(`${base}/transcript?from=0&n=50`, "transcript (start)")}</p>`
390
+ ? `<p>${link(`${base}/transcript?from=last&n=${TRANSCRIPT_PAGE}`, "transcript (tail)")} | ${link(`${base}/transcript?from=0&n=${TRANSCRIPT_PAGE}`, "transcript (start)")}</p>`
335
391
  : "";
336
392
  const last = vm.lastText ? `<h2>last text</h2>${pre(vm.lastText)}` : "";
337
393
  const final = vm.finalText
@@ -359,6 +415,8 @@ export function renderMemberLog(vm: MemberLogVm, ctx: RenderContext): string {
359
415
  // Transcript
360
416
  // ---------------------------------------------------------------------------
361
417
 
418
+ const TRANSCRIPT_PAGE = 50;
419
+
362
420
  function transcriptBase(vm: { runId: string; member?: string | undefined; file: string }): string {
363
421
  return vm.member
364
422
  ? `${memberHref(vm.runId, vm.member)}/transcript`
@@ -409,18 +467,27 @@ function entryRow(entry: TranscriptEntry, base: string, full: boolean, file: str
409
467
  return [link(href, `#${entry.index}`), clock(entry.timestamp), entryRole(entry), content, entryUsage(entry)];
410
468
  }
411
469
 
412
- function pagingBar(vm: TranscriptVm, base: string): string {
470
+ /** The export crawls one page size, so it offers no size links. */
471
+ function pagingBar(vm: TranscriptVm, base: string, ctx: RenderContext): string {
413
472
  const to = Math.min(vm.total, vm.from + vm.n);
414
473
  const go = (from: number | "last", n = vm.n): string => `${base}${query({ from: String(from), n: String(n) })}`;
415
474
  const prev = vm.from > 0 ? link(go(Math.max(0, vm.from - vm.n)), "prev") : "prev";
416
475
  const next = vm.hasMore ? link(go(vm.from + vm.n), "next") : "next";
417
- const sizes = [20, 50, 100].map((n) => (n === vm.n ? `n=${n}` : link(go(vm.from, n), `n=${n}`))).join(" ");
476
+ const sizes = ctx.export
477
+ ? ""
478
+ : ` | ${[20, 50, 100].map((n) => (n === vm.n ? `n=${n}` : link(go(vm.from, n), `n=${n}`))).join(" ")}`;
418
479
  return (
419
480
  `<p>entries ${vm.from}..${to} of ${vm.total} | ${link(go(0), "first")} ${prev} ${next} ` +
420
- `${link(go("last"), "last")} | ${sizes}</p>`
481
+ `${link(go("last"), "last")}${sizes}</p>`
421
482
  );
422
483
  }
423
484
 
485
+ /** Live pages centre the entry in a short window; the export returns to the crawled page holding it. */
486
+ function transcriptPageHref(base: string, index: number, ctx: RenderContext): string {
487
+ if (ctx.export) return `${base}?from=${Math.floor(index / TRANSCRIPT_PAGE) * TRANSCRIPT_PAGE}&n=${TRANSCRIPT_PAGE}`;
488
+ return `${base}?from=${Math.max(0, index - 10)}&n=20`;
489
+ }
490
+
424
491
  function transcriptCrumbs(vm: { runId: string; member?: string | undefined; file: string }, leaf: string): Crumb[] {
425
492
  const crumbs: Crumb[] = [{ text: "runs", href: "/" }, { text: vm.runId, href: runHref(vm.runId) }];
426
493
  if (vm.member) crumbs.push({ text: vm.member, href: memberHref(vm.runId, vm.member) });
@@ -433,18 +500,17 @@ export function renderTranscript(vm: TranscriptVm, ctx: RenderContext): string {
433
500
  const base = transcriptBase(vm);
434
501
  const body =
435
502
  `<p>file ${e(vm.file)}${vm.sessionId ? `, session ${e(vm.sessionId)}` : ""}${vm.cwd ? `, cwd ${e(vm.cwd)}` : ""}</p>` +
436
- pagingBar(vm, base) +
503
+ pagingBar(vm, base, ctx) +
437
504
  table(["#", "time", "role", "content", "in/out"], vm.entries.map((entry) => entryRow(entry, base, false, vm.file)), "no entries") +
438
- pagingBar(vm, base);
505
+ pagingBar(vm, base, ctx);
439
506
  const title = `${vm.member ?? vm.file} transcript`;
440
507
  return page(title, transcriptCrumbs(vm, "transcript"), body, ctx);
441
508
  }
442
509
 
443
510
  export function renderTranscriptEntry(vm: TranscriptEntryVm, ctx: RenderContext): string {
444
511
  const base = transcriptBase(vm);
445
- const from = Math.max(0, vm.entry.index - 10);
446
512
  const body =
447
- `<p>${link(`${base}?from=${from}&n=20`, "back to transcript")}</p>` +
513
+ `<p>${link(transcriptPageHref(base, vm.entry.index, ctx), "back to transcript")}</p>` +
448
514
  table(["#", "time", "role", "content", "in/out"], [entryRow(vm.entry, base, true, vm.file)]) +
449
515
  details("raw json", pre(vm.raw), `${base}:${vm.file}:${vm.entry.index}:raw`);
450
516
  return page(`entry #${vm.entry.index}`, transcriptCrumbs(vm, `#${vm.entry.index}`), body, ctx);
@@ -529,16 +595,17 @@ function findingsHref(vm: FindingsVm, open: number[]): string {
529
595
  return `/findings${query({ run: vm.run, view: vm.view, open: open.length > 0 ? open.join(",") : undefined })}`;
530
596
  }
531
597
 
532
- /** The body is expanded when its id is in the URL, so the state survives auto-refresh. */
533
- function findingBody(f: FindingEntry, vm: FindingsVm): string {
598
+ /** The body is expanded when its id is in the URL, so the state survives auto-refresh; the export shows it inline. */
599
+ function findingBody(f: FindingEntry, vm: FindingsVm, ctx: RenderContext): string {
534
600
  if (!f.body) return "";
601
+ if (ctx.export) return pre(f.body);
535
602
  if (vm.open.includes(f.id)) {
536
603
  return pre(f.body) + link(findingsHref(vm, vm.open.filter((id) => id !== f.id)), "[hide body]");
537
604
  }
538
605
  return ` ${link(findingsHref(vm, [...vm.open, f.id]), "[show body]")}`;
539
606
  }
540
607
 
541
- function findingRow(f: FindingEntry, vm: FindingsVm): string[] {
608
+ function findingRow(f: FindingEntry, vm: FindingsVm, ctx: RenderContext): string[] {
542
609
  const idea = f.ideaId === undefined ? "" : link(`/ideas#idea-${f.ideaId}`, `#${f.ideaId}`);
543
610
  const authors = [f.author, ...f.corroborators].map((a) => authorLink(a, f.runId)).join(", ");
544
611
  const title = (f.supersededBy !== undefined ? "<b>SUPERSEDED</b> " : "") + e(f.title);
@@ -555,7 +622,7 @@ function findingRow(f: FindingEntry, vm: FindingsVm): string[] {
555
622
  f.supersedes === undefined ? "" : link(`#F${f.supersedes}`, `F${f.supersedes}`),
556
623
  f.supersededBy === undefined ? "" : link(`#F${f.supersededBy}`, `F${f.supersededBy}`),
557
624
  e(vm.reviewStates?.[f.id] ?? (f.verifiedBy ? `legacy (${f.verifiedBy})` : "unreviewed")),
558
- title + findingBody(f, vm),
625
+ title + findingBody(f, vm, ctx),
559
626
  ];
560
627
  }
561
628
 
@@ -577,13 +644,13 @@ export function renderFindings(vm: FindingsVm, ctx: RenderContext): string {
577
644
  const deltas = cluster.deltaMembers.length > 0 ? `; deltas ${cluster.deltaMembers.join(", ")}` : "";
578
645
  return (
579
646
  `<h2>cluster ${i + 1}: F${cluster.representative}, members ${e(members)}${e(deltas)}</h2>` +
580
- table(FINDING_HEADERS, cluster.findings.map((f) => findingRow(f, vm)))
647
+ table(FINDING_HEADERS, cluster.findings.map((f) => findingRow(f, vm, ctx)))
581
648
  );
582
649
  })
583
650
  .join("");
584
651
  if (vm.clusters.length === 0) body += "<p>no findings</p>";
585
652
  } else {
586
- body += table(FINDING_HEADERS, vm.findings.map((f) => findingRow(f, vm)), "no findings");
653
+ body += table(FINDING_HEADERS, vm.findings.map((f) => findingRow(f, vm, ctx)), "no findings");
587
654
  }
588
655
  return page("findings", [{ text: "findings" }], body, ctx);
589
656
  }
package/src/dashboard.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  renderClaims,
30
30
  renderCollaboration,
31
31
  renderError,
32
+ renderExportStart,
32
33
  renderFacts,
33
34
  renderFile,
34
35
  renderFindings,
@@ -45,8 +46,12 @@ import {
45
46
  renderTranscriptEntry,
46
47
  } from "./dashboard-html.js";
47
48
  import type { RenderContext } from "./dashboard-html.js";
49
+ import { collect, exportDashboard } from "./dashboard-export.js";
50
+ import type { ExportPage } from "./dashboard-export.js";
48
51
 
49
52
  const DEFAULT_PORT = 7433;
53
+ const EXPORT_PAGE = "/export";
54
+ const EXPORT_PATH = "/export.zip";
50
55
  const REFRESH_SECONDS = 3;
51
56
  const MAX_URL_LENGTH = 4096;
52
57
  const CLOSE_GUARD_MS = 2_000;
@@ -57,6 +62,8 @@ interface DashboardResponse {
57
62
  contentType: string;
58
63
  body: string;
59
64
  headers: Record<string, string>;
65
+ /** Written after the headers in place of `body`, as it is produced. */
66
+ stream?: AsyncIterable<Buffer> | undefined;
60
67
  }
61
68
 
62
69
  interface Route {
@@ -282,6 +289,12 @@ function respond(status: number, contentType: string, body: string, extra: Recor
282
289
  return { status, contentType, body, headers: { ...baseHeaders(), ...extra } };
283
290
  }
284
291
 
292
+ /** A same-origin dashboard path, else `/`; the export page returns here. */
293
+ function backPath(query: URLSearchParams): string {
294
+ const back = query.get("back") ?? "/";
295
+ return /^\/(?![/\\])[^\s]{0,2000}$/.test(back) ? back : "/";
296
+ }
297
+
285
298
  function parentPath(pathname: string): string {
286
299
  const trimmed = pathname.replace(/\/+$/, "");
287
300
  const parent = trimmed.slice(0, trimmed.lastIndexOf("/"));
@@ -316,24 +329,33 @@ function routeMatch(pathname: string): { route: Route; params: string[] } | unde
316
329
  return undefined;
317
330
  }
318
331
 
319
- function handleRoute(src: DashboardSource, url: URL, json: boolean, ctx: RenderContext): DashboardResponse {
332
+ /** The run this page belongs to, and the run the nav's collaboration and graph links target. */
333
+ function runContext(src: DashboardSource, url: URL, params: string[]): Pick<RenderContext, "runId" | "navRun"> {
334
+ const runId = RUN_ID_RE.test(params[0] ?? "") ? params[0] : undefined;
335
+ const filterRun = url.searchParams.get("run");
336
+ const navRun = runId ?? (filterRun && RUN_ID_RE.test(filterRun) ? filterRun : undefined) ?? latestRunId(src);
337
+ return { runId, navRun };
338
+ }
339
+
340
+ /** The export costs seconds of work per request, so pages from other sites may not start one. */
341
+ function handleRoute(src: DashboardSource, url: URL, json: boolean, ctx: RenderContext, crossSite: boolean): DashboardResponse {
320
342
  const pathname = json ? url.pathname.slice("/api".length) || "/" : url.pathname;
343
+ if (!json && (pathname === EXPORT_PATH || pathname === EXPORT_PAGE)) {
344
+ if (crossSite) return errorResponse(403, "the export can only be started from the dashboard itself", url, json, ctx);
345
+ if (pathname === EXPORT_PATH) return exportResponse(src, url, ctx);
346
+ return respond(200, "text/html; charset=utf-8", renderExportStart(EXPORT_PATH, backPath(url.searchParams), ctx));
347
+ }
321
348
  const matched = routeMatch(pathname);
322
349
  if (!matched) throw new DashboardError(404, `no such page: ${pathname}`);
323
350
  const { route, params } = matched;
324
351
  const refreshParam = queryEnum(url.searchParams, "refresh", ["0", String(REFRESH_SECONDS)] as const, String(REFRESH_SECONDS));
325
352
  const vm = route.load(src, params, url.searchParams);
326
353
  if (json) return respond(200, "application/json; charset=utf-8", JSON.stringify(vm));
327
- const runId = RUN_ID_RE.test(params[0] ?? "") ? params[0] : undefined;
328
- const filterRun = url.searchParams.get("run");
329
- const navRun = runId ?? (filterRun && RUN_ID_RE.test(filterRun) ? filterRun : undefined) ?? latestRunId(src);
330
354
  const live = route.live(vm, src, params, url.searchParams);
331
355
  const on = live && refreshParam !== "0";
332
356
  const pageCtx: RenderContext = {
333
357
  ...ctx,
334
- runId,
335
- navRun,
336
- href: url.pathname + url.search,
358
+ ...runContext(src, url, params),
337
359
  refresh: live ? { on, toggleHref: refreshToggle(url, on), seconds: REFRESH_SECONDS } : undefined,
338
360
  };
339
361
  const body = route.render(vm as never, pageCtx);
@@ -341,17 +363,68 @@ function handleRoute(src: DashboardSource, url: URL, json: boolean, ctx: RenderC
341
363
  return respond(200, "text/html; charset=utf-8", body);
342
364
  }
343
365
 
344
- /** Pure request handler: validates method and host, dispatches, maps errors to responses. */
345
- export function routeRequest(src: DashboardSource, method: string, url: URL, host: string | undefined): DashboardResponse {
366
+ /** One page of the static export; errors become pages so no exported link dangles. */
367
+ function exportPage(src: DashboardSource, url: URL): ExportPage {
368
+ const ctx: RenderContext = { activeId: src.getActive()?.id, href: url.pathname + url.search, export: true };
369
+ try {
370
+ const matched = routeMatch(url.pathname);
371
+ if (!matched) throw new DashboardError(404, `no such page: ${url.pathname}`);
372
+ const { route, params } = matched;
373
+ const vm = route.load(src, params, url.searchParams);
374
+ return { body: route.render(vm as never, { ...ctx, ...runContext(src, url, params) }), text: route.text };
375
+ } catch (error) {
376
+ const status = error instanceof DashboardError ? error.status : 500;
377
+ const message = error instanceof DashboardError ? error.message : `dashboard error: ${error instanceof Error ? error.message : String(error)}`;
378
+ return { body: renderError(status, message, parentPath(url.pathname), ctx) };
379
+ }
380
+ }
381
+
382
+ /** The whole dashboard as a zip of static pages, streamed as it is built, named for the moment it was taken. */
383
+ export function exportDashboardArchive(src: DashboardSource, now = new Date()): { filename: string; zip: AsyncGenerator<Buffer> } {
384
+ const folder = `horde-dashboard-${now.toISOString().slice(0, 19).replace(/[T:]/g, "-")}`;
385
+ return { filename: `${folder}.zip`, zip: exportDashboard((url) => exportPage(src, url), folder, now) };
386
+ }
387
+
388
+ /** The finished archive in one buffer. */
389
+ export async function exportDashboardBuffer(src: DashboardSource, now = new Date()): Promise<{ filename: string; zip: Buffer }> {
390
+ const { filename, zip } = exportDashboardArchive(src, now);
391
+ return { filename, zip: await collect(zip) };
392
+ }
393
+
394
+ let exporting = false;
395
+
396
+ /** Marks the export busy from its first chunk until the stream ends or its reader goes away. */
397
+ async function* exclusive(stream: AsyncIterable<Buffer>): AsyncGenerator<Buffer> {
398
+ exporting = true;
399
+ try {
400
+ yield* stream;
401
+ } finally {
402
+ exporting = false;
403
+ }
404
+ }
405
+
406
+ /** Headers go out at once so the browser shows the download while the pages are still being packed; one at a time. */
407
+ function exportResponse(src: DashboardSource, url: URL, ctx: RenderContext): DashboardResponse {
408
+ if (exporting) return errorResponse(503, "an export is already in progress; wait for it to finish", url, false, ctx);
409
+ const { filename, zip } = exportDashboardArchive(src);
410
+ return { ...respond(200, "application/zip", "", { "Content-Disposition": `attachment; filename="${filename}"` }), stream: exclusive(zip) };
411
+ }
412
+
413
+ /** Pure request handler: validates method and host, dispatches, maps errors to responses. `fetchSite` is the Sec-Fetch-Site header. */
414
+ export function routeRequest(src: DashboardSource, method: string, url: URL, host: string | undefined, fetchSite?: string): DashboardResponse {
346
415
  const json = url.pathname === "/api" || url.pathname.startsWith("/api/");
347
- const ctx: RenderContext = { activeId: src.getActive()?.id, origin: host ? `http://${host}` : undefined };
416
+ const ctx: RenderContext = {
417
+ activeId: src.getActive()?.id,
418
+ origin: host ? `http://${host}` : undefined,
419
+ href: url.pathname + url.search,
420
+ };
348
421
  try {
349
422
  if (method !== "GET" && method !== "HEAD") {
350
423
  return { ...errorResponse(405, `method ${method} not allowed`, url, json, ctx), headers: { ...baseHeaders(), Allow: "GET, HEAD" } };
351
424
  }
352
425
  if (!host || !HOST_RE.test(host)) return errorResponse(403, "host must be 127.0.0.1 or localhost", url, json, ctx);
353
426
  if (url.href.length > MAX_URL_LENGTH) return errorResponse(414, "url too long", url, json, ctx);
354
- return handleRoute(src, url, json, ctx);
427
+ return handleRoute(src, url, json, ctx, fetchSite === "cross-site");
355
428
  } catch (error) {
356
429
  if (error instanceof DashboardError) return errorResponse(error.status, error.message, url, json, ctx);
357
430
  const message = error instanceof Error ? error.message : String(error);
@@ -383,12 +456,41 @@ function listenError(error: NodeJS.ErrnoException, port: number): Error {
383
456
  return new Error(`dashboard could not listen on ${port}: ${error.message}`);
384
457
  }
385
458
 
459
+ const drained = (res: http.ServerResponse): Promise<void> =>
460
+ new Promise((resolve) => {
461
+ const done = (): void => {
462
+ res.off("drain", done);
463
+ res.off("close", done);
464
+ resolve();
465
+ };
466
+ res.once("drain", done);
467
+ res.once("close", done);
468
+ });
469
+
470
+ /** Writes the chunks as they come, pausing for the client; a failure mid-stream cuts the connection. */
471
+ async function streamBody(res: http.ServerResponse, stream: AsyncIterable<Buffer>): Promise<void> {
472
+ try {
473
+ for await (const chunk of stream) {
474
+ if (res.destroyed) return;
475
+ if (!res.write(chunk)) await drained(res);
476
+ }
477
+ res.end();
478
+ } catch (error) {
479
+ res.destroy(error instanceof Error ? error : new Error(String(error)));
480
+ }
481
+ }
482
+
386
483
  function serve(src: DashboardSource, req: http.IncomingMessage, res: http.ServerResponse): void {
387
484
  req.resume();
388
485
  res.on("error", () => {});
389
486
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
390
- const response = routeRequest(src, req.method ?? "GET", url, req.headers.host);
487
+ const fetchSite = req.headers["sec-fetch-site"];
488
+ const response = routeRequest(src, req.method ?? "GET", url, req.headers.host, Array.isArray(fetchSite) ? fetchSite[0] : fetchSite);
391
489
  res.writeHead(response.status, { ...response.headers, "Content-Type": response.contentType });
490
+ if (response.stream && req.method !== "HEAD") {
491
+ void streamBody(res, response.stream);
492
+ return;
493
+ }
392
494
  res.end(response.body);
393
495
  }
394
496