omkit 0.8.8 → 0.8.10

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.
@@ -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.
@@ -122,6 +124,8 @@ export declare class ExecutionTree {
122
124
  * artifact name is free-form, so any printable separator can be forged inside a key.
123
125
  */
124
126
  private curatedArtifacts;
127
+ /** {@link curatedArtifacts}, each still carrying the node that registered it (for the report). */
128
+ private curatedArtifactsWithNode;
125
129
  /**
126
130
  * Absolute `.log` path for a node — root is `main.log`, children drop the `main/`
127
131
  * 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.
@@ -84,6 +87,7 @@ export class ExecutionTree {
84
87
  onUncaught = (e) => this.onFatal("uncaughtException", e);
85
88
  onUnhandled = (e) => this.onFatal("unhandledRejection", e);
86
89
  constructor(name, definedAt, description, mcp) {
90
+ this.name = name;
87
91
  this.description = description;
88
92
  this.mcp = mcp;
89
93
  // The run's identity: its name + where om() is written (not how it was launched),
@@ -307,7 +311,22 @@ export class ExecutionTree {
307
311
  await writeRollup(at("asserts.log"), flat, entries, (e) => e.level === "assert", assertSummary);
308
312
  await writeRollup(at("snapshots.log"), flat, entries, (e) => e.level === "snapshot");
309
313
  await writeRollup(at("artifacts.log"), flat, entries, (e) => e.level === "artifact");
310
- await writeResult(at("result.json"), this.runView());
314
+ const run = this.runView();
315
+ await writeResult(at("result.json"), run);
316
+ // The page is a convenience over the record above — a failure to build it must not
317
+ // cost the run its verdict, its exit code, or its other files.
318
+ try {
319
+ await writeReport(at("report.html"), {
320
+ name: this.name,
321
+ folder: this.folder.path(),
322
+ run,
323
+ entries,
324
+ artifacts: this.curatedArtifactsWithNode(),
325
+ });
326
+ }
327
+ catch (e) {
328
+ this.originalLog(`omkit: report.html not written — ${e instanceof Error ? e.message : String(e)}`);
329
+ }
311
330
  // Only now let go of the process hooks — a Ctrl+C during the writes must still be
312
331
  // caught (as a no-op) so it can't fall through to Node's kill-without-log default.
313
332
  process.off("SIGINT", this.onSigint);
@@ -338,6 +357,7 @@ export class ExecutionTree {
338
357
  ` asserts → ${at("asserts.log")} ${assertSummary}`,
339
358
  ` snapshots → ${at("snapshots.log")}`,
340
359
  ` artifacts → ${at("artifacts.log")}`,
360
+ ` report → ${at("report.html")}`,
341
361
  ];
342
362
  }
343
363
  /** The recap to the terminal (files are already written). */
@@ -412,6 +432,10 @@ export class ExecutionTree {
412
432
  * artifact name is free-form, so any printable separator can be forged inside a key.
413
433
  */
414
434
  curatedArtifacts() {
435
+ return this.curatedArtifactsWithNode().map(({ nodeId: _nodeId, ...view }) => view);
436
+ }
437
+ /** {@link curatedArtifacts}, each still carrying the node that registered it (for the report). */
438
+ curatedArtifactsWithNode() {
415
439
  const byKey = new Map();
416
440
  for (const { name, file, description, mime, nodeId } of this.artifactStore.all()) {
417
441
  byKey.set(`${nodeId}\0${name}`, {
@@ -419,6 +443,7 @@ export class ExecutionTree {
419
443
  file,
420
444
  mime,
421
445
  ...(description === undefined ? {} : { description }),
446
+ nodeId,
422
447
  });
423
448
  }
424
449
  return [...byKey.values()];
@@ -39,9 +39,9 @@ const legend = [
39
39
  "#",
40
40
  "# ── legend ──────────────────────────────────────────────────────────",
41
41
  "# [n] global sequence — the merge/order key across every .log",
42
- "# indented rows a child action's milestones, bubbled up here",
42
+ "# launch ACTION_RUN(id) · → · ….log a child started — open that file for its full output",
43
+ "# indented rows a child action's milestones, bubbled up here (one level under launches)",
43
44
  "# name [tags] the action and its tags · defined … where its code lives",
44
- "# launch · → ….log a child started — open that file for its full output",
45
45
  "# ⚡ event ✓ done ok ⊘ cancelled ✗ failed ⊨/⊭ assert pass/fail 📸 snapshot ▪ output",
46
46
  ];
47
47
  const stamp = (ms) => (ms ? new Date(ms).toISOString() : "-");
@@ -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,238 @@
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 payload = {
37
+ version: 1,
38
+ name: input.name,
39
+ folder: input.folder,
40
+ generatedAt: Date.now(),
41
+ run: input.run,
42
+ entries: input.entries,
43
+ snapshots,
44
+ files,
45
+ };
46
+ const [d3, css, client] = await Promise.all([
47
+ readFile(d3Path(), "utf8"),
48
+ readFile(assetPath("report.css"), "utf8"),
49
+ readFile(assetPath("report.js"), "utf8"),
50
+ ]);
51
+ await writeFile(file, renderPage(input, payload, assets, { d3, css, client }), "utf8");
52
+ }
53
+ function renderPage(input, payload, assets, code) {
54
+ const title = `Run: ${input.name}`;
55
+ const blobs = assets
56
+ .map((a) => `<script type="text/plain" id="${a.id}" data-mime="${a.mime}">${a.data}</script>`)
57
+ .join("\n");
58
+ return `<!doctype html>
59
+ <html lang="en">
60
+ <head>
61
+ <meta charset="utf-8">
62
+ <meta name="viewport" content="width=device-width, initial-scale=1">
63
+ <title>${escapeHtml(title)}</title>
64
+ <style>${code.css}</style>
65
+ </head>
66
+ <body>
67
+ <div id="loader" role="status" aria-live="polite">
68
+ <div class="loader-card">
69
+ <div class="loader-spin"></div>
70
+ <div class="loader-title">${escapeHtml(title)}</div>
71
+ <div class="loader-step" id="loader-step">Loading the run record…</div>
72
+ </div>
73
+ </div>
74
+ <div id="app" hidden></div>
75
+ <script id="omkit-data" type="application/json">${inertJson(payload)}</script>
76
+ ${blobs}
77
+ <script>${inertScript(code.d3)}</script>
78
+ <script>${inertScript(code.client)}</script>
79
+ </body>
80
+ </html>
81
+ `;
82
+ }
83
+ async function collectFiles(input, assets) {
84
+ const out = [];
85
+ const seen = new Set();
86
+ let budget = IMAGES_TOTAL_CAP;
87
+ const artifactTs = artifactTimes(input.entries);
88
+ const add = async (abs, meta) => {
89
+ const key = path.resolve(abs).toLowerCase();
90
+ if (seen.has(key))
91
+ return;
92
+ seen.add(key);
93
+ const base = { ...meta, file: abs, rel: relTo(input.folder, abs) };
94
+ let size;
95
+ try {
96
+ size = (await stat(abs)).size;
97
+ }
98
+ catch {
99
+ out.push({ ...base, size: 0, omitted: "file not found" });
100
+ return;
101
+ }
102
+ if (meta.mime.startsWith("image/")) {
103
+ if (size > IMAGE_CAP) {
104
+ out.push({ ...base, size, omitted: `larger than ${mb(IMAGE_CAP)}` });
105
+ }
106
+ else if (size > budget) {
107
+ out.push({ ...base, size, omitted: `page image budget (${mb(IMAGES_TOTAL_CAP)}) spent` });
108
+ }
109
+ else {
110
+ budget -= size;
111
+ const id = `asset-${assets.length + 1}`;
112
+ assets.push({ id, mime: meta.mime, data: (await readFile(abs)).toString("base64") });
113
+ out.push({ ...base, size, asset: id });
114
+ }
115
+ }
116
+ else if (isText(meta.mime) && size <= TEXT_CAP) {
117
+ out.push({ ...base, size, text: await readFile(abs, "utf8") });
118
+ }
119
+ else {
120
+ out.push({ ...base, size, omitted: isText(meta.mime) ? `larger than ${mb(TEXT_CAP)}` : "binary" });
121
+ }
122
+ };
123
+ for (const a of input.artifacts) {
124
+ const ts = artifactTs.get(`${a.nodeId}\0${path.resolve(a.file)}`);
125
+ await add(a.file, {
126
+ name: a.name,
127
+ mime: a.mime,
128
+ curated: true,
129
+ nodeId: a.nodeId,
130
+ ...(ts === undefined ? {} : { ts }),
131
+ ...(a.description === undefined ? {} : { description: a.description }),
132
+ });
133
+ }
134
+ // Images the run left in its folder without labelling them still belong in the picture.
135
+ for (const abs of await walk(input.folder)) {
136
+ const mime = IMAGE_MIME[path.extname(abs).toLowerCase()];
137
+ if (mime)
138
+ await add(abs, { name: path.basename(abs), mime, curated: false });
139
+ }
140
+ return out;
141
+ }
142
+ async function collectSnapshots(input) {
143
+ const out = [];
144
+ let seq = 0;
145
+ for (const e of input.entries) {
146
+ if (e.level !== "snapshot")
147
+ continue;
148
+ const split = e.message.lastIndexOf(" → ");
149
+ if (split < 0)
150
+ continue;
151
+ const name = e.message.slice(0, split);
152
+ const abs = e.message.slice(split + 3);
153
+ const base = { seq: ++seq, name, nodeId: e.nodeId, ts: e.ts, file: abs, rel: relTo(input.folder, abs) };
154
+ try {
155
+ const raw = await readFile(abs, "utf8");
156
+ if (raw.length > SNAPSHOT_CAP) {
157
+ out.push({ ...base, text: raw.slice(0, SNAPSHOT_CAP), truncated: true });
158
+ }
159
+ else {
160
+ try {
161
+ out.push({ ...base, value: JSON.parse(raw) });
162
+ }
163
+ catch {
164
+ out.push({ ...base, text: raw });
165
+ }
166
+ }
167
+ }
168
+ catch {
169
+ out.push({ ...base, text: "(snapshot file not found)" });
170
+ }
171
+ }
172
+ return out;
173
+ }
174
+ /** `ts` of each `artifact` timeline line, keyed by `(nodeId, file)` — the latest wins. */
175
+ function artifactTimes(entries) {
176
+ const out = new Map();
177
+ for (const e of entries) {
178
+ if (e.level !== "artifact")
179
+ continue;
180
+ const split = e.message.lastIndexOf(" → ");
181
+ if (split >= 0)
182
+ out.set(`${e.nodeId}\0${path.resolve(e.message.slice(split + 3))}`, e.ts);
183
+ }
184
+ return out;
185
+ }
186
+ async function walk(dir, depth = 0) {
187
+ if (depth > 4)
188
+ return [];
189
+ let names;
190
+ try {
191
+ names = await readdir(dir, { withFileTypes: true });
192
+ }
193
+ catch {
194
+ return [];
195
+ }
196
+ const out = [];
197
+ for (const d of names) {
198
+ const abs = path.join(dir, d.name);
199
+ if (d.isDirectory())
200
+ out.push(...(await walk(abs, depth + 1)));
201
+ else if (d.isFile())
202
+ out.push(abs);
203
+ }
204
+ return out.sort();
205
+ }
206
+ function relTo(folder, abs) {
207
+ const rel = path.relative(folder, abs);
208
+ return rel.startsWith("..") || path.isAbsolute(rel) ? abs : rel.split(path.sep).join("/");
209
+ }
210
+ function isText(mime) {
211
+ return (mime.startsWith("text/") ||
212
+ mime === "application/json" ||
213
+ mime === "application/x-ndjson" ||
214
+ mime === "image/svg+xml");
215
+ }
216
+ function mb(bytes) {
217
+ return `${Math.round(bytes / 1024 / 1024)} MB`;
218
+ }
219
+ /** JSON safe to sit inside a `<script>` block: no `</script>`, no `<!--`. */
220
+ function inertJson(value) {
221
+ return JSON.stringify(value).replace(/</g, "\\u003c");
222
+ }
223
+ /** JS safe to sit inside a `<script>` block. */
224
+ function inertScript(code) {
225
+ return code.replace(/<\/script/gi, "<\\/script");
226
+ }
227
+ function escapeHtml(s) {
228
+ return s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
229
+ }
230
+ /** The viewer's own files, next to this module (`report/`) in both `src` and `dist`. */
231
+ function assetPath(name) {
232
+ return fileURLToPath(new URL(`./report/${name}`, import.meta.url));
233
+ }
234
+ /** d3's UMD bundle — the package's `exports` hide `dist/`, so go via its resolved entry. */
235
+ function d3Path() {
236
+ const entry = createRequire(import.meta.url).resolve("d3");
237
+ return path.join(path.dirname(entry), "..", "dist", "d3.min.js");
238
+ }
@@ -6,10 +6,20 @@ import { label } from "../../foundation/ActionRef.js";
6
6
  * the child by its {@link label} (name + tags) via `refOf`; everything else keeps its
7
7
  * `source` (proc name, `console`, …).
8
8
  */
9
- export const entryLine = (e, refOf) => {
10
- // Bubbled child rows are nested a level in — the whole row, `[seq]` included.
11
- const indent = e.level === "child" ? " " : "";
12
- return `${indent}[${e.sequence}] ${bodyOf(e, refOf)}`;
9
+ export const entryLine = (e, refOf) => `${indentOf(e)}[${e.sequence}] ${bodyOf(e, refOf)}`;
10
+ /**
11
+ * Bubbled child rows are nested in — the whole row, `[seq]` included. The launch pointer
12
+ * sits one level in; the child's own milestones sit one level deeper, under their launch.
13
+ */
14
+ const indentOf = (e) => {
15
+ if (e.level !== "child")
16
+ return "";
17
+ return e.source === "launch" ? " " : " ";
18
+ };
19
+ /** `→ <id> · <logfile> · …` → `ACTION_RUN(<id>) · → · <logfile> · …`, so a launch stands out. */
20
+ const launchBody = (message) => {
21
+ const [head = "", ...rest] = message.replace(/^→\s*/, "").split(" · ");
22
+ return [`ACTION_RUN(${head})`, "→", ...rest].join(" · ");
13
23
  };
14
24
  const bodyOf = (e, refOf) => {
15
25
  switch (e.level) {
@@ -28,11 +38,11 @@ const bodyOf = (e, refOf) => {
28
38
  // output. (Cancellation no longer logs at this level — it's a clean stop.)
29
39
  return e.source === "error" ? `✗ ${e.message}` : `▪ ${e.message}`;
30
40
  case "child": {
31
- // A bubbled child line (indented at the row level by `entryLine`). The launch pointer
32
- // (`source === "launch"`) keeps its `→ id · logfile` message; a milestone names the
33
- // child by its label (name + tags) via `refOf`.
41
+ // A bubbled child line (indented at the row level by `indentOf`). The launch pointer
42
+ // (`source === "launch"`) reads `launch ACTION_RUN(id) · → · logfile`; a milestone
43
+ // names the child by its label (name + tags) via `refOf`.
34
44
  if (e.source === "launch")
35
- return `${e.source} · ${e.message}`;
45
+ return `${e.source} ${launchBody(e.message)}`;
36
46
  const ref = refOf?.(e.source);
37
47
  return `${ref ? label(ref) : e.source} · ${e.message}`;
38
48
  }