omkit 0.8.9 → 0.8.11
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 +411 -410
- package/dist/core/ActionRun.d.ts +3 -1
- package/dist/core/ActionRun.js +2 -0
- package/dist/core/ExecutionTree.d.ts +11 -0
- package/dist/core/ExecutionTree.js +53 -1
- package/dist/core/action.js +11 -2
- package/dist/core/om.js +3 -0
- package/dist/core/types.d.ts +13 -0
- package/dist/output/writers/ReportWriter.d.ts +16 -0
- package/dist/output/writers/ReportWriter.js +275 -0
- package/dist/output/writers/report/report.css +300 -0
- package/dist/output/writers/report/report.js +1496 -0
- package/dist/output/writers/views.d.ts +10 -0
- package/dist/skill/render.js +2 -1
- package/package.json +4 -3
package/dist/core/ActionRun.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { ActionRef } from "../foundation/ActionRef.ts";
|
|
|
3
3
|
import type { LogStore } from "../output/log/LogStore.ts";
|
|
4
4
|
import type { SnapshotStore } from "../output/snapshot/SnapshotStore.ts";
|
|
5
5
|
import type { ArtifactStore } from "../output/artifact/ArtifactStore.ts";
|
|
6
|
-
import type { Activity, Exec, InstanceEvents, NodeStatus } from "./types.ts";
|
|
6
|
+
import type { ActionMeta, Activity, Exec, InstanceEvents, NodeStatus } from "./types.ts";
|
|
7
7
|
/** Everything a node needs to exist, minus its behavior. */
|
|
8
8
|
export interface NodeInit {
|
|
9
9
|
readonly store: LogStore;
|
|
@@ -45,6 +45,8 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
|
|
|
45
45
|
readonly path: string;
|
|
46
46
|
readonly parentId: string | null;
|
|
47
47
|
readonly tags: string[];
|
|
48
|
+
/** What the definition said about itself (`.describe()`, `.mcp()`, `.args()`); for the record. */
|
|
49
|
+
meta: ActionMeta | undefined;
|
|
48
50
|
/** The captured args this node ran with (for the log header); set at run start. */
|
|
49
51
|
args: readonly unknown[];
|
|
50
52
|
readonly children: ActionRun[];
|
package/dist/core/ActionRun.js
CHANGED
|
@@ -18,6 +18,8 @@ export class ActionRun {
|
|
|
18
18
|
path;
|
|
19
19
|
parentId;
|
|
20
20
|
tags = [];
|
|
21
|
+
/** What the definition said about itself (`.describe()`, `.mcp()`, `.args()`); for the record. */
|
|
22
|
+
meta;
|
|
21
23
|
/** The captured args this node ran with (for the log header); set at run start. */
|
|
22
24
|
args = [];
|
|
23
25
|
children = [];
|
|
@@ -22,6 +22,8 @@ export declare class ExecutionTree {
|
|
|
22
22
|
readonly store: LogStore;
|
|
23
23
|
readonly folder: RunFolder;
|
|
24
24
|
readonly root: ActionRun;
|
|
25
|
+
/** The om's name, as given to `om(name)`. */
|
|
26
|
+
readonly name: string;
|
|
25
27
|
/**
|
|
26
28
|
* The om's `.describe(…)` summary, carried here from the builder so it survives the
|
|
27
29
|
* terminal `.run(...)` call. Stored so it is retrievable; nothing reads it yet.
|
|
@@ -39,6 +41,7 @@ export declare class ExecutionTree {
|
|
|
39
41
|
private readonly snapshotStore;
|
|
40
42
|
private readonly artifactStore;
|
|
41
43
|
private readonly registry;
|
|
44
|
+
private readonly schemaCache;
|
|
42
45
|
private readonly unsettled;
|
|
43
46
|
private readonly originalLog;
|
|
44
47
|
private streaming;
|
|
@@ -99,6 +102,12 @@ export declare class ExecutionTree {
|
|
|
99
102
|
private printSummary;
|
|
100
103
|
private verdictOk;
|
|
101
104
|
private nodeView;
|
|
105
|
+
/**
|
|
106
|
+
* The node's self-description, projected for the record. The root's comes from the om
|
|
107
|
+
* builder; a child's from its action definition. The args schema is converted here, once per
|
|
108
|
+
* definition, so a step launched a thousand times converts its schema once.
|
|
109
|
+
*/
|
|
110
|
+
private metaView;
|
|
102
111
|
/** Test seam: the projected run view (same object written to result.json). */
|
|
103
112
|
runViewForTest(): RunView;
|
|
104
113
|
/** Test seam: the run's curated artifacts. */
|
|
@@ -122,6 +131,8 @@ export declare class ExecutionTree {
|
|
|
122
131
|
* artifact name is free-form, so any printable separator can be forged inside a key.
|
|
123
132
|
*/
|
|
124
133
|
private curatedArtifacts;
|
|
134
|
+
/** {@link curatedArtifacts}, each still carrying the node that registered it (for the report). */
|
|
135
|
+
private curatedArtifactsWithNode;
|
|
125
136
|
/**
|
|
126
137
|
* Absolute `.log` path for a node — root is `main.log`, children drop the `main/`
|
|
127
138
|
* prefix. One directory level per ancestor, mirroring the tree.
|
|
@@ -13,6 +13,7 @@ import { LiveRenderer } from "../output/LiveRenderer.js";
|
|
|
13
13
|
import { writeNodeLogs } from "../output/writers/NodeLogWriter.js";
|
|
14
14
|
import { writeRollup } from "../output/writers/RollupWriter.js";
|
|
15
15
|
import { writeResult } from "../output/writers/ResultWriter.js";
|
|
16
|
+
import { writeReport } from "../output/writers/ReportWriter.js";
|
|
16
17
|
import { ActionRun } from "./ActionRun.js";
|
|
17
18
|
import { procRegistry } from "../system/proc.js";
|
|
18
19
|
import { currentNode } from "./context.js";
|
|
@@ -41,6 +42,8 @@ export class ExecutionTree {
|
|
|
41
42
|
store = new LogStore();
|
|
42
43
|
folder;
|
|
43
44
|
root;
|
|
45
|
+
/** The om's name, as given to `om(name)`. */
|
|
46
|
+
name;
|
|
44
47
|
/**
|
|
45
48
|
* The om's `.describe(…)` summary, carried here from the builder so it survives the
|
|
46
49
|
* terminal `.run(...)` call. Stored so it is retrievable; nothing reads it yet.
|
|
@@ -58,6 +61,7 @@ export class ExecutionTree {
|
|
|
58
61
|
snapshotStore;
|
|
59
62
|
artifactStore = new ArtifactStore();
|
|
60
63
|
registry = [];
|
|
64
|
+
schemaCache = new Map();
|
|
61
65
|
unsettled = new Set();
|
|
62
66
|
originalLog = console.log.bind(console);
|
|
63
67
|
streaming;
|
|
@@ -84,6 +88,7 @@ export class ExecutionTree {
|
|
|
84
88
|
onUncaught = (e) => this.onFatal("uncaughtException", e);
|
|
85
89
|
onUnhandled = (e) => this.onFatal("unhandledRejection", e);
|
|
86
90
|
constructor(name, definedAt, description, mcp) {
|
|
91
|
+
this.name = name;
|
|
87
92
|
this.description = description;
|
|
88
93
|
this.mcp = mcp;
|
|
89
94
|
// The run's identity: its name + where om() is written (not how it was launched),
|
|
@@ -133,6 +138,7 @@ export class ExecutionTree {
|
|
|
133
138
|
// into the parent's log, attributed to this child's id.
|
|
134
139
|
bubble: (message) => this.append(parent, "child", id, message),
|
|
135
140
|
});
|
|
141
|
+
node.meta = spec.meta;
|
|
136
142
|
parent.children.push(node);
|
|
137
143
|
this.registry.push(node);
|
|
138
144
|
// Launch reference — the parent's pointer to the child's own (absolute) log, plus where
|
|
@@ -307,7 +313,22 @@ export class ExecutionTree {
|
|
|
307
313
|
await writeRollup(at("asserts.log"), flat, entries, (e) => e.level === "assert", assertSummary);
|
|
308
314
|
await writeRollup(at("snapshots.log"), flat, entries, (e) => e.level === "snapshot");
|
|
309
315
|
await writeRollup(at("artifacts.log"), flat, entries, (e) => e.level === "artifact");
|
|
310
|
-
|
|
316
|
+
const run = this.runView();
|
|
317
|
+
await writeResult(at("result.json"), run);
|
|
318
|
+
// The page is a convenience over the record above — a failure to build it must not
|
|
319
|
+
// cost the run its verdict, its exit code, or its other files.
|
|
320
|
+
try {
|
|
321
|
+
await writeReport(at("report.html"), {
|
|
322
|
+
name: this.name,
|
|
323
|
+
folder: this.folder.path(),
|
|
324
|
+
run,
|
|
325
|
+
entries,
|
|
326
|
+
artifacts: this.curatedArtifactsWithNode(),
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
catch (e) {
|
|
330
|
+
this.originalLog(`omkit: report.html not written — ${e instanceof Error ? e.message : String(e)}`);
|
|
331
|
+
}
|
|
311
332
|
// Only now let go of the process hooks — a Ctrl+C during the writes must still be
|
|
312
333
|
// caught (as a no-op) so it can't fall through to Node's kill-without-log default.
|
|
313
334
|
process.off("SIGINT", this.onSigint);
|
|
@@ -338,6 +359,7 @@ export class ExecutionTree {
|
|
|
338
359
|
` asserts → ${at("asserts.log")} ${assertSummary}`,
|
|
339
360
|
` snapshots → ${at("snapshots.log")}`,
|
|
340
361
|
` artifacts → ${at("artifacts.log")}`,
|
|
362
|
+
` report → ${at("report.html")}`,
|
|
341
363
|
];
|
|
342
364
|
}
|
|
343
365
|
/** The recap to the terminal (files are already written). */
|
|
@@ -366,9 +388,34 @@ export class ExecutionTree {
|
|
|
366
388
|
endedAt: node.endedAt,
|
|
367
389
|
duration: node.duration,
|
|
368
390
|
logFile: this.logFile(node.path),
|
|
391
|
+
...this.metaView(node),
|
|
369
392
|
children: node.children.map((child) => this.nodeView(child)),
|
|
370
393
|
};
|
|
371
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* The node's self-description, projected for the record. The root's comes from the om
|
|
397
|
+
* builder; a child's from its action definition. The args schema is converted here, once per
|
|
398
|
+
* definition, so a step launched a thousand times converts its schema once.
|
|
399
|
+
*/
|
|
400
|
+
metaView(node) {
|
|
401
|
+
const meta = node.parentId === null
|
|
402
|
+
? { description: this.description, mcp: this.mcp, describeArgs: node.meta?.describeArgs }
|
|
403
|
+
: node.meta;
|
|
404
|
+
if (!meta)
|
|
405
|
+
return {};
|
|
406
|
+
let inputSchema;
|
|
407
|
+
if (meta.describeArgs) {
|
|
408
|
+
if (!this.schemaCache.has(meta.describeArgs)) {
|
|
409
|
+
this.schemaCache.set(meta.describeArgs, meta.describeArgs().inputSchema);
|
|
410
|
+
}
|
|
411
|
+
inputSchema = this.schemaCache.get(meta.describeArgs);
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
...(meta.description === undefined ? {} : { description: { ...meta.description } }),
|
|
415
|
+
...(meta.mcp === undefined ? {} : { mcp: { ...meta.mcp } }),
|
|
416
|
+
...(inputSchema === undefined ? {} : { inputSchema }),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
372
419
|
/** Test seam: the projected run view (same object written to result.json). */
|
|
373
420
|
runViewForTest() {
|
|
374
421
|
return this.runView();
|
|
@@ -412,6 +459,10 @@ export class ExecutionTree {
|
|
|
412
459
|
* artifact name is free-form, so any printable separator can be forged inside a key.
|
|
413
460
|
*/
|
|
414
461
|
curatedArtifacts() {
|
|
462
|
+
return this.curatedArtifactsWithNode().map(({ nodeId: _nodeId, ...view }) => view);
|
|
463
|
+
}
|
|
464
|
+
/** {@link curatedArtifacts}, each still carrying the node that registered it (for the report). */
|
|
465
|
+
curatedArtifactsWithNode() {
|
|
415
466
|
const byKey = new Map();
|
|
416
467
|
for (const { name, file, description, mime, nodeId } of this.artifactStore.all()) {
|
|
417
468
|
byKey.set(`${nodeId}\0${name}`, {
|
|
@@ -419,6 +470,7 @@ export class ExecutionTree {
|
|
|
419
470
|
file,
|
|
420
471
|
mime,
|
|
421
472
|
...(description === undefined ? {} : { description }),
|
|
473
|
+
nodeId,
|
|
422
474
|
});
|
|
423
475
|
}
|
|
424
476
|
return [...byKey.values()];
|
package/dist/core/action.js
CHANGED
|
@@ -49,9 +49,13 @@ class Builder {
|
|
|
49
49
|
// No `.args()` on this path, so there is nothing to describe — or to resolve against.
|
|
50
50
|
const describeArgs = () => describeSchema(undefined);
|
|
51
51
|
const parseArgs = (supplied) => supplied;
|
|
52
|
+
const meta = {
|
|
53
|
+
...(description === undefined ? {} : { description }),
|
|
54
|
+
...(mcp === undefined ? {} : { mcp }),
|
|
55
|
+
};
|
|
52
56
|
const exec = body;
|
|
53
57
|
const create = (...args) => {
|
|
54
|
-
const spec = { name, args, tags: [], body: exec, definedAt };
|
|
58
|
+
const spec = { name, args, tags: [], body: exec, definedAt, meta };
|
|
55
59
|
return ExecutionTree.require().launch(spec);
|
|
56
60
|
};
|
|
57
61
|
return Object.assign(create, {
|
|
@@ -102,6 +106,11 @@ class ArgsBuilder {
|
|
|
102
106
|
// that built it — rather than in whichever copy the discovery child is running.
|
|
103
107
|
const schema = this.schema;
|
|
104
108
|
const describeArgs = () => describeSchema(schema);
|
|
109
|
+
const meta = {
|
|
110
|
+
...(description === undefined ? {} : { description }),
|
|
111
|
+
...(mcp === undefined ? {} : { mcp }),
|
|
112
|
+
describeArgs,
|
|
113
|
+
};
|
|
105
114
|
// `safeParse` is what applies `.default()`s, so this is where an advertised default
|
|
106
115
|
// becomes the effective one. A refusal names the action and every failing field.
|
|
107
116
|
const parseArgs = (supplied) => {
|
|
@@ -112,7 +121,7 @@ class ArgsBuilder {
|
|
|
112
121
|
};
|
|
113
122
|
const exec = body;
|
|
114
123
|
const create = (...args) => {
|
|
115
|
-
const spec = { name, args, tags: [], body: exec, definedAt };
|
|
124
|
+
const spec = { name, args, tags: [], body: exec, definedAt, meta };
|
|
116
125
|
return ExecutionTree.require().launch(spec);
|
|
117
126
|
};
|
|
118
127
|
return Object.assign(create, {
|
package/dist/core/om.js
CHANGED
|
@@ -37,6 +37,9 @@ function launch(name, site, description, exposure, body, schema) {
|
|
|
37
37
|
}
|
|
38
38
|
const tree = new ExecutionTree(name, site, description, exposure);
|
|
39
39
|
ExecutionTree.current = tree;
|
|
40
|
+
// The root's description and exposure live on the tree; its schema rides on the node.
|
|
41
|
+
if (schema !== undefined)
|
|
42
|
+
tree.root.meta = { describeArgs: () => describeSchema(schema) };
|
|
40
43
|
const rootBody = (ctx) => body({
|
|
41
44
|
signal: ctx.signal,
|
|
42
45
|
logs: ctx.logs,
|
package/dist/core/types.d.ts
CHANGED
|
@@ -239,4 +239,17 @@ export interface LaunchSpec {
|
|
|
239
239
|
readonly body: Exec<object, unknown, unknown>;
|
|
240
240
|
/** `file:line` where the action was defined; carried onto the node for its log header. */
|
|
241
241
|
readonly definedAt: string | undefined;
|
|
242
|
+
/** What the definition declared about itself — carried onto the node for the run record. */
|
|
243
|
+
readonly meta?: ActionMeta;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* A definition's self-description, as it travels onto each node it launches: what
|
|
247
|
+
* `.describe()` and `.mcp()` said, and the `.args()` schema as JSON Schema. Read only by the
|
|
248
|
+
* run record (`result.json`, `report.html`) — the runtime never branches on it.
|
|
249
|
+
*/
|
|
250
|
+
export interface ActionMeta {
|
|
251
|
+
readonly description?: OmDescription;
|
|
252
|
+
readonly mcp?: ResolvedMcpExposure;
|
|
253
|
+
/** Converts the declared args schema; called once, at finalize. */
|
|
254
|
+
readonly describeArgs?: () => DescribedArgs;
|
|
242
255
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { LogEntry } from "../../foundation/LogEntry.ts";
|
|
2
|
+
import type { ArtifactView, RunView } from "./views.ts";
|
|
3
|
+
export interface ReportInput {
|
|
4
|
+
/** The om's name, as given to `om(name)`. */
|
|
5
|
+
readonly name: string;
|
|
6
|
+
/** The run folder (absolute). */
|
|
7
|
+
readonly folder: string;
|
|
8
|
+
readonly run: RunView;
|
|
9
|
+
readonly entries: readonly LogEntry[];
|
|
10
|
+
/** The curated artifacts, as in `run.artifacts`, with the node that registered each. */
|
|
11
|
+
readonly artifacts: ReadonlyArray<ArtifactView & {
|
|
12
|
+
readonly nodeId: string;
|
|
13
|
+
}>;
|
|
14
|
+
}
|
|
15
|
+
/** Writes `report.html` for a finished run. */
|
|
16
|
+
export declare function writeReport(file: string, input: ReportInput): Promise<void>;
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
/**
|
|
6
|
+
* `report.html` — the run as one self-contained page: the run view, every log entry, the
|
|
7
|
+
* snapshot payloads, the run's images (as base64), d3, and the viewer itself, all inlined so
|
|
8
|
+
* the file opens straight from disk, survives being zipped or attached, and needs no server.
|
|
9
|
+
*
|
|
10
|
+
* Heavy by design. Images are kept out of the main JSON — each sits in its own inert
|
|
11
|
+
* `<script type="text/plain">` block the viewer decodes only when it shows that image — so the
|
|
12
|
+
* page's one `JSON.parse` stays proportional to the log, not to the screenshots. The viewer
|
|
13
|
+
* paints a loader first and swaps it out once the data is parsed.
|
|
14
|
+
*/
|
|
15
|
+
/** Snapshot payloads larger than this are embedded as truncated text, not parsed JSON. */
|
|
16
|
+
const SNAPSHOT_CAP = 2 * 1024 * 1024;
|
|
17
|
+
/** A single image larger than this is listed but not embedded. */
|
|
18
|
+
const IMAGE_CAP = 25 * 1024 * 1024;
|
|
19
|
+
/** Images stop being embedded once this many bytes are in the page. */
|
|
20
|
+
const IMAGES_TOTAL_CAP = 200 * 1024 * 1024;
|
|
21
|
+
/** Text artifacts (logs, json, md, …) are embedded up to this size. */
|
|
22
|
+
const TEXT_CAP = 1024 * 1024;
|
|
23
|
+
const IMAGE_MIME = {
|
|
24
|
+
".png": "image/png",
|
|
25
|
+
".jpg": "image/jpeg",
|
|
26
|
+
".jpeg": "image/jpeg",
|
|
27
|
+
".gif": "image/gif",
|
|
28
|
+
".webp": "image/webp",
|
|
29
|
+
".svg": "image/svg+xml",
|
|
30
|
+
};
|
|
31
|
+
/** Writes `report.html` for a finished run. */
|
|
32
|
+
export async function writeReport(file, input) {
|
|
33
|
+
const assets = [];
|
|
34
|
+
const files = await collectFiles(input, assets);
|
|
35
|
+
const snapshots = await collectSnapshots(input);
|
|
36
|
+
const root = await projectRoot(input.folder);
|
|
37
|
+
const payload = {
|
|
38
|
+
version: 1,
|
|
39
|
+
name: input.name,
|
|
40
|
+
folder: input.folder,
|
|
41
|
+
projectRoot: root,
|
|
42
|
+
sources: definitionSources(input.run.root, root),
|
|
43
|
+
generatedAt: Date.now(),
|
|
44
|
+
run: input.run,
|
|
45
|
+
entries: input.entries,
|
|
46
|
+
snapshots,
|
|
47
|
+
files,
|
|
48
|
+
};
|
|
49
|
+
const [d3, css, client] = await Promise.all([
|
|
50
|
+
readFile(d3Path(), "utf8"),
|
|
51
|
+
readFile(assetPath("report.css"), "utf8"),
|
|
52
|
+
readFile(assetPath("report.js"), "utf8"),
|
|
53
|
+
]);
|
|
54
|
+
await writeFile(file, renderPage(input, payload, assets, { d3, css, client }), "utf8");
|
|
55
|
+
}
|
|
56
|
+
function renderPage(input, payload, assets, code) {
|
|
57
|
+
const title = `Run: ${input.name}`;
|
|
58
|
+
const blobs = assets
|
|
59
|
+
.map((a) => `<script type="text/plain" id="${a.id}" data-mime="${a.mime}">${a.data}</script>`)
|
|
60
|
+
.join("\n");
|
|
61
|
+
return `<!doctype html>
|
|
62
|
+
<html lang="en">
|
|
63
|
+
<head>
|
|
64
|
+
<meta charset="utf-8">
|
|
65
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
66
|
+
<title>${escapeHtml(title)}</title>
|
|
67
|
+
<style>${code.css}</style>
|
|
68
|
+
</head>
|
|
69
|
+
<body>
|
|
70
|
+
<div id="loader" role="status" aria-live="polite">
|
|
71
|
+
<div class="loader-card">
|
|
72
|
+
<div class="loader-spin"></div>
|
|
73
|
+
<div class="loader-title">${escapeHtml(title)}</div>
|
|
74
|
+
<div class="loader-step" id="loader-step">Loading the run record…</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
<div id="app" hidden></div>
|
|
78
|
+
<script id="omkit-data" type="application/json">${inertJson(payload)}</script>
|
|
79
|
+
${blobs}
|
|
80
|
+
<script>${inertScript(code.d3)}</script>
|
|
81
|
+
<script>${inertScript(code.client)}</script>
|
|
82
|
+
</body>
|
|
83
|
+
</html>
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
86
|
+
async function collectFiles(input, assets) {
|
|
87
|
+
const out = [];
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
let budget = IMAGES_TOTAL_CAP;
|
|
90
|
+
const artifactTs = artifactTimes(input.entries);
|
|
91
|
+
const add = async (abs, meta) => {
|
|
92
|
+
const key = path.resolve(abs).toLowerCase();
|
|
93
|
+
if (seen.has(key))
|
|
94
|
+
return;
|
|
95
|
+
seen.add(key);
|
|
96
|
+
const base = { ...meta, file: abs, rel: relTo(input.folder, abs) };
|
|
97
|
+
let size;
|
|
98
|
+
try {
|
|
99
|
+
size = (await stat(abs)).size;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
out.push({ ...base, size: 0, omitted: "file not found" });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (meta.mime.startsWith("image/")) {
|
|
106
|
+
if (size > IMAGE_CAP) {
|
|
107
|
+
out.push({ ...base, size, omitted: `larger than ${mb(IMAGE_CAP)}` });
|
|
108
|
+
}
|
|
109
|
+
else if (size > budget) {
|
|
110
|
+
out.push({ ...base, size, omitted: `page image budget (${mb(IMAGES_TOTAL_CAP)}) spent` });
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
budget -= size;
|
|
114
|
+
const id = `asset-${assets.length + 1}`;
|
|
115
|
+
assets.push({ id, mime: meta.mime, data: (await readFile(abs)).toString("base64") });
|
|
116
|
+
out.push({ ...base, size, asset: id });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else if (isText(meta.mime) && size <= TEXT_CAP) {
|
|
120
|
+
out.push({ ...base, size, text: await readFile(abs, "utf8") });
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
out.push({ ...base, size, omitted: isText(meta.mime) ? `larger than ${mb(TEXT_CAP)}` : "binary" });
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
for (const a of input.artifacts) {
|
|
127
|
+
const ts = artifactTs.get(`${a.nodeId}\0${path.resolve(a.file)}`);
|
|
128
|
+
await add(a.file, {
|
|
129
|
+
name: a.name,
|
|
130
|
+
mime: a.mime,
|
|
131
|
+
curated: true,
|
|
132
|
+
nodeId: a.nodeId,
|
|
133
|
+
...(ts === undefined ? {} : { ts }),
|
|
134
|
+
...(a.description === undefined ? {} : { description: a.description }),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
// Images the run left in its folder without labelling them still belong in the picture.
|
|
138
|
+
for (const abs of await walk(input.folder)) {
|
|
139
|
+
const mime = IMAGE_MIME[path.extname(abs).toLowerCase()];
|
|
140
|
+
if (mime)
|
|
141
|
+
await add(abs, { name: path.basename(abs), mime, curated: false });
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
async function collectSnapshots(input) {
|
|
146
|
+
const out = [];
|
|
147
|
+
let seq = 0;
|
|
148
|
+
for (const e of input.entries) {
|
|
149
|
+
if (e.level !== "snapshot")
|
|
150
|
+
continue;
|
|
151
|
+
const split = e.message.lastIndexOf(" → ");
|
|
152
|
+
if (split < 0)
|
|
153
|
+
continue;
|
|
154
|
+
const name = e.message.slice(0, split);
|
|
155
|
+
const abs = e.message.slice(split + 3);
|
|
156
|
+
const base = { seq: ++seq, name, nodeId: e.nodeId, ts: e.ts, file: abs, rel: relTo(input.folder, abs) };
|
|
157
|
+
try {
|
|
158
|
+
const raw = await readFile(abs, "utf8");
|
|
159
|
+
if (raw.length > SNAPSHOT_CAP) {
|
|
160
|
+
out.push({ ...base, text: raw.slice(0, SNAPSHOT_CAP), truncated: true });
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
try {
|
|
164
|
+
out.push({ ...base, value: JSON.parse(raw) });
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
out.push({ ...base, text: raw });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
out.push({ ...base, text: "(snapshot file not found)" });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
/** `ts` of each `artifact` timeline line, keyed by `(nodeId, file)` — the latest wins. */
|
|
178
|
+
function artifactTimes(entries) {
|
|
179
|
+
const out = new Map();
|
|
180
|
+
for (const e of entries) {
|
|
181
|
+
if (e.level !== "artifact")
|
|
182
|
+
continue;
|
|
183
|
+
const split = e.message.lastIndexOf(" → ");
|
|
184
|
+
if (split >= 0)
|
|
185
|
+
out.set(`${e.nodeId}\0${path.resolve(e.message.slice(split + 3))}`, e.ts);
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The project the run belongs to, for project-relative paths: the nearest ancestor of the run
|
|
191
|
+
* folder holding `.git`, else the folder `logs/` sits in (the root omkit was given), else cwd.
|
|
192
|
+
*/
|
|
193
|
+
async function projectRoot(folder) {
|
|
194
|
+
let dir = path.resolve(folder);
|
|
195
|
+
for (;;) {
|
|
196
|
+
try {
|
|
197
|
+
await stat(path.join(dir, ".git"));
|
|
198
|
+
return dir;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
/* keep climbing */
|
|
202
|
+
}
|
|
203
|
+
const up = path.dirname(dir);
|
|
204
|
+
if (up === dir)
|
|
205
|
+
break;
|
|
206
|
+
dir = up;
|
|
207
|
+
}
|
|
208
|
+
return process.env.OMKIT_ROOT ? path.resolve(process.env.OMKIT_ROOT) : process.cwd();
|
|
209
|
+
}
|
|
210
|
+
/** Each distinct `definedAt` site → its file, relative to the project root, forward-slashed, no line. */
|
|
211
|
+
function definitionSources(root, projectDir) {
|
|
212
|
+
const out = {};
|
|
213
|
+
const visit = (n) => {
|
|
214
|
+
if (n.definedAt && !(n.definedAt in out)) {
|
|
215
|
+
const file = n.definedAt.replace(/:\d+(:\d+)?$/, "");
|
|
216
|
+
out[n.definedAt] = path.relative(projectDir, file).split(path.sep).join("/") || file;
|
|
217
|
+
}
|
|
218
|
+
n.children.forEach(visit);
|
|
219
|
+
};
|
|
220
|
+
visit(root);
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
async function walk(dir, depth = 0) {
|
|
224
|
+
if (depth > 4)
|
|
225
|
+
return [];
|
|
226
|
+
let names;
|
|
227
|
+
try {
|
|
228
|
+
names = await readdir(dir, { withFileTypes: true });
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
const out = [];
|
|
234
|
+
for (const d of names) {
|
|
235
|
+
const abs = path.join(dir, d.name);
|
|
236
|
+
if (d.isDirectory())
|
|
237
|
+
out.push(...(await walk(abs, depth + 1)));
|
|
238
|
+
else if (d.isFile())
|
|
239
|
+
out.push(abs);
|
|
240
|
+
}
|
|
241
|
+
return out.sort();
|
|
242
|
+
}
|
|
243
|
+
function relTo(folder, abs) {
|
|
244
|
+
const rel = path.relative(folder, abs);
|
|
245
|
+
return rel.startsWith("..") || path.isAbsolute(rel) ? abs : rel.split(path.sep).join("/");
|
|
246
|
+
}
|
|
247
|
+
function isText(mime) {
|
|
248
|
+
return (mime.startsWith("text/") ||
|
|
249
|
+
mime === "application/json" ||
|
|
250
|
+
mime === "application/x-ndjson" ||
|
|
251
|
+
mime === "image/svg+xml");
|
|
252
|
+
}
|
|
253
|
+
function mb(bytes) {
|
|
254
|
+
return `${Math.round(bytes / 1024 / 1024)} MB`;
|
|
255
|
+
}
|
|
256
|
+
/** JSON safe to sit inside a `<script>` block: no `</script>`, no `<!--`. */
|
|
257
|
+
function inertJson(value) {
|
|
258
|
+
return JSON.stringify(value).replace(/</g, "\\u003c");
|
|
259
|
+
}
|
|
260
|
+
/** JS safe to sit inside a `<script>` block. */
|
|
261
|
+
function inertScript(code) {
|
|
262
|
+
return code.replace(/<\/script/gi, "<\\/script");
|
|
263
|
+
}
|
|
264
|
+
function escapeHtml(s) {
|
|
265
|
+
return s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
266
|
+
}
|
|
267
|
+
/** The viewer's own files, next to this module (`report/`) in both `src` and `dist`. */
|
|
268
|
+
function assetPath(name) {
|
|
269
|
+
return fileURLToPath(new URL(`./report/${name}`, import.meta.url));
|
|
270
|
+
}
|
|
271
|
+
/** d3's UMD bundle — the package's `exports` hide `dist/`, so go via its resolved entry. */
|
|
272
|
+
function d3Path() {
|
|
273
|
+
const entry = createRequire(import.meta.url).resolve("d3");
|
|
274
|
+
return path.join(path.dirname(entry), "..", "dist", "d3.min.js");
|
|
275
|
+
}
|