agent-horde 0.1.4 → 0.1.5
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 +3 -2
- package/SECURITY.md +8 -0
- package/docs/REFERENCE.md +18 -0
- package/package.json +1 -1
- package/src/dashboard-export.ts +192 -0
- package/src/dashboard-html.ts +77 -18
- package/src/dashboard.ts +114 -12
package/README.md
CHANGED
|
@@ -88,7 +88,7 @@ pi install npm:agent-horde
|
|
|
88
88
|
Or from the repository, pinned to a tag:
|
|
89
89
|
|
|
90
90
|
```
|
|
91
|
-
pi install git:github.com/eliyastein/agent-horde@v0.1.
|
|
91
|
+
pi install git:github.com/eliyastein/agent-horde@v0.1.5
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
To hack on it, clone and install in place. pi loads the files from the clone, so edits take effect
|
|
@@ -192,7 +192,8 @@ ledgers, the collaboration threads with their review scope and acceptance state,
|
|
|
192
192
|
audit, and a graph of the run. The graph is a swimlane, one lane per member, with every event
|
|
193
193
|
linked to the page that shows it, so a run reads left to right: who claimed what, which findings
|
|
194
194
|
landed under which ideas, who reviewed whom, and where actions were rejected. It is server-rendered
|
|
195
|
-
SVG/HTML with no scripts.
|
|
195
|
+
SVG/HTML with no scripts. The download icon on every page exports the whole dashboard as a zip of
|
|
196
|
+
static pages that opens from disk, after a warning that it may carry sensitive data from the machine.
|
|
196
197
|
|
|
197
198
|
## Benchmark: FinanceBench
|
|
198
199
|
|
package/SECURITY.md
CHANGED
|
@@ -32,6 +32,14 @@ writes, rejects requests whose `Host` header is not localhost, sends a `default-
|
|
|
32
32
|
security policy, and renders no scripts. It still exposes everything under `.horde/` to any process
|
|
33
33
|
or browser tab on the machine, member transcripts included. Do not forward or proxy the port.
|
|
34
34
|
|
|
35
|
+
`/export.zip` packs every dashboard page, transcripts and logs included, into one archive meant
|
|
36
|
+
to be shared. The export control warns before the download, but the archive carries whatever the
|
|
37
|
+
members read or produced: file contents, command output, paths, and any credentials they met.
|
|
38
|
+
Review it before passing it on. Building it costs seconds of work, so the dashboard runs one
|
|
39
|
+
export at a time and refuses to start one for a request the browser marks as cross-site; a page
|
|
40
|
+
on another origin cannot read the dashboard, but it can otherwise make a browser request its
|
|
41
|
+
localhost port.
|
|
42
|
+
|
|
35
43
|
## Supply chain
|
|
36
44
|
|
|
37
45
|
The package has no runtime dependencies beyond the packages pi itself provides to extensions.
|
package/docs/REFERENCE.md
CHANGED
|
@@ -87,6 +87,24 @@ Every page has a JSON twin under `/api/<same path>`.
|
|
|
87
87
|
| `/runs/<id>/graph` | the run as a swimlane: one lane per member plus the parent, phase bands, every event a link to its ledger entry (`?w=<px>`, `?from=<s>&to=<s>`); the axis is real time until the last member exits, then the synthesis phases share a compressed tail |
|
|
88
88
|
| `/runs/<id>/graph.mmd` | the same run as a Mermaid flowchart with absolute dashboard links (text/plain) |
|
|
89
89
|
| `/ideas`, `/board`, `/findings`, `/claims`, `/facts`, `/resources` | the shared ledgers; `?run=<id>\|all`, `?view=clusters`, `?owner=<m>` filter them; `/resources` also lists who cited each resource in the active run, as REPORT.md counts reuse |
|
|
90
|
+
| `/export` | confirms the export and starts the download of `/export.zip` (`?back=<path>` names the page to return to); no JSON twin |
|
|
91
|
+
| `/export.zip` | the whole dashboard as static HTML (`application/zip`, streamed, no JSON twin); see below |
|
|
92
|
+
|
|
93
|
+
The download icon in the top-right corner of every page opens a warning that the export may
|
|
94
|
+
leak sensitive data from this machine. Its download link goes to `/export`, which closes the
|
|
95
|
+
warning, explains the wait, and starts the download of `/export.zip`. The zip holds one
|
|
96
|
+
folder, `horde-dashboard-<timestamp>/`, whose `index.html` opens the dashboard from disk: every
|
|
97
|
+
run, member, transcript page and entry, log, run document, collaboration page, graph (each width,
|
|
98
|
+
plus the Mermaid file), and the ledgers under each run filter, with links rewritten relative to
|
|
99
|
+
each other. Exported pages do not refresh, expandable entries use the browser's own disclosure,
|
|
100
|
+
finding bodies are shown inline, transcripts are paged fifty entries at a time, and the export
|
|
101
|
+
control itself is absent. Pages that fail to load are exported as their error page, so no link
|
|
102
|
+
dangles. The export is built when requested and has no cache. It is streamed one page per
|
|
103
|
+
event-loop turn, so the download appears in the browser at once and grows while the pages are
|
|
104
|
+
packed, and the run is not held up; a dashboard with many long transcripts takes a few seconds
|
|
105
|
+
and the archive can run to hundreds of megabytes. One export runs at a time (a second request
|
|
106
|
+
gets 503), and requests a browser marks as coming from another site (`Sec-Fetch-Site:
|
|
107
|
+
cross-site`) get 403.
|
|
90
108
|
|
|
91
109
|
Only the active run has live member state. Historical runs are rebuilt from `member-N.log` and
|
|
92
110
|
the session files, so they show end cause, turns, and usage but no activity.
|
package/package.json
CHANGED
|
@@ -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
|
@@ -39,6 +39,8 @@ export interface RenderContext {
|
|
|
39
39
|
origin?: string | undefined;
|
|
40
40
|
/** The run the collaboration and graph links target: this page's run, else the newest. */
|
|
41
41
|
navRun?: string | undefined;
|
|
42
|
+
/** Rendering for the static export: no refresh, no export control, native disclosure. */
|
|
43
|
+
export?: boolean | undefined;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
interface Crumb {
|
|
@@ -53,8 +55,25 @@ const CSS =
|
|
|
53
55
|
"th{background:#eee}pre{white-space:pre-wrap;word-break:break-word;margin:0}" +
|
|
54
56
|
"nav a{margin-right:8px}.r{text-align:right}h1,h2{font-size:13px;margin:8px 0 4px}" +
|
|
55
57
|
"details>summary{cursor:pointer}a{color:#000}" +
|
|
58
|
+
".export{position:absolute;top:8px;right:8px;line-height:0}" +
|
|
59
|
+
".modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7)}" +
|
|
60
|
+
".modal:target{display:flex;align-items:center;justify-content:center}" +
|
|
61
|
+
".modal-box{background:#fff;border:3px solid #000;padding:32px;width:min(90vw,760px);font-size:18px;line-height:1.5}" +
|
|
62
|
+
".modal-box h2{font-size:24px;margin:0 0 16px}" +
|
|
63
|
+
".modal-box .btn{display:inline-block;border:2px solid #000;padding:8px 16px;margin-right:12px}" +
|
|
56
64
|
GRAPH_CSS;
|
|
57
65
|
|
|
66
|
+
const EXPORT_ICON =
|
|
67
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">' +
|
|
68
|
+
'<path d="M12 3v12m0 0l-5-5m5 5l5-5M4 17v3h16v-3" fill="none" stroke="currentColor" stroke-width="2" ' +
|
|
69
|
+
'stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
70
|
+
|
|
71
|
+
const EXPORT_WARNING =
|
|
72
|
+
"The zip holds every page of this dashboard as static HTML: every run, every member transcript and log, " +
|
|
73
|
+
"the run documents, the shared ledgers and the event audit. Transcripts include everything members read " +
|
|
74
|
+
"and produced on this machine: file contents, command output, paths, and any secrets or credentials they " +
|
|
75
|
+
"encountered. Review the export before sharing it with anyone who should not see all of that.";
|
|
76
|
+
|
|
58
77
|
const ESCAPES: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
59
78
|
|
|
60
79
|
function escapeHtml(text: string): string {
|
|
@@ -182,12 +201,41 @@ function persistentDetails(body: string, href = "/"): string {
|
|
|
182
201
|
});
|
|
183
202
|
}
|
|
184
203
|
|
|
185
|
-
export
|
|
186
|
-
|
|
187
|
-
|
|
204
|
+
/** The export opens the modal on the paused page, so a refresh cannot close it; cancel restores the page. */
|
|
205
|
+
function exportControl(ctx: RenderContext): string {
|
|
206
|
+
if (ctx.export) return "";
|
|
207
|
+
const current = ctx.href ?? "/";
|
|
208
|
+
const paused = ctx.refresh?.on ? ctx.refresh.toggleHref : current;
|
|
209
|
+
return (
|
|
210
|
+
`<a class="export" href="${e(`${paused}#export`)}" title="export dashboard" aria-label="export dashboard">${EXPORT_ICON}</a>` +
|
|
211
|
+
`<div id="export" class="modal"><div class="modal-box"><h2>Export this dashboard?</h2><p>${EXPORT_WARNING}</p>` +
|
|
212
|
+
`<p><a class="btn" href="${e(`/export${query({ back: current })}`)}">download zip</a><a class="btn" href="${e(current)}">cancel</a></p></div></div>`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Shown once the download is confirmed; it starts the download itself and says what the wait is. */
|
|
217
|
+
export function renderExportStart(zipHref: string, backHref: string, ctx: RenderContext): string {
|
|
218
|
+
const body =
|
|
219
|
+
"<p>The archive is being packed one page at a time and streamed to your browser, so it appears in the " +
|
|
220
|
+
"browser's download list right away and grows until it is complete. A dashboard with long transcripts takes " +
|
|
221
|
+
"a while; the dashboard stays usable meanwhile.</p>" +
|
|
222
|
+
`<p>If no download appears, ${link(zipHref, "download it directly")}.</p>` +
|
|
223
|
+
`<p>${link(backHref, "back to the dashboard")}</p>`;
|
|
224
|
+
const start = `<meta http-equiv="refresh" content="0;url=${e(zipHref)}">`;
|
|
225
|
+
return page("exporting dashboard", [{ text: "runs", href: "/" }, { text: "export" }], body, { ...ctx, refresh: undefined }, start);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The static export has no server, so disclosure falls back to the browser's own details element. */
|
|
229
|
+
function nativeDetails(body: string): string {
|
|
230
|
+
return body.replace(/<details data-expand="[a-f0-9]+">/g, "<details>");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function page(title: string, crumbs: Crumb[], body: string, ctx: RenderContext, head = ""): string {
|
|
234
|
+
body = ctx.export ? nativeDetails(body) : persistentDetails(body, ctx.href);
|
|
235
|
+
const meta = ctx.refresh?.on ? `<meta http-equiv="refresh" content="${ctx.refresh.seconds}">` : head;
|
|
188
236
|
return (
|
|
189
237
|
`<!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)}` +
|
|
238
|
+
`<style>${CSS}</style></head><body>${exportControl(ctx)}${nav(ctx)}${crumbLine(crumbs)}${refreshLine(ctx)}` +
|
|
191
239
|
`<h1>${e(title)}</h1>${body}</body></html>`
|
|
192
240
|
);
|
|
193
241
|
}
|
|
@@ -331,7 +379,7 @@ export function renderMemberDetail(vm: MemberDetailVm, ctx: RenderContext): stri
|
|
|
331
379
|
["session file", vm.sessionFile ? e(vm.sessionFile) : "none mapped; see " + link(`${runHref(vm.runId)}/sessions`, "session files")],
|
|
332
380
|
];
|
|
333
381
|
const transcript = vm.sessionFile
|
|
334
|
-
? `<p>${link(`${base}/transcript?from=last&n
|
|
382
|
+
? `<p>${link(`${base}/transcript?from=last&n=${TRANSCRIPT_PAGE}`, "transcript (tail)")} | ${link(`${base}/transcript?from=0&n=${TRANSCRIPT_PAGE}`, "transcript (start)")}</p>`
|
|
335
383
|
: "";
|
|
336
384
|
const last = vm.lastText ? `<h2>last text</h2>${pre(vm.lastText)}` : "";
|
|
337
385
|
const final = vm.finalText
|
|
@@ -359,6 +407,8 @@ export function renderMemberLog(vm: MemberLogVm, ctx: RenderContext): string {
|
|
|
359
407
|
// Transcript
|
|
360
408
|
// ---------------------------------------------------------------------------
|
|
361
409
|
|
|
410
|
+
const TRANSCRIPT_PAGE = 50;
|
|
411
|
+
|
|
362
412
|
function transcriptBase(vm: { runId: string; member?: string | undefined; file: string }): string {
|
|
363
413
|
return vm.member
|
|
364
414
|
? `${memberHref(vm.runId, vm.member)}/transcript`
|
|
@@ -409,18 +459,27 @@ function entryRow(entry: TranscriptEntry, base: string, full: boolean, file: str
|
|
|
409
459
|
return [link(href, `#${entry.index}`), clock(entry.timestamp), entryRole(entry), content, entryUsage(entry)];
|
|
410
460
|
}
|
|
411
461
|
|
|
412
|
-
|
|
462
|
+
/** The export crawls one page size, so it offers no size links. */
|
|
463
|
+
function pagingBar(vm: TranscriptVm, base: string, ctx: RenderContext): string {
|
|
413
464
|
const to = Math.min(vm.total, vm.from + vm.n);
|
|
414
465
|
const go = (from: number | "last", n = vm.n): string => `${base}${query({ from: String(from), n: String(n) })}`;
|
|
415
466
|
const prev = vm.from > 0 ? link(go(Math.max(0, vm.from - vm.n)), "prev") : "prev";
|
|
416
467
|
const next = vm.hasMore ? link(go(vm.from + vm.n), "next") : "next";
|
|
417
|
-
const sizes =
|
|
468
|
+
const sizes = ctx.export
|
|
469
|
+
? ""
|
|
470
|
+
: ` | ${[20, 50, 100].map((n) => (n === vm.n ? `n=${n}` : link(go(vm.from, n), `n=${n}`))).join(" ")}`;
|
|
418
471
|
return (
|
|
419
472
|
`<p>entries ${vm.from}..${to} of ${vm.total} | ${link(go(0), "first")} ${prev} ${next} ` +
|
|
420
|
-
`${link(go("last"), "last")}
|
|
473
|
+
`${link(go("last"), "last")}${sizes}</p>`
|
|
421
474
|
);
|
|
422
475
|
}
|
|
423
476
|
|
|
477
|
+
/** Live pages centre the entry in a short window; the export returns to the crawled page holding it. */
|
|
478
|
+
function transcriptPageHref(base: string, index: number, ctx: RenderContext): string {
|
|
479
|
+
if (ctx.export) return `${base}?from=${Math.floor(index / TRANSCRIPT_PAGE) * TRANSCRIPT_PAGE}&n=${TRANSCRIPT_PAGE}`;
|
|
480
|
+
return `${base}?from=${Math.max(0, index - 10)}&n=20`;
|
|
481
|
+
}
|
|
482
|
+
|
|
424
483
|
function transcriptCrumbs(vm: { runId: string; member?: string | undefined; file: string }, leaf: string): Crumb[] {
|
|
425
484
|
const crumbs: Crumb[] = [{ text: "runs", href: "/" }, { text: vm.runId, href: runHref(vm.runId) }];
|
|
426
485
|
if (vm.member) crumbs.push({ text: vm.member, href: memberHref(vm.runId, vm.member) });
|
|
@@ -433,18 +492,17 @@ export function renderTranscript(vm: TranscriptVm, ctx: RenderContext): string {
|
|
|
433
492
|
const base = transcriptBase(vm);
|
|
434
493
|
const body =
|
|
435
494
|
`<p>file ${e(vm.file)}${vm.sessionId ? `, session ${e(vm.sessionId)}` : ""}${vm.cwd ? `, cwd ${e(vm.cwd)}` : ""}</p>` +
|
|
436
|
-
pagingBar(vm, base) +
|
|
495
|
+
pagingBar(vm, base, ctx) +
|
|
437
496
|
table(["#", "time", "role", "content", "in/out"], vm.entries.map((entry) => entryRow(entry, base, false, vm.file)), "no entries") +
|
|
438
|
-
pagingBar(vm, base);
|
|
497
|
+
pagingBar(vm, base, ctx);
|
|
439
498
|
const title = `${vm.member ?? vm.file} transcript`;
|
|
440
499
|
return page(title, transcriptCrumbs(vm, "transcript"), body, ctx);
|
|
441
500
|
}
|
|
442
501
|
|
|
443
502
|
export function renderTranscriptEntry(vm: TranscriptEntryVm, ctx: RenderContext): string {
|
|
444
503
|
const base = transcriptBase(vm);
|
|
445
|
-
const from = Math.max(0, vm.entry.index - 10);
|
|
446
504
|
const body =
|
|
447
|
-
`<p>${link(
|
|
505
|
+
`<p>${link(transcriptPageHref(base, vm.entry.index, ctx), "back to transcript")}</p>` +
|
|
448
506
|
table(["#", "time", "role", "content", "in/out"], [entryRow(vm.entry, base, true, vm.file)]) +
|
|
449
507
|
details("raw json", pre(vm.raw), `${base}:${vm.file}:${vm.entry.index}:raw`);
|
|
450
508
|
return page(`entry #${vm.entry.index}`, transcriptCrumbs(vm, `#${vm.entry.index}`), body, ctx);
|
|
@@ -529,16 +587,17 @@ function findingsHref(vm: FindingsVm, open: number[]): string {
|
|
|
529
587
|
return `/findings${query({ run: vm.run, view: vm.view, open: open.length > 0 ? open.join(",") : undefined })}`;
|
|
530
588
|
}
|
|
531
589
|
|
|
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 {
|
|
590
|
+
/** The body is expanded when its id is in the URL, so the state survives auto-refresh; the export shows it inline. */
|
|
591
|
+
function findingBody(f: FindingEntry, vm: FindingsVm, ctx: RenderContext): string {
|
|
534
592
|
if (!f.body) return "";
|
|
593
|
+
if (ctx.export) return pre(f.body);
|
|
535
594
|
if (vm.open.includes(f.id)) {
|
|
536
595
|
return pre(f.body) + link(findingsHref(vm, vm.open.filter((id) => id !== f.id)), "[hide body]");
|
|
537
596
|
}
|
|
538
597
|
return ` ${link(findingsHref(vm, [...vm.open, f.id]), "[show body]")}`;
|
|
539
598
|
}
|
|
540
599
|
|
|
541
|
-
function findingRow(f: FindingEntry, vm: FindingsVm): string[] {
|
|
600
|
+
function findingRow(f: FindingEntry, vm: FindingsVm, ctx: RenderContext): string[] {
|
|
542
601
|
const idea = f.ideaId === undefined ? "" : link(`/ideas#idea-${f.ideaId}`, `#${f.ideaId}`);
|
|
543
602
|
const authors = [f.author, ...f.corroborators].map((a) => authorLink(a, f.runId)).join(", ");
|
|
544
603
|
const title = (f.supersededBy !== undefined ? "<b>SUPERSEDED</b> " : "") + e(f.title);
|
|
@@ -555,7 +614,7 @@ function findingRow(f: FindingEntry, vm: FindingsVm): string[] {
|
|
|
555
614
|
f.supersedes === undefined ? "" : link(`#F${f.supersedes}`, `F${f.supersedes}`),
|
|
556
615
|
f.supersededBy === undefined ? "" : link(`#F${f.supersededBy}`, `F${f.supersededBy}`),
|
|
557
616
|
e(vm.reviewStates?.[f.id] ?? (f.verifiedBy ? `legacy (${f.verifiedBy})` : "unreviewed")),
|
|
558
|
-
title + findingBody(f, vm),
|
|
617
|
+
title + findingBody(f, vm, ctx),
|
|
559
618
|
];
|
|
560
619
|
}
|
|
561
620
|
|
|
@@ -577,13 +636,13 @@ export function renderFindings(vm: FindingsVm, ctx: RenderContext): string {
|
|
|
577
636
|
const deltas = cluster.deltaMembers.length > 0 ? `; deltas ${cluster.deltaMembers.join(", ")}` : "";
|
|
578
637
|
return (
|
|
579
638
|
`<h2>cluster ${i + 1}: F${cluster.representative}, members ${e(members)}${e(deltas)}</h2>` +
|
|
580
|
-
table(FINDING_HEADERS, cluster.findings.map((f) => findingRow(f, vm)))
|
|
639
|
+
table(FINDING_HEADERS, cluster.findings.map((f) => findingRow(f, vm, ctx)))
|
|
581
640
|
);
|
|
582
641
|
})
|
|
583
642
|
.join("");
|
|
584
643
|
if (vm.clusters.length === 0) body += "<p>no findings</p>";
|
|
585
644
|
} else {
|
|
586
|
-
body += table(FINDING_HEADERS, vm.findings.map((f) => findingRow(f, vm)), "no findings");
|
|
645
|
+
body += table(FINDING_HEADERS, vm.findings.map((f) => findingRow(f, vm, ctx)), "no findings");
|
|
587
646
|
}
|
|
588
647
|
return page("findings", [{ text: "findings" }], body, ctx);
|
|
589
648
|
}
|
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
|
|