omkit 0.0.11 → 0.0.13

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.
@@ -33,12 +33,16 @@ export interface ChromePageOptions {
33
33
  * from whatever {@link ChromePageSource} is given — a CDP endpoint to connect to,
34
34
  * or an already-owned Playwright handle (an Electron `BrowserWindow` page, a
35
35
  * `BrowserContext`, or a `Browser`). The source may be a promise, so it wires
36
- * straight from another action's `.ref` (e.g. `chromePage(chromedriver.ref)` or
37
- * `chromePage(electronApp.ref)`).
36
+ * straight from another action's `.ref` (e.g. `chromePage("Explorer",
37
+ * chromedriver.ref)` or `chromePage("App", electronApp.ref)`).
38
38
  *
39
39
  * Daemon-shaped like the watch actions: acquire the page, attach it, then stay
40
40
  * alive on a promise that only resolves when the run's `signal` aborts. On
41
41
  * teardown it closes the CDP connection *only* when it opened one — a handed-in
42
42
  * browser or window is left untouched for its owner to close.
43
+ *
44
+ * `label` names this page in the log (its `source`), so several `chromePage`
45
+ * instances in one run stay distinguishable — e.g. `chromePage("Explorer",
46
+ * chrome.ref)` logs its lines under `Explorer` rather than a shared default.
43
47
  */
44
- export declare const chromePage: import("../index.ts").Action<[source: ChromePageSource | Promise<ChromePageSource>, opts?: ChromePageOptions | undefined], void, ChromePageEvents, Page>;
48
+ export declare const chromePage: import("../index.ts").Action<[label: string, source: ChromePageSource | Promise<ChromePageSource>, opts?: ChromePageOptions | undefined], void, ChromePageEvents, Page>;
@@ -13,18 +13,22 @@ const pageOf = (context, fresh) => fresh ? context.newPage() : (context.pages()[
13
13
  * from whatever {@link ChromePageSource} is given — a CDP endpoint to connect to,
14
14
  * or an already-owned Playwright handle (an Electron `BrowserWindow` page, a
15
15
  * `BrowserContext`, or a `Browser`). The source may be a promise, so it wires
16
- * straight from another action's `.ref` (e.g. `chromePage(chromedriver.ref)` or
17
- * `chromePage(electronApp.ref)`).
16
+ * straight from another action's `.ref` (e.g. `chromePage("Explorer",
17
+ * chromedriver.ref)` or `chromePage("App", electronApp.ref)`).
18
18
  *
19
19
  * Daemon-shaped like the watch actions: acquire the page, attach it, then stay
20
20
  * alive on a promise that only resolves when the run's `signal` aborts. On
21
21
  * teardown it closes the CDP connection *only* when it opened one — a handed-in
22
22
  * browser or window is left untouched for its owner to close.
23
+ *
24
+ * `label` names this page in the log (its `source`), so several `chromePage`
25
+ * instances in one run stay distinguishable — e.g. `chromePage("Explorer",
26
+ * chrome.ref)` logs its lines under `Explorer` rather than a shared default.
23
27
  */
24
28
  export const chromePage = action("Chrome Page")
25
29
  .emits()
26
30
  .ref()
27
- .run(async ({ logs, signal, emit, attach }, source, opts = {}) => {
31
+ .run(async ({ logs, signal, emit, attach }, label, source, opts = {}) => {
28
32
  const { url } = opts;
29
33
  const resolved = await source;
30
34
  // Resolve the source to a Page, recording what (if anything) we own. Only a
@@ -60,23 +64,23 @@ export const chromePage = action("Chrome Page")
60
64
  page = await pageOf(resolved, url !== undefined);
61
65
  }
62
66
  page.on("console", (msg) => {
63
- logs.append({ source: "Chrome Page", level: "info", message: `console: ${msg.text()}` });
64
- emit("console", msg.text());
67
+ logs.append({ source: label, level: "info", message: `console: ${msg.text()}` });
68
+ emit("console", `from: ${label}: ${msg.text()}`);
65
69
  });
66
70
  page.on("pageerror", (err) => {
67
- logs.append({ source: "Chrome Page", level: "error", message: err.message });
68
- emit("pageerror", err.message);
71
+ logs.append({ source: label, level: "error", message: err.message });
72
+ emit("pageerror", `from: ${label}: ${err.message}`);
69
73
  });
70
74
  page.on("framenavigated", (frame) => {
71
75
  if (frame === page.mainFrame())
72
- emit("navigated", frame.url());
76
+ emit("navigated", `${label}: ${frame.url()}`);
73
77
  });
74
78
  if (url) {
75
79
  await page.goto(url);
76
- logs.append({ source: "Chrome Page", level: "info", message: `navigated ${url}` });
80
+ logs.append({ source: label, level: "info", message: `navigated ${url}` });
77
81
  }
78
82
  attach(page); // resolves instance.ref for every downstream action
79
- logs.append({ source: "Chrome Page", level: "info", message: `attached ${from}` });
83
+ logs.append({ source: label, level: "info", message: `attached ${from}` });
80
84
  // Daemon: hold the page open until teardown, then drop the CDP session if it
81
85
  // was ours; never close a handed-in browser/window.
82
86
  return new Promise((resolveRun) => {
@@ -6,8 +6,6 @@ export interface CommandOptions {
6
6
  * node was executed from (process.cwd()). Default ".".
7
7
  */
8
8
  cwd?: string;
9
- /** Label the child's output is logged under. Defaults to the action's name. */
10
- label?: string;
11
9
  /**
12
10
  * Environment for the child. Pass the full set you want (e.g.
13
11
  * `{ ...process.env, NODE_ENV: "development" }`) — it's handed to the child
@@ -21,7 +21,7 @@ export function command(name, cmdOrFile, argsOrOptions, maybeOptions) {
21
21
  // against process.cwd() — exactly the "absolute from where node runs" rule.
22
22
  const cwd = path.resolve(options.cwd ?? ".");
23
23
  // proc sources the LogsCollector from async context and streams output into it.
24
- return proc(options.label ?? name, {
24
+ return proc(name, {
25
25
  cwd,
26
26
  ...(options.env ? { env: options.env } : {}),
27
27
  })(verbatim(cmdOrFile));
@@ -36,7 +36,7 @@ export function command(name, cmdOrFile, argsOrOptions, maybeOptions) {
36
36
  */
37
37
  const spawnDirect = (snapshots, logs, signal, name, file, args, options) => {
38
38
  const cwd = path.resolve(options.cwd ?? ".");
39
- const source = options.label ?? name;
39
+ const source = name;
40
40
  // windowsHide keeps a stray console window from flashing for the child.
41
41
  // env omitted -> spawn inherits the parent's process.env (Node's default).
42
42
  const child = spawn(file, args, { cwd, windowsHide: true, env: options.env });
package/dist/index.d.ts CHANGED
@@ -10,6 +10,8 @@
10
10
  */
11
11
  export { action } from "./orchestration/action/action.ts";
12
12
  export type { Action, ActionBuilder, ActionInstance, AnyActionInstance, ActionContext, SystemGlobal, SystemEvents, InstanceEvents, NoEvents, Awaitable, Outcome, } from "./orchestration/action/types.ts";
13
+ export { AssertionError } from "./orchestration/assert.ts";
14
+ export type { Assert } from "./orchestration/assert.ts";
13
15
  export { spin } from "./orchestration/spin/spin.ts";
14
16
  export type { Nod, SpinContext, SpinBody } from "./orchestration/spin/types.ts";
15
17
  export { events } from "./orchestration/events/events.ts";
@@ -17,7 +19,7 @@ export type { Emitter, EventHandler, EventMeta } from "./orchestration/events/ev
17
19
  export { createProc } from "./system/process/process.ts";
18
20
  export type { Proc } from "./system/process/process.ts";
19
21
  export { LogsCollector } from "./output/log/LogsCollector.ts";
20
- export type { Logger, LogEntry, LogInput, SubscribeOptions, } from "./output/log/LogsCollector.ts";
22
+ export type { Logger, LogEntry, LogInput, SubscribeOptions } from "./output/log/LogsCollector.ts";
21
23
  export { Output } from "./output/index.ts";
22
24
  export type { SnapshotRef } from "./output/index.ts";
23
25
  export { FolderCache } from "./system/fs/FolderCache.ts";
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
  // ── Actions ────────────────────────────────────────────────────────────────
12
12
  export { action } from "./orchestration/action/action.js";
13
+ export { AssertionError } from "./orchestration/assert.js";
13
14
  // ── Spin (linear orchestration) ──────────────────────────────────────────────
14
15
  export { spin } from "./orchestration/spin/spin.js";
15
16
  // ── Events ─────────────────────────────────────────────────────────────────
@@ -123,6 +123,7 @@ class ActionRun {
123
123
  signal: ctx.signal,
124
124
  nod: ctx.nod,
125
125
  output: ctx.output,
126
+ assert: ctx.assert,
126
127
  });
127
128
  // Re-throw the inner failure so the wrapper fails too (the framework re-wraps
128
129
  // it into this wrapper's own Outcome); only record on success.
@@ -2,6 +2,7 @@ import type { Logger } from "../../output/log/LogsCollector.ts";
2
2
  import type { Output } from "../../output/Output.ts";
3
3
  import type { Emitter, EventHandler } from "../events/events.ts";
4
4
  import type { Proc } from "../../system/process/process.ts";
5
+ import type { Assert } from "../assert.ts";
5
6
  /** An action that declares no events. */
6
7
  export type NoEvents = Record<never, never>;
7
8
  /** A value an action's `exec` may return: a result, or a promise of one. */
@@ -67,6 +68,12 @@ export interface SystemGlobal {
67
68
  * capture their own artifacts.
68
69
  */
69
70
  readonly output: Output;
71
+ /**
72
+ * Assert a boolean invariant for this action. Logs a `⊨` line every call; a
73
+ * mismatch is recorded into the run's verdict (never throws, never stops the
74
+ * run). Bound to this action's path for attribution.
75
+ */
76
+ readonly assert: Assert;
70
77
  }
71
78
  /** What an action's implementation receives: the system bag, plus `emit`, `attach`, and `proc`. */
72
79
  export interface ActionContext<Events extends object, Handle = void> extends SystemGlobal {
@@ -113,7 +120,7 @@ export interface ActionInstance<Result = unknown, Events extends object = NoEven
113
120
  readonly done: Promise<Outcome<Result>>;
114
121
  /**
115
122
  * Resolves with the handle the action attached via `ctx.attach` (set once).
116
- * Used to thread a handle into another action (`chromePage(driver.ref)`). If
123
+ * Used to thread a handle into another action (`chromePage("Explorer", driver.ref)`). If
117
124
  * the action settles *without* attaching, this resolves (`undefined` for the
118
125
  * default `void` handle) on success and **rejects on failure** — so, unlike
119
126
  * `done`, awaiting `ref` in a body can throw. It's meant to be consumed as a
@@ -0,0 +1,22 @@
1
+ import type { Logger } from "../output/log/LogsCollector.ts";
2
+ /**
3
+ * An inline check injected into every action's `ctx` and the `spin` body. Passes
4
+ * when `value` is `true`. Every call is logged; a failure is recorded into the
5
+ * run's verdict — as data, not a throw — so the run keeps going and later
6
+ * assertions still fire. Returns the pass boolean so a body can branch on it.
7
+ *
8
+ * assert(page.isVisible(), "page loaded")
9
+ */
10
+ export type Assert = (value: boolean, what: string) => boolean;
11
+ /** Marks a failed assertion in the spin's verdict, distinct from an action's own error. */
12
+ export declare class AssertionError extends Error {
13
+ constructor(message: string);
14
+ }
15
+ /**
16
+ * Build an {@link Assert} bound to one action (or the spin body): `name` is its
17
+ * launch path (used for attribution in the log line and the error), `logs` is the
18
+ * logger to append to, and `onFail` records a genuine failure into the verdict. The
19
+ * factory is file-agnostic — it only appends `level: "assert"` entries; the
20
+ * dedicated `assertions.log` is a downstream projection of that stream.
21
+ */
22
+ export declare function createAssert(name: string, logs: Logger, onFail: (error: AssertionError) => void): Assert;
@@ -0,0 +1,26 @@
1
+ /** Marks a failed assertion in the spin's verdict, distinct from an action's own error. */
2
+ export class AssertionError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = "AssertionError";
6
+ }
7
+ }
8
+ /**
9
+ * Build an {@link Assert} bound to one action (or the spin body): `name` is its
10
+ * launch path (used for attribution in the log line and the error), `logs` is the
11
+ * logger to append to, and `onFail` records a genuine failure into the verdict. The
12
+ * factory is file-agnostic — it only appends `level: "assert"` entries; the
13
+ * dedicated `assertions.log` is a downstream projection of that stream.
14
+ */
15
+ export function createAssert(name, logs, onFail) {
16
+ return (value, what) => {
17
+ const pass = value;
18
+ const glyph = pass ? "✓" : "✗";
19
+ const message = `${name} · ${glyph} ${what}`;
20
+ logs.append({ source: "assert", level: "assert", message });
21
+ if (!pass) {
22
+ onFail(new AssertionError(`${name}: ${what}`));
23
+ }
24
+ return pass;
25
+ };
26
+ }
@@ -32,6 +32,10 @@ export declare class SpinHost {
32
32
  readonly signal: AbortSignal;
33
33
  /** Settles once the spin reaches `closed`, carrying the terminal verdict. Never rejects. */
34
34
  readonly done: Promise<SpinResult>;
35
+ /** The original `console.log`, captured before patching — the run's terminal writer. */
36
+ private readonly term;
37
+ /** Reinstalls the pre-run console methods; called once at finalize. */
38
+ private readonly restoreConsole;
35
39
  private readonly controller;
36
40
  private phase;
37
41
  /** The current lifecycle state — see {@link SpinState}. */
@@ -2,7 +2,9 @@ import { relative } from "node:path";
2
2
  import { producerOfRef } from "../action/action.js";
3
3
  import { LogsCollector, ScopedLogger } from "../../output/log/LogsCollector.js";
4
4
  import { LogRenderer } from "../../output/log/LogRenderer.js";
5
+ import { actionScope, captureConsole } from "../../output/log/console-capture.js";
5
6
  import { Output } from "../../output/Output.js";
7
+ import { createAssert } from "../assert.js";
6
8
  /** The entry script, relative to where node was invoked (so the log says what produced it). */
7
9
  const scriptPath = () => {
8
10
  const entry = process.argv[1];
@@ -58,6 +60,10 @@ export class SpinHost {
58
60
  signal;
59
61
  /** Settles once the spin reaches `closed`, carrying the terminal verdict. Never rejects. */
60
62
  done;
63
+ /** The original `console.log`, captured before patching — the run's terminal writer. */
64
+ term;
65
+ /** Reinstalls the pre-run console methods; called once at finalize. */
66
+ restoreConsole;
61
67
  controller = new AbortController();
62
68
  // ── Lifecycle state ──────────────────────────────────────────────────────
63
69
  // The single source of truth for "where in its life is this run". Only
@@ -89,6 +95,12 @@ export class SpinHost {
89
95
  for (const instance of actions)
90
96
  assertInstance(instance);
91
97
  this.signal = this.controller.signal;
98
+ // Capture the ORIGINAL console.log as this run's terminal writer BEFORE patching,
99
+ // or `drain`/finalize would re-enter the patched method and feedback-loop. Then
100
+ // patch console so every console.* during the run folds into this run's log,
101
+ // attributed via `actionScope` (see `launch`).
102
+ this.term = console.log.bind(console);
103
+ this.restoreConsole = captureConsole(this.logs);
92
104
  process.once("SIGINT", this.onSigint);
93
105
  process.on("uncaughtException", this.onUncaught);
94
106
  process.on("unhandledRejection", this.onUnhandled);
@@ -150,7 +162,7 @@ export class SpinHost {
150
162
  void (async () => {
151
163
  const renderer = new LogRenderer();
152
164
  for await (const entry of this.logs.subscribe({ replay: true }))
153
- console.log(renderer.render(entry));
165
+ this.term(renderer.render(entry));
154
166
  })().catch(() => { });
155
167
  }
156
168
  /** Narrate a run-lifecycle milestone into this run's log (renders as a `[run]` line). */
@@ -206,7 +218,7 @@ export class SpinHost {
206
218
  };
207
219
  }
208
220
  launch(instance, parentPath) {
209
- // A consumer built with another action's `.ref` (e.g. `chromePage(driver.ref)`)
221
+ // A consumer built with another action's `.ref` (e.g. `chromePage("Explorer", driver.ref)`)
210
222
  // must be launched *after* that producer — otherwise it awaits a handle that
211
223
  // never arrives and the spin hangs silently. Catch it and say how to fix it.
212
224
  for (const arg of instance.args) {
@@ -228,6 +240,16 @@ export class SpinHost {
228
240
  this.nodInto(child, path);
229
241
  return child;
230
242
  };
243
+ // A boolean check bound to this action's path: it logs a `⊨` milestone, and a
244
+ // mismatch records a verdict failure the same way an action's own failure does
245
+ // — skipped during teardown (cascade, not a fault). Uses `this.logs` (not
246
+ // `scoped`) the same way `record` below does: the path is already embedded in
247
+ // the message via `name`, so routing through `scoped` would double-prefix the
248
+ // `source` field for a nodded child.
249
+ const assert = createAssert(path, this.logs, (error) => {
250
+ if (!this.controller.signal.aborted)
251
+ this.recordFailure(path, error);
252
+ });
231
253
  this.narrate(`launch ${path}`);
232
254
  // The action logs into this run's shared store (entries carry their `source`;
233
255
  // grouping is applied at render time) and gets the spin's signal directly; the
@@ -247,8 +269,14 @@ export class SpinHost {
247
269
  if (!this.controller.signal.aborted)
248
270
  this.recordFailure(path, error);
249
271
  };
250
- const promise = instance
251
- .start({ logs: scoped, signal: this.controller.signal, nod, output: this.output })
272
+ const promise = actionScope
273
+ .run(path, () => instance.start({
274
+ logs: scoped,
275
+ signal: this.controller.signal,
276
+ nod,
277
+ output: this.output,
278
+ assert,
279
+ }))
252
280
  .then((outcome) => {
253
281
  if (!outcome.ok)
254
282
  record(outcome.error);
@@ -282,6 +310,7 @@ export class SpinHost {
282
310
  process.off("SIGINT", this.onSigint);
283
311
  process.off("uncaughtException", this.onUncaught);
284
312
  process.off("unhandledRejection", this.onUnhandled);
313
+ this.restoreConsole();
285
314
  this.narrate("finished");
286
315
  // Close the log now (after the last entry) so `streamLog` drains and finishes
287
316
  // writing the complete run.jsonl, then wait for it to fully close its files —
@@ -291,7 +320,7 @@ export class SpinHost {
291
320
  await this.streaming;
292
321
  const file = await this.output.runLog.write();
293
322
  // Direct to the terminal — the file's already written, so this line isn't in it.
294
- console.log(LogRenderer.marker("run", `log → ${file}`));
323
+ this.term(LogRenderer.marker("run", `log → ${file}`));
295
324
  const result = { ok: this.failures.length === 0, failures: this.failures };
296
325
  // One process = one run = one verdict; reflect pass/fail in the exit code so
297
326
  // shells and CI see it without parsing the log. Only set it on failure, so a
@@ -16,7 +16,7 @@ import type { SpinBody } from "./types.ts";
16
16
  * if (ans.ok && ans.value === "yes") await nod(test).done;
17
17
  * nod(devServer); // fire-and-forget daemon
18
18
  * const chrome = nod(chromedriver({ … }));
19
- * nod(chromePage(chrome.ref)); // data deps via .ref
19
+ * nod(chromePage("Explorer", chrome.ref)); // data deps via .ref
20
20
  * });
21
21
  *
22
22
  * Auto-drains by default;
@@ -17,7 +17,7 @@ import { SpinHost } from "./SpinHost.js";
17
17
  * if (ans.ok && ans.value === "yes") await nod(test).done;
18
18
  * nod(devServer); // fire-and-forget daemon
19
19
  * const chrome = nod(chromedriver({ … }));
20
- * nod(chromePage(chrome.ref)); // data deps via .ref
20
+ * nod(chromePage("Explorer", chrome.ref)); // data deps via .ref
21
21
  * });
22
22
  *
23
23
  * Auto-drains by default;
@@ -41,6 +41,7 @@ export function spin(body) {
41
41
  // the body from there rather than reaching for a module singleton.
42
42
  artifactsFolder: ctx.artifactsFolder,
43
43
  snapshot: (name, value) => ctx.output.snapshots.snapshot(name, value),
44
+ assert: ctx.assert,
44
45
  });
45
46
  });
46
47
  const mainSpinAction = hostAction();
@@ -1,5 +1,6 @@
1
1
  export type { Nod } from "../action/types.ts";
2
2
  import type { Nod } from "../action/types.ts";
3
+ import type { Assert } from "../assert.ts";
3
4
  /** What a {@link spin} body receives. */
4
5
  export interface SpinContext {
5
6
  /** Launch an action into the spin; returns the instance for awaiting/chaining. */
@@ -25,6 +26,12 @@ export interface SpinContext {
25
26
  * run-scoped counterpart to the action `ctx.output.snapshots`.
26
27
  */
27
28
  readonly snapshot: (name: string, value: unknown) => Promise<string>;
29
+ /**
30
+ * Assert a boolean invariant from the spin body. Same semantics as the action
31
+ * `ctx.assert`; body assertions are attributed to the host action path
32
+ * (`omkit:Spin`).
33
+ */
34
+ readonly assert: Assert;
28
35
  }
29
36
  /** The body of a {@link spin}: any (async) function — it isn't named or wrapped by you. */
30
37
  export type SpinBody = (ctx: SpinContext) => void | Promise<void>;
@@ -1,6 +1,6 @@
1
1
  import type { LogEntry } from "./LogsCollector.ts";
2
2
  /** The special "title" log lines that get a distinguishing prefix. */
3
- export type MarkerKind = "event" | "action" | "run" | "snapshot";
3
+ export type MarkerKind = "event" | "action" | "run" | "snapshot" | "assert";
4
4
  /** A run set aside for collapsing: its source and every output line, in order. */
5
5
  export interface CollapsedRun {
6
6
  readonly source: string;
@@ -3,6 +3,7 @@ const PREFIX = {
3
3
  action: "▸",
4
4
  run: "●",
5
5
  snapshot: "📎",
6
+ assert: "⊨",
6
7
  };
7
8
  /**
8
9
  * Levels that render as their own top-level milestone line — marker-prefixed and
@@ -13,6 +14,7 @@ const MILESTONE = {
13
14
  run: "run",
14
15
  event: "event",
15
16
  snapshot: "snapshot",
17
+ assert: "assert",
16
18
  };
17
19
  /** Only runs *longer than* this collapse, so a trigger always hides a meaningful amount. */
18
20
  const COLLAPSE_THRESHOLD = 15;
@@ -37,10 +37,18 @@ export class RunLog {
37
37
  const pretty = createWriteStream(this.folder.resolve("run.log"));
38
38
  const renderer = new LogRenderer();
39
39
  pretty.write(this.header() + "\n");
40
+ // The dedicated assertions file is a filtered projection of the same stream:
41
+ // opened lazily on the first `assert` entry (so runs with none produce no file),
42
+ // one `⊨ …` line each. Assertions still ride the main run.jsonl / run.log too.
43
+ let assertions;
40
44
  try {
41
45
  for await (const entry of entries) {
42
46
  jsonl.write(JSON.stringify(entry) + "\n");
43
47
  pretty.write(renderer.render(entry) + "\n");
48
+ if (entry.level === "assert") {
49
+ assertions ??= createWriteStream(this.folder.resolve("assertions.log"));
50
+ assertions.write(LogRenderer.marker("assert", entry.message) + "\n");
51
+ }
44
52
  }
45
53
  }
46
54
  finally {
@@ -49,7 +57,11 @@ export class RunLog {
49
57
  // this stream landed *after* write() truncated run.log, the OS would zero-fill the
50
58
  // gap — a multi-MB run of NUL bytes. `run` awaits this promise before calling
51
59
  // write(), so this writer is guaranteed done first (and run.jsonl is complete).
52
- await Promise.all([closeStream(jsonl), closeStream(pretty)]);
60
+ await Promise.all([
61
+ closeStream(jsonl),
62
+ closeStream(pretty),
63
+ ...(assertions ? [closeStream(assertions)] : []),
64
+ ]);
53
65
  }
54
66
  }
55
67
  /**
@@ -112,7 +124,7 @@ export class RunLog {
112
124
  const now = new Date();
113
125
  return [
114
126
  `─── ${this.folder.name()} · ${ymd(now)} ${hms(now)} ───`,
115
- `● run ⚡ event ▸ action 📎 snapshot ⇥ output`,
127
+ `● run ⚡ event ▸ action 📎 snapshot ⇥ output ⊨ assert`,
116
128
  "",
117
129
  ].join("\n");
118
130
  }
@@ -0,0 +1,24 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import type { Logger } from "./LogsCollector.ts";
3
+ /**
4
+ * The path of the action whose async context is currently executing (e.g.
5
+ * "Build", "Dev › Server"). `SpinHost.launch` wraps each action's `start` in
6
+ * `actionScope.run(path, …)`, so any `console.*` call inside that action's
7
+ * (a)synchronous body reads its path here. No store → the call ran outside any
8
+ * action (the spin body, top-level, a third-party module init).
9
+ */
10
+ export declare const actionScope: AsyncLocalStorage<string>;
11
+ /**
12
+ * Patch `console.log/info/debug/warn/error` so each call appends into `logs`
13
+ * instead of writing to the terminal, attributing every line to the current
14
+ * action via {@link actionScope}. Returns a `restore()` that reinstalls the
15
+ * methods that were installed when this ran — so sequential runs nest cleanly
16
+ * (save-current / restore-saved).
17
+ *
18
+ * The patched methods do **not** echo to the terminal: their output reaches it
19
+ * through the run's log renderer (the caller keeps the original `console.log`
20
+ * for the renderer's own writes). Args are rendered with `util.format` (exact
21
+ * console semantics), split on newlines, and appended line-by-line; an empty
22
+ * render is skipped.
23
+ */
24
+ export declare function captureConsole(logs: Logger): () => void;
@@ -0,0 +1,53 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { format } from "node:util";
3
+ /**
4
+ * The path of the action whose async context is currently executing (e.g.
5
+ * "Build", "Dev › Server"). `SpinHost.launch` wraps each action's `start` in
6
+ * `actionScope.run(path, …)`, so any `console.*` call inside that action's
7
+ * (a)synchronous body reads its path here. No store → the call ran outside any
8
+ * action (the spin body, top-level, a third-party module init).
9
+ */
10
+ export const actionScope = new AsyncLocalStorage();
11
+ /** The console methods we fold into the log, each mapped to its log level. */
12
+ const LEVELS = {
13
+ log: "info",
14
+ info: "info",
15
+ debug: "debug",
16
+ warn: "warn",
17
+ error: "error",
18
+ };
19
+ /**
20
+ * Patch `console.log/info/debug/warn/error` so each call appends into `logs`
21
+ * instead of writing to the terminal, attributing every line to the current
22
+ * action via {@link actionScope}. Returns a `restore()` that reinstalls the
23
+ * methods that were installed when this ran — so sequential runs nest cleanly
24
+ * (save-current / restore-saved).
25
+ *
26
+ * The patched methods do **not** echo to the terminal: their output reaches it
27
+ * through the run's log renderer (the caller keeps the original `console.log`
28
+ * for the renderer's own writes). Args are rendered with `util.format` (exact
29
+ * console semantics), split on newlines, and appended line-by-line; an empty
30
+ * render is skipped.
31
+ */
32
+ export function captureConsole(logs) {
33
+ const c = console;
34
+ const saved = {};
35
+ const methods = Object.keys(LEVELS);
36
+ for (const method of methods) {
37
+ saved[method] = c[method];
38
+ const level = LEVELS[method];
39
+ c[method] = (...args) => {
40
+ const path = actionScope.getStore();
41
+ const source = path ? `${path} › console` : "console";
42
+ const text = format(...args);
43
+ for (const line of text.split("\n")) {
44
+ if (line)
45
+ logs.append({ source, level, message: line });
46
+ }
47
+ };
48
+ }
49
+ return () => {
50
+ for (const method of methods)
51
+ c[method] = saved[method];
52
+ };
53
+ }
@@ -111,7 +111,9 @@ export class FolderCache {
111
111
  }
112
112
  /** The on-disk filename for a set of targets — a hash of their absolute paths. */
113
113
  static entryPath(paths) {
114
- const key = createHash("sha1").update(FolderCache.resolvePaths(paths).sort().join("\0")).digest("hex");
114
+ const key = createHash("sha1")
115
+ .update(FolderCache.resolvePaths(paths).sort().join("\0"))
116
+ .digest("hex");
115
117
  return join(FolderCache.dir, `${key}.json`);
116
118
  }
117
119
  }
@@ -63,7 +63,11 @@ export const createProc = (logs, signal, snapshots) => (name, opts) => {
63
63
  // spread sits before `cwd` so this resolved value wins even when opts omits
64
64
  // it). This is the "where" that makes a launch line reproducible.
65
65
  const cwd = opts?.cwd ?? process.cwd();
66
- const snap = snapshots.captureJson(`proc-${name}-start`, { cmd: child.cmd, ...safeOpts, cwd });
66
+ const snap = snapshots.captureJson(`proc-${name}-start`, {
67
+ cmd: child.cmd,
68
+ ...safeOpts,
69
+ cwd,
70
+ });
67
71
  logs.append({
68
72
  source: name,
69
73
  level: "event",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omkit",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "description": "A tiny runtime for developer workflows: typed actions that pass runtime capabilities and emit typed events, under one run and one shared log. The operational/runtime layer to tskb's knowledge layer.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",