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.
- package/README.md +23 -9
- package/SECURITY.md +8 -0
- package/docs/REFERENCE.md +73 -16
- package/package.json +1 -1
- package/src/args.ts +18 -3
- package/src/build-info.ts +65 -0
- package/src/collaboration-report.ts +23 -9
- package/src/dashboard-export.ts +192 -0
- package/src/dashboard-html.ts +88 -21
- package/src/dashboard.ts +114 -12
- package/src/ledger/collaboration.ts +36 -6
- package/src/ledger/facts.ts +52 -5
- package/src/ledger/findings.ts +91 -1
- package/src/ledger/run-signals.ts +17 -2
- package/src/member.ts +15 -3
- package/src/prompt.ts +73 -16
- package/src/run/completions.ts +6 -1
- package/src/run/finalize.ts +6 -7
- package/src/run/launch.ts +1 -1
- package/src/run/repair.ts +15 -7
- package/src/run/schedule.ts +43 -13
- package/src/run/setup.ts +2 -1
- package/src/run/solved.ts +125 -15
- package/src/run/supervise.ts +74 -9
- package/src/run/types.ts +6 -0
- package/src/run-graph.ts +9 -2
- package/src/tool/actions.ts +149 -13
- package/src/tool/collaboration-actions.ts +75 -25
- package/src/tool/params.ts +10 -7
- package/src/tool/register.ts +11 -6
|
@@ -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(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function escapeHtml(text: string): string {
|
|
52
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
+
}
|
package/src/dashboard-html.ts
CHANGED
|
@@ -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> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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")}
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
345
|
-
|
|
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 = {
|
|
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
|
|
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
|
|