omkit 0.0.5 → 0.0.7

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,7 +22,10 @@ export function command(name, cmdOrFile, argsOrOptions, maybeOptions) {
22
22
  // against process.cwd() — exactly the "absolute from where node runs" rule.
23
23
  const cwd = path.resolve(options.cwd ?? ".");
24
24
  // proc sources the LogsCollector from async context and streams output into it.
25
- return proc(options.label ?? name, { cwd, ...(options.env ? { env: options.env } : {}) })(verbatim(cmdOrFile));
25
+ return proc(options.label ?? name, {
26
+ cwd,
27
+ ...(options.env ? { env: options.env } : {}),
28
+ })(verbatim(cmdOrFile));
26
29
  })(); // construct the instance here, so callers get one in a single call
27
30
  }
28
31
  /**
@@ -76,9 +79,13 @@ const spawnDirect = (logs, signal, name, file, args, options) => {
76
79
  // During teardown the abort-kill is why it exited — a clean shutdown, not a
77
80
  // fault; let it settle successfully so it isn't recorded as a failure.
78
81
  if (signal.aborted || code === 0)
79
- resolve();
80
- else
81
- reject(new Error(`${name} exited with ${code ?? sig}`));
82
+ return resolve();
83
+ // Non-zero exit: reject with an error carrying `exitCode`, so the framework
84
+ // surfaces it on the action's Outcome (`{ ok: false, exitCode }`).
85
+ const error = Object.assign(new Error(`${name} exited with ${code ?? sig}`), {
86
+ exitCode: code ?? undefined,
87
+ });
88
+ reject(error);
82
89
  });
83
90
  });
84
91
  };
@@ -40,8 +40,6 @@ export const tailLog = action("Tail Log").run(async ({ logs, signal }, target, o
40
40
  logs.append({ source, level, message: line });
41
41
  }
42
42
  };
43
- // Read [pos, size) and feed it. Catches the file being absent (rotated away)
44
- // and resets on a shrink (truncation/rotation), so reading self-heals.
45
43
  const readDelta = async () => {
46
44
  let size;
47
45
  try {
@@ -58,11 +56,24 @@ export const tailLog = action("Tail Log").run(async ({ logs, signal }, target, o
58
56
  return;
59
57
  const handle = await open(file, "r");
60
58
  try {
61
- const length = size - pos;
62
- const buffer = Buffer.alloc(length);
63
- await handle.read(buffer, 0, length, pos);
64
- pos = size;
65
- feed(buffer.toString(encoding));
59
+ const CHUNK = 64 * 1024;
60
+ const buffer = Buffer.allocUnsafe(CHUNK);
61
+ while (pos < size) {
62
+ const length = Math.min(CHUNK, size - pos);
63
+ const { bytesRead } = await handle.read(buffer, 0, length, pos);
64
+ if (bytesRead === 0)
65
+ break;
66
+ pos += bytesRead;
67
+ // Strip NUL bytes before decoding. Some log rotators on Windows
68
+ // temporarily expose sparse/preallocated regions filled with 0x00.
69
+ let out = 0;
70
+ for (let i = 0; i < bytesRead; i++) {
71
+ if (buffer[i] !== 0)
72
+ buffer[out++] = buffer[i];
73
+ }
74
+ if (out > 0)
75
+ feed(buffer.subarray(0, out).toString(encoding));
76
+ }
66
77
  }
67
78
  finally {
68
79
  await handle.close();
@@ -5,6 +5,21 @@ import { type Proc } from "./process.ts";
5
5
  export type NoEvents = Record<never, never>;
6
6
  /** A value an action's `exec` may return: a result, or a promise of one. */
7
7
  export type Awaitable<T> = T | Promise<T>;
8
+ /**
9
+ * How an action settles. An action **never throws into the spin body** — its
10
+ * failure is data, not control flow: it completes with `{ ok: false, error }`
11
+ * instead of rejecting. On success it carries the exec's `value`. `exitCode` is
12
+ * present when the failure came from a process (see {@link import("../actions/command.ts").command}).
13
+ * Await it via `instance.done` or `once("done")`; both resolve, never reject.
14
+ */
15
+ export type Outcome<T> = {
16
+ readonly ok: true;
17
+ readonly value: T;
18
+ } | {
19
+ readonly ok: false;
20
+ readonly error: unknown;
21
+ readonly exitCode?: number;
22
+ };
8
23
  /**
9
24
  * Lifecycle events the framework emits for every action — on top of whatever it
10
25
  * declares via `.emits<…>()`. They make key moments first-class signals (no
@@ -13,9 +28,9 @@ export type Awaitable<T> = T | Promise<T>;
13
28
  export interface SystemEvents<Result> {
14
29
  /** The action published its imperative handle via `ctx.attach` (fires once). */
15
30
  attached: void;
16
- /** The run resolved; payload is its result. */
17
- done: Result;
18
- /** The run rejected; payload is the thrown error. */
31
+ /** The action settled; payload is its {@link Outcome} (success or failure). Fires once, always. */
32
+ done: Outcome<Result>;
33
+ /** The action failed; payload is the thrown error. Fires only on failure (alongside `done`). */
19
34
  error: unknown;
20
35
  }
21
36
  /** The full event map a constructed instance exposes: the declared events plus the system ones. */
@@ -39,18 +54,38 @@ export interface ActionContext<Events extends object, Handle = void> extends Sys
39
54
  * one (e.g. CDP) follows the same pattern over `logs`/`signal`.
40
55
  */
41
56
  readonly proc: Proc;
57
+ /**
58
+ * Absolute path to this run's output folder (`logs/<name>/<date>/<time>/`) —
59
+ * created and shared across the run. Write action artifacts here so they land
60
+ * beside `run.log`; the same folder is on the `spin` context.
61
+ */
62
+ readonly artifactsFolder: string;
42
63
  }
43
64
  /** A constructed-but-not-yet-run action: what calling an `Action` produces. */
44
65
  export interface ActionInstance<Result = unknown, Events extends object = NoEvents, Handle = void> {
45
66
  readonly name: string;
46
67
  readonly args: readonly unknown[];
47
- /** Runs the action with the captured args and the injected system services. */
48
- readonly start: (system: SystemGlobal) => Promise<Result>;
49
68
  /**
50
- * Resolves with the handle the action attached via `ctx.attach` (set once). If
51
- * the action settles *without* attaching, this never hangs: it resolves
52
- * (`undefined` for the default `void` handle) on success, or rejects with the
53
- * action's error on failure.
69
+ * Runs the action with the captured args and the injected system services.
70
+ * **Resolves with the {@link Outcome}, never rejects** — a failing action is
71
+ * reported as `{ ok: false, error }`, not a thrown promise, so it can't tear a
72
+ * caller down. The run reads this to build its verdict.
73
+ */
74
+ readonly start: (system: SystemGlobal) => Promise<Outcome<Result>>;
75
+ /**
76
+ * The action's {@link Outcome}, as a memoized promise that **never rejects** —
77
+ * the ergonomic way to await completion in a spin body:
78
+ * `const r = await nod(x).done; if (!r.ok) …`. Same value as `once("done")`.
79
+ */
80
+ readonly done: Promise<Outcome<Result>>;
81
+ /**
82
+ * Resolves with the handle the action attached via `ctx.attach` (set once).
83
+ * Used to thread a handle into another action (`chromePage(driver.ref)`). If
84
+ * the action settles *without* attaching, this resolves (`undefined` for the
85
+ * default `void` handle) on success and **rejects on failure** — so, unlike
86
+ * `done`, awaiting `ref` in a body can throw. It's meant to be consumed as a
87
+ * constructor arg *inside* another action, where that rejection becomes that
88
+ * action's failure Outcome. For control flow, await `done`.
54
89
  */
55
90
  readonly ref: Promise<Handle>;
56
91
  /** Subscribe to a declared or system event (`done`/`error`) with a handler. */
@@ -82,7 +117,7 @@ export interface ActionInstance<Result = unknown, Events extends object = NoEven
82
117
  }
83
118
  /**
84
119
  * Any constructed instance, whatever its result, events, or handle. The launch
85
- * surface ({@link ActionInstance}-takers like `run` and `Run.run`) accepts this
120
+ * surface ({@link ActionInstance}-takers like `run` and `Spin.run`) accepts this
86
121
  * so handle-bearing actions — those declaring `.ref<H>()` — can be launched: the
87
122
  * bare `ActionInstance` pins the handle to `void`, so an instance whose `ref` is
88
123
  * a `Promise<Page>` (not `Promise<void>`) would otherwise be rejected. `unknown`
@@ -1,6 +1,7 @@
1
1
  import { events } from "./events.js";
2
2
  import { createProc } from "./process.js";
3
3
  import { fingerprint, read, write, resolvePaths } from "./folder-cache.js";
4
+ import { artifactsFolder } from "./output.js";
4
5
  /** A promise paired with its settlers, for the set-once handle. */
5
6
  const deferred = () => {
6
7
  let resolve;
@@ -11,6 +12,11 @@ const deferred = () => {
11
12
  });
12
13
  return { promise, resolve, reject };
13
14
  };
15
+ /** Duck-type an exit code off a thrown error (zx's ProcessOutput, command's exit error). */
16
+ const exitCodeOf = (error) => {
17
+ const code = error?.exitCode;
18
+ return typeof code === "number" ? code : undefined;
19
+ };
14
20
  /**
15
21
  * A promise for the next emit of `key`. It races the event against the
16
22
  * instance's terminal events: if the action errors first it rejects with that
@@ -74,35 +80,58 @@ const build = (name, exec) => {
74
80
  // handled so a settle-time rejection on such an instance doesn't surface as an
75
81
  // unhandled rejection. Real awaiters of `ref` still receive the rejection.
76
82
  void handle.promise.catch(() => { });
83
+ // The action's Outcome as a set-once promise. `start` resolves it (success or
84
+ // failure); `instance.done` / `once("done")` read it. It never rejects.
85
+ const settled = deferred();
77
86
  const instance = {
78
87
  name,
79
88
  args,
80
89
  ref: handle.promise,
90
+ done: settled.promise,
81
91
  start: (system) =>
82
92
  // `Promise.resolve().then` normalizes a sync result and routes a sync
83
93
  // throw to the `error` path, so exec may be sync or async.
84
94
  Promise.resolve()
85
- .then(() => exec({ ...system, emit, attach, proc: createProc(system.logs, system.signal) }, ...args))
95
+ .then(() => exec({
96
+ ...system,
97
+ emit,
98
+ attach,
99
+ proc: createProc(system.logs, system.signal),
100
+ artifactsFolder: artifactsFolder(),
101
+ }, ...args))
86
102
  .then((result) => {
87
- systemEmit("done", result);
103
+ const outcome = { ok: true, value: result };
104
+ settled.resolve(outcome);
105
+ systemEmit("done", outcome);
88
106
  // Settle `ref` if the action finished without attaching, so awaiters
89
107
  // resolve (with `undefined` for the default void handle) rather than
90
108
  // hang. Idempotent — a no-op when a handle was already attached.
91
109
  handle.resolve(undefined);
92
- return result;
110
+ return outcome;
93
111
  }, (error) => {
112
+ const exitCode = exitCodeOf(error);
113
+ const outcome = exitCode === undefined ? { ok: false, error } : { ok: false, error, exitCode };
114
+ // `error` first, so a pending `once(<declaredEvent>)` rejects on it
115
+ // (settled before its event) rather than resolving on `done`.
94
116
  systemEmit("error", error);
117
+ settled.resolve(outcome);
118
+ systemEmit("done", outcome);
95
119
  // The action failed, so its handle will never arrive — reject `ref`
96
120
  // with the same error instead of leaving awaiters hung. No-op if a
97
121
  // handle was already attached before the failure.
98
122
  handle.reject(error);
99
- throw error;
123
+ // Resolve (never rethrow): failure is the Outcome, not a rejection.
124
+ return outcome;
100
125
  }),
101
126
  on(key, handler) {
102
127
  emitter.listen(key, handler);
103
128
  },
104
129
  once(key) {
105
- return awaitEvent(emitter, name, key);
130
+ // `done` always fires with the Outcome and never "fails" the await, so it
131
+ // reads straight from the settled promise; other keys race via awaitEvent.
132
+ return (key === "done"
133
+ ? settled.promise
134
+ : awaitEvent(emitter, name, key));
106
135
  },
107
136
  // Wrap this instance in a fresh one (reusing the engine for its own
108
137
  // emitter/ref/on/once) that fingerprints `paths` and either skips or
@@ -120,9 +149,13 @@ const build = (name, exec) => {
120
149
  ctx.logs.append({ source: name, level: "info", message: "cached, skipping" });
121
150
  return undefined;
122
151
  }
123
- const result = await instance.start({ logs: ctx.logs, signal: ctx.signal });
124
- await write(targets, fp); // only on success — a throw above skips this
125
- return result;
152
+ const outcome = await instance.start({ logs: ctx.logs, signal: ctx.signal });
153
+ // Re-throw the inner failure so the wrapper fails too (the framework
154
+ // re-wraps it into this wrapper's own Outcome); only record on success.
155
+ if (!outcome.ok)
156
+ throw outcome.error;
157
+ await write(targets, fp);
158
+ return outcome.value;
126
159
  })();
127
160
  },
128
161
  };
@@ -1,4 +1,13 @@
1
1
  import type { LogsCollector } from "./log-collector/LogsCollector.ts";
2
+ /**
3
+ * Absolute path to this run's output folder — `logs/<name>/<date>/<time>/`, where
4
+ * `run.log`, `run.jsonl`, and snapshots live. Exposed so actions can drop their
5
+ * own **artifacts** into the same per-run folder (one folder per run; one run per
6
+ * process). The folder is created on first access — synchronously, so a caller
7
+ * can write into it in the same tick — and the path is absolute so it's safe to
8
+ * hand to a child process with a different cwd.
9
+ */
10
+ export declare const artifactsFolder: () => string;
2
11
  /**
3
12
  * Drains the collected log to two files in the run's output folder (the common
4
13
  * `logs/<name>/<date>/<time>/` convention, local time):
@@ -1,4 +1,4 @@
1
- import { createWriteStream } from "node:fs";
1
+ import { createWriteStream, mkdirSync } from "node:fs";
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { log } from "./log-collector/global.js";
@@ -24,6 +24,23 @@ const runDir = () => {
24
24
  };
25
25
  let made;
26
26
  const ensureDir = () => (made ??= mkdir(runDir(), { recursive: true }).then(() => { }));
27
+ let ensuredSync = false;
28
+ /**
29
+ * Absolute path to this run's output folder — `logs/<name>/<date>/<time>/`, where
30
+ * `run.log`, `run.jsonl`, and snapshots live. Exposed so actions can drop their
31
+ * own **artifacts** into the same per-run folder (one folder per run; one run per
32
+ * process). The folder is created on first access — synchronously, so a caller
33
+ * can write into it in the same tick — and the path is absolute so it's safe to
34
+ * hand to a child process with a different cwd.
35
+ */
36
+ export const artifactsFolder = () => {
37
+ const abs = path.resolve(runDir());
38
+ if (!ensuredSync) {
39
+ mkdirSync(abs, { recursive: true });
40
+ ensuredSync = true;
41
+ }
42
+ return abs;
43
+ };
27
44
  /** A compact header block for the top of run.log — legend + run identity. */
28
45
  const logHeader = () => {
29
46
  const now = new Date();
@@ -1,95 +1,88 @@
1
1
  import type { AnyActionInstance } from "./action.ts";
2
2
  import { log as logs } from "./log-collector/global.ts";
3
3
  /**
4
- * A single action's failure, captured for the run's verdict. Cascade failures —
5
- * actions that reject only because teardown killed them — are deliberately
6
- * excluded (see `launch`'s catch), so this records genuine faults, not fallout.
4
+ * A single action's failure, captured for the spin's verdict. Cascade failures —
5
+ * actions that settled `{ ok: false }` only because teardown aborted them — are
6
+ * deliberately excluded (see `launch`'s outcome handling), so this records
7
+ * genuine faults, not fallout.
7
8
  */
8
9
  export interface ActionFailure {
9
10
  /** The failing action's name. */
10
11
  readonly action: string;
11
- /** Whatever it threw or rejected with. */
12
+ /** Whatever it failed with (the Outcome's `error`). */
12
13
  readonly error: unknown;
13
14
  }
14
15
  /**
15
- * The terminal verdict of a run: did everything that mattered succeed, and if
16
- * not, what failed. {@link Run.done} resolves (never rejects) with this, so a
16
+ * The terminal verdict of a spin: did everything that mattered succeed, and if
17
+ * not, what failed. {@link Spin.done} resolves (never rejects) with this, so a
17
18
  * single `await` yields the outcome instead of forcing a try/catch on awaiters.
19
+ * A failing action is *recorded* here (so `ok` is false and the exit code is 1)
20
+ * but does **not** tear the spin down — see {@link Spin}.
18
21
  */
19
- export interface RunResult {
22
+ export interface SpinResult {
20
23
  /** True when no genuine failure was recorded. */
21
24
  readonly ok: boolean;
22
25
  /** The genuine failures, in the order they surfaced. Empty when `ok`. */
23
26
  readonly failures: readonly ActionFailure[];
24
27
  }
25
28
  /**
26
- * The run's lifecycle. It only ever moves forward:
29
+ * The spin's lifecycle. It only ever moves forward:
27
30
  *
28
31
  * open ──▶ closing ──▶ closed
29
32
  *
30
33
  * - **open** — launching and running actions; `.run(…)` is accepted.
31
- * - **closing** — teardown has begun (cancel, first failure, Ctrl-C, or natural
32
- * completion). The signal is aborted, in-flight actions are unwinding, and new
33
- * `.run(…)` is refused.
34
+ * - **closing** — teardown has begun (cancel, Ctrl-C, natural completion, or an
35
+ * uncaught fatal). The signal is aborted, in-flight actions are unwinding, and
36
+ * new `.run(…)` is refused. Note: a failing *action* does not begin teardown.
34
37
  * - **closed** — everything has settled, the log is flushed, the verdict is final.
35
38
  */
36
- export type RunState = "open" | "closing" | "closed";
37
- /** Tunes a run's failure policy. */
38
- export interface RunOptions {
39
- /**
40
- * Abort the whole run the moment the first genuine failure surfaces
41
- * (default `true`). Set `false` to let every action finish and collect all
42
- * failures into the verdict — useful when you want the complete picture of
43
- * what broke rather than just the first cause.
44
- */
45
- readonly failFast?: boolean;
46
- }
47
- /** A live run: a set of actions sharing one abort controller and the global log. */
48
- export interface Run {
49
- /** The shared global log (output + events for everything in this run). */
39
+ export type SpinState = "open" | "closing" | "closed";
40
+ /** A live spin: a set of actions sharing one abort controller and the global log. */
41
+ export interface Spin {
42
+ /** The shared global log (output + events for everything in this spin). */
50
43
  readonly logs: typeof logs;
51
- /** The current lifecycle state — see {@link RunState}. */
52
- readonly state: RunState;
53
- /** Settles once the run reaches `closed`, carrying the terminal {@link RunResult}. */
54
- readonly done: Promise<RunResult>;
55
- /** The run's abort signal — for consumers that want to observe teardown. */
44
+ /** The current lifecycle state — see {@link SpinState}. */
45
+ readonly state: SpinState;
46
+ /** Settles once the spin reaches `closed`, carrying the terminal {@link SpinResult}. */
47
+ readonly done: Promise<SpinResult>;
48
+ /** The spin's abort signal — for consumers that want to observe teardown. */
56
49
  readonly signal: AbortSignal;
57
50
  /**
58
- * Launch more actions into this same run — same controller, same log. Chainable.
51
+ * Launch more actions into this same spin — same controller, same log. Chainable.
59
52
  *
60
- * Throws if the run is no longer `open`: launching into a closing/closed run
53
+ * Throws if the spin is no longer `open`: launching into a closing/closed spin
61
54
  * would bind the actions to an already-aborted signal, so they'd start and
62
55
  * immediately unwind, silently doing nothing. Launch before teardown, or from
63
56
  * a lifecycle event of an action that is still running (those fire while the
64
- * run is still `open`).
57
+ * spin is still `open`).
65
58
  */
66
- run(...actions: AnyActionInstance[]): Run;
59
+ run(...actions: AnyActionInstance[]): Spin;
67
60
  /** Begin teardown: abort the signal (killing procs, unblocking waits, stopping daemons). */
68
61
  cancel(): void;
69
62
  /**
70
63
  * Start streaming the global log live to a writer (console by default) in the
71
- * background; it stops on its own when the run settles. Returns the run, so it
64
+ * background; it stops on its own when the spin settles. Returns the spin, so it
72
65
  * chains. Await `.done` for the verdict.
73
66
  */
74
- drain(write?: (message: string) => void): Run;
67
+ drain(write?: (message: string) => void): Spin;
75
68
  }
76
69
  /**
77
- * Starts the run: launches the given actions in parallel under one abort
78
- * controller, against the process-wide global log. Returns a {@link Run} — add
70
+ * Starts the spin: launches the given actions in parallel under one abort
71
+ * controller, against the process-wide global log. Returns a {@link Spin} — add
79
72
  * more actions on demand with `.run(…)`, observe `.done` for the verdict, stop
80
- * with `.cancel()`. An optional leading {@link RunOptions} tunes failure policy.
73
+ * with `.cancel()`.
81
74
  *
82
- * **Callable once per process.** A run *is* the world being modelled, and there's
83
- * one global log per world; a second `run(…)` would silently share that log, so
84
- * it throws instead. Attach further actions — including event-driven ones — to
85
- * the returned {@link Run} via its `.run(…)` method, not by calling this again.
75
+ * **Callable once per process.** A spin *is* the world being modelled, and there's
76
+ * one global log per world; a second call would silently share that log, so it
77
+ * throws instead. Attach further actions — including event-driven ones — to the
78
+ * returned {@link Spin} via its `.run(…)` method, not by calling this again.
86
79
  *
87
80
  * Each action runs against its own child logger (which headers and indents its
88
- * lines into the global log) and the run's shared abort signal — both injected
81
+ * lines into the global log) and the spin's shared abort signal — both injected
89
82
  * into its context, where the framework also binds a `proc` to them so child
90
- * processes stream into the right log and die on teardown. On Ctrl-C, `.cancel()`,
91
- * the first failure (under fail-fast), or natural completion, the run transitions
92
- * through {@link RunState} to `closed`.
83
+ * processes stream into the right log and die on teardown. A **failing action
84
+ * never tears the spin down** — its `{ ok: false }` outcome is recorded in the
85
+ * verdict (and sets exit code 1) and the spin keeps running. Teardown to `closed`
86
+ * happens only on Ctrl-C, `.cancel()`, natural completion, or an uncaught fatal.
93
87
  */
94
- export declare function run(...actions: AnyActionInstance[]): Run;
95
- export declare function run(options: RunOptions, ...actions: AnyActionInstance[]): Run;
88
+ export declare function run(...actions: AnyActionInstance[]): Spin;
package/dist/core/run.js CHANGED
@@ -3,7 +3,7 @@ import { log as logs } from "./log-collector/global.js";
3
3
  import { createRenderer } from "./log-collector/render.js";
4
4
  import { streamLog, writeLog } from "./output.js";
5
5
  import { marker } from "./markers.js";
6
- /** One run per process — a single world over the one global log. See {@link run}. */
6
+ /** One spin per process — a single world over the one global log. See {@link run}. */
7
7
  let started = false;
8
8
  /** Narrate a run-lifecycle milestone into the global log (renders as a `[run]` line). */
9
9
  const narrate = (message) => logs.append({ source: "run", level: "run", message });
@@ -32,19 +32,29 @@ function assertInstance(value) {
32
32
  : "";
33
33
  throw new Error(`run() expects action instances; received ${typeof value}${hint}`);
34
34
  }
35
- export function run(...args) {
35
+ /**
36
+ * Starts the spin: launches the given actions in parallel under one abort
37
+ * controller, against the process-wide global log. Returns a {@link Spin} — add
38
+ * more actions on demand with `.run(…)`, observe `.done` for the verdict, stop
39
+ * with `.cancel()`.
40
+ *
41
+ * **Callable once per process.** A spin *is* the world being modelled, and there's
42
+ * one global log per world; a second call would silently share that log, so it
43
+ * throws instead. Attach further actions — including event-driven ones — to the
44
+ * returned {@link Spin} via its `.run(…)` method, not by calling this again.
45
+ *
46
+ * Each action runs against its own child logger (which headers and indents its
47
+ * lines into the global log) and the spin's shared abort signal — both injected
48
+ * into its context, where the framework also binds a `proc` to them so child
49
+ * processes stream into the right log and die on teardown. A **failing action
50
+ * never tears the spin down** — its `{ ok: false }` outcome is recorded in the
51
+ * verdict (and sets exit code 1) and the spin keeps running. Teardown to `closed`
52
+ * happens only on Ctrl-C, `.cancel()`, natural completion, or an uncaught fatal.
53
+ */
54
+ export function run(...actions) {
36
55
  if (started) {
37
56
  throw new Error("run() may only be called once per process — attach more actions to the existing run with its .run(…) method.");
38
57
  }
39
- // Peel an optional leading options bag off the variadic actions, keeping the
40
- // bare `run(...actions)` call form working unchanged. Options are a plain
41
- // object (no `.start`); a *function* first arg is never options — it's an
42
- // uncalled action, which `assertInstance` below reports clearly.
43
- const first = args[0];
44
- const hasOptions = first !== undefined && !isActionInstance(first) && typeof first !== "function";
45
- const options = hasOptions ? first : {};
46
- const actions = (hasOptions ? args.slice(1) : args);
47
- const failFast = options.failFast ?? true;
48
58
  // Validate eagerly, before the one-run latch flips, so a bad argument throws
49
59
  // cleanly and leaves the process's single run slot still usable.
50
60
  for (const instance of actions)
@@ -77,13 +87,15 @@ export function run(...args) {
77
87
  narrate(`tearing down · ${reason}`);
78
88
  controller.abort();
79
89
  };
80
- /** Record a genuine fault and, under fail-fast, kick off teardown. */
90
+ /**
91
+ * Record a genuine fault in the verdict. It does **not** tear the spin down — a
92
+ * failing action is data, and the spin keeps running (only Ctrl-C / cancel /
93
+ * completion / an uncaught fatal close it).
94
+ */
81
95
  const recordFailure = (action, error) => {
82
96
  failures.push({ action, error });
83
- if (failFast)
84
- beginClosing(`failed: ${action}`);
85
97
  };
86
- // Ctrl-C tears the run down gracefully through the lifecycle, like cancel().
98
+ // Ctrl-C tears the spin down gracefully through the lifecycle, like cancel().
87
99
  const onSigint = () => beginClosing("interrupted (SIGINT)");
88
100
  process.once("SIGINT", onSigint);
89
101
  // A throw or rejection that escaped every action's own handling. Without a
@@ -100,10 +112,10 @@ export function run(...args) {
100
112
  message: error instanceof Error ? (error.stack ?? error.message) : String(error),
101
113
  });
102
114
  // Genuine fault vs. teardown fallout: only one that surfaces *before* the
103
- // signal aborts is the run's own (mirrors `launch`'s cascade rule); a throw
115
+ // signal aborts is the spin's own (mirrors `launch`'s cascade rule); a throw
104
116
  // during unwind is logged but doesn't pollute the verdict. Either way tear
105
- // down — an uncaught fault leaves the process state undefined, so we always
106
- // finish and flush, even under `failFast: false`.
117
+ // down — unlike an action's own failure, an *uncaught* fatal leaves the
118
+ // process state undefined, so we always finish and flush.
107
119
  if (!controller.signal.aborted)
108
120
  failures.push({ action: kind, error });
109
121
  beginClosing(kind);
@@ -119,9 +131,11 @@ export function run(...args) {
119
131
  const launch = (instance) => {
120
132
  narrate(`launch ${instance.name}`);
121
133
  // The action logs into the one shared store (entries carry their `source`;
122
- // grouping is applied at render time) and gets the run's signal directly; the
134
+ // grouping is applied at render time) and gets the spin's signal directly; the
123
135
  // framework injects a `proc` bound to both, so there's no ambient context.
124
- const promise = instance.start({ logs, signal: controller.signal }).catch((error) => {
136
+ // `start()` resolves with the action's Outcome — it never rejects for the
137
+ // action's own failure — so a failure is handled here as data, not a throw.
138
+ const record = (error) => {
125
139
  // Always record the fault in the action's own log, under its name.
126
140
  logs.append({
127
141
  source: instance.name,
@@ -129,13 +143,20 @@ export function run(...args) {
129
143
  message: error instanceof Error ? (error.stack ?? error.message) : String(error),
130
144
  });
131
145
  // Genuine failure vs. teardown fallout: if the signal was already aborted
132
- // when this surfaced, the rejection is almost certainly the abort killing
133
- // the action (a cascade), not its own fault — so it must not pollute the
134
- // verdict. The *first* real error is caught here before `recordFailure`
135
- // triggers the abort, so `signal.aborted` is still false for it.
146
+ // when this surfaced, the failure is almost certainly the abort killing the
147
+ // action (a cascade), not its own fault — so it must not pollute the verdict.
136
148
  if (!controller.signal.aborted)
137
149
  recordFailure(instance.name, error);
138
- });
150
+ };
151
+ const promise = instance
152
+ .start({ logs, signal: controller.signal })
153
+ .then((outcome) => {
154
+ if (!outcome.ok)
155
+ record(outcome.error);
156
+ })
157
+ // Defensive: `start()` shouldn't reject, but if the framework itself throws,
158
+ // treat it as a fault rather than losing it to an unhandled rejection.
159
+ .catch(record);
139
160
  inFlight.add(promise);
140
161
  void promise.finally(() => inFlight.delete(promise));
141
162
  };
@@ -198,7 +219,7 @@ export function run(...args) {
198
219
  signal: controller.signal,
199
220
  run(...more) {
200
221
  if (state !== "open") {
201
- throw new Error(`run.run(…) rejected: the run is "${state}", not "open". Launch actions before ` +
222
+ throw new Error(`spin.run(…) rejected: the spin is "${state}", not "open". Launch actions before ` +
202
223
  `teardown, or from a lifecycle event of an action that is still running.`);
203
224
  }
204
225
  for (const instance of more)
@@ -1,13 +1,16 @@
1
1
  import { type AnyActionInstance } from "./action.ts";
2
- import { type Run, type RunOptions } from "./run.ts";
2
+ import { type Spin } from "./run.ts";
3
3
  /**
4
- * Launch an action into the spin's run and get the same instance back, so you
5
- * can `await` its `.done` / `.ref` or subscribe with `.on` / `.once`. A nod is
6
- * how the body waves an action in.
4
+ * Launch an action into the spin and get the same instance back, so you can
5
+ * `await` its `.done` (its {@link import("./action.ts").Outcome} — resolves with
6
+ * `{ ok, value } | { ok, error }`, **never throws**), its `.ref` (the attached
7
+ * handle; rejects on failure, so it's for data deps consumed inside other
8
+ * actions), or subscribe with `.on` / `.once`. A nod is how the body waves an
9
+ * action in.
7
10
  */
8
11
  export type Nod = <I extends AnyActionInstance>(instance: I) => I;
9
- /** Tunes a {@link spin} — the run's options plus how it drains. */
10
- export interface SpinOptions extends RunOptions {
12
+ /** Tunes a {@link spin} — currently just how it drains. */
13
+ export interface SpinOptions {
11
14
  /**
12
15
  * Stream the log to the console automatically from the start. Default `true`.
13
16
  * Set `false` when you need to drain later via {@link SpinContext.drain} —
@@ -17,42 +20,53 @@ export interface SpinOptions extends RunOptions {
17
20
  }
18
21
  /** What a {@link spin} body receives. */
19
22
  export interface SpinContext {
20
- /** Launch an action into the run; returns the instance for awaiting/chaining. */
23
+ /** Launch an action into the spin; returns the instance for awaiting/chaining. */
21
24
  readonly nod: Nod;
22
25
  /**
23
- * Start streaming the run's log to the console now. Only needed when the spin
26
+ * Start streaming the spin's log to the console now. Only needed when the spin
24
27
  * was created with `drain: false`; idempotent, so it's safe to call regardless.
25
28
  */
26
- readonly drain: Run["drain"];
29
+ readonly drain: Spin["drain"];
27
30
  /**
28
- * Tear the run down (abort the signal, stopping daemons). Use it to end a run
31
+ * Tear the spin down (abort the signal, stopping daemons). Use it to end a spin
29
32
  * whose work is finished but which is held open by a daemon — e.g. once a
30
33
  * one-shot build completes while a watcher is still running.
31
34
  */
32
- readonly cancel: Run["cancel"];
33
- /** The run's abort signal — honor it to stop early. */
35
+ readonly cancel: Spin["cancel"];
36
+ /** The spin's abort signal — honor it to stop early. */
34
37
  readonly signal: AbortSignal;
38
+ /**
39
+ * Absolute path to this spin's output folder (`logs/<name>/<date>/<time>/`),
40
+ * created up front. Hand it to actions (or use `ctx.artifactsFolder` inside
41
+ * one) to write artifacts beside `run.log`.
42
+ */
43
+ readonly artifactsFolder: string;
35
44
  }
36
45
  /** The body of a {@link spin}: any (async) function — it isn't named or wrapped by you. */
37
46
  export type SpinBody = (ctx: SpinContext) => void | Promise<void>;
38
47
  /**
39
- * Runs a linear orchestration. You pass a plain (async) function; tswm hosts it
48
+ * Runs a linear orchestration. You pass a plain (async) function; omkit hosts it
40
49
  * in a single in-flight action and hands it a {@link Nod}. Because that host
41
- * stays in-flight while your body `await`s, the run never idles shut between
42
- * steps — so you write `await` / `if` control flow instead of nested `done`
43
- * callbacks. When the body returns, the daemons it nodded in keep the run alive
44
- * until teardown.
50
+ * stays in-flight while your body `await`s, the spin never idles shut between
51
+ * steps — so you write `await` / `if` control flow instead of nested callbacks.
52
+ * When the body returns, the daemons it nodded in keep the spin alive until
53
+ * teardown.
54
+ *
55
+ * A failing action **never throws into the body** — `await nod(x).done` yields an
56
+ * {@link import("./action.ts").Outcome} you inspect, and the spin keeps going
57
+ * (the failure is recorded in the verdict and sets exit code 1):
45
58
  *
46
- * spin({ failFast: false }, async ({ nod }) => {
47
- * if ((await nod(prompt({ … })).done) === "yes") await nod(test).done;
48
- * nod(devServer); // fire-and-forget daemon
59
+ * spin({ drain: false }, async ({ nod }) => {
60
+ * const ans = await nod(prompt({ … })).done; // Outcome — never throws
61
+ * if (ans.ok && ans.value === "yes") await nod(test).done;
62
+ * nod(devServer); // fire-and-forget daemon
49
63
  * const chrome = nod(chromedriver({ … }));
50
- * nod(chromePage(chrome.ref)); // data deps via .ref
64
+ * nod(chromePage(chrome.ref)); // data deps via .ref
51
65
  * });
52
66
  *
53
67
  * Auto-drains by default; pass `drain: false` and call `ctx.drain()` yourself to
54
68
  * stream the log only from a chosen point (e.g. after a `prompt`). Returns the
55
- * {@link Run}, so `.done` is available on it too.
69
+ * {@link Spin} — `await` its `.done` for the verdict.
56
70
  */
57
- export declare function spin(body: SpinBody): Run;
58
- export declare function spin(options: SpinOptions, body: SpinBody): Run;
71
+ export declare function spin(body: SpinBody): Spin;
72
+ export declare function spin(options: SpinOptions, body: SpinBody): Spin;
package/dist/core/spin.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { action } from "./action.js";
2
2
  import { run } from "./run.js";
3
+ import { artifactsFolder } from "./output.js";
3
4
  export function spin(a, b) {
4
5
  const [options, body] = typeof a === "function" ? [{}, a] : [a, b];
5
- const { drain: autoDrain = true, ...runOptions } = options;
6
+ const { drain: autoDrain = true } = options;
6
7
  // `run(...)` below assigns `host` synchronously, before the action's async body
7
- // ever runs — so the closures here capture the run safely.
8
+ // ever runs — so the closures here capture the spin safely.
8
9
  let host;
9
10
  // One-shot drain: auto-drain and a manual ctx.drain() can't double-subscribe.
10
11
  let drained = false;
@@ -20,9 +21,15 @@ export function spin(a, b) {
20
21
  host.run(instance);
21
22
  return instance;
22
23
  };
23
- await body({ nod, drain: startDrain, cancel: () => host.cancel(), signal });
24
+ await body({
25
+ nod,
26
+ drain: startDrain,
27
+ cancel: () => host.cancel(),
28
+ signal,
29
+ artifactsFolder: artifactsFolder(),
30
+ });
24
31
  });
25
- host = run(runOptions, hosted());
32
+ host = run(hosted());
26
33
  if (autoDrain)
27
34
  startDrain();
28
35
  return host;
package/dist/index.d.ts CHANGED
@@ -9,9 +9,9 @@
9
9
  * renderer, marker prefixing) are intentionally not exported.
10
10
  */
11
11
  export { action } from "./core/action.ts";
12
- export type { Action, ActionBuilder, ActionInstance, AnyActionInstance, ActionContext, SystemGlobal, SystemEvents, InstanceEvents, NoEvents, Awaitable, } from "./core/action.ts";
12
+ export type { Action, ActionBuilder, ActionInstance, AnyActionInstance, ActionContext, SystemGlobal, SystemEvents, InstanceEvents, NoEvents, Awaitable, Outcome, } from "./core/action.ts";
13
13
  export { run } from "./core/run.ts";
14
- export type { Run, RunResult, RunOptions, RunState, ActionFailure } from "./core/run.ts";
14
+ export type { Spin, SpinResult, SpinState, ActionFailure } from "./core/run.ts";
15
15
  export { spin } from "./core/spin.ts";
16
16
  export type { Nod, SpinOptions, SpinContext, SpinBody } from "./core/spin.ts";
17
17
  export { events } from "./core/events.ts";
@@ -23,4 +23,5 @@ export type { Logger, LogEntry, LogInput, SubscribeOptions, } from "./core/log-c
23
23
  export { log } from "./core/log-collector/global.ts";
24
24
  export { snapshot, captureSnapshot } from "./core/output.ts";
25
25
  export type { SnapshotRef } from "./core/output.ts";
26
+ export { artifactsFolder } from "./core/output.ts";
26
27
  export { fingerprint, cacheDir } from "./core/folder-cache.ts";
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
  // ── Actions ────────────────────────────────────────────────────────────────
12
12
  export { action } from "./core/action.js";
13
- // ── Runs ───────────────────────────────────────────────────────────────────
13
+ // ── Spin (the run handle) ────────────────────────────────────────────────────
14
14
  export { run } from "./core/run.js";
15
15
  // ── Spin (linear orchestration) ──────────────────────────────────────────────
16
16
  export { spin } from "./core/spin.js";
@@ -23,6 +23,10 @@ export { LogsCollector } from "./core/log-collector/LogsCollector.js";
23
23
  export { log } from "./core/log-collector/global.js";
24
24
  // ── Snapshots ──────────────────────────────────────────────────────────────
25
25
  export { snapshot, captureSnapshot } from "./core/output.js";
26
+ // ── Artifacts ────────────────────────────────────────────────────────────────
27
+ // This run's output folder (absolute) — also on the `spin`/action `ctx`. Import
28
+ // it directly for artifact-writing helpers that aren't inside an action.
29
+ export { artifactsFolder } from "./core/output.js";
26
30
  // ── Folder cache ─────────────────────────────────────────────────────────────
27
31
  // Backs the `.withCache(...inputs)` method on instances (fingerprints an
28
32
  // action's input files/folders, not its outputs); exported for scripts that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omkit",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
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",