omkit 0.0.6 → 0.0.8

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
  };
@@ -1,6 +1,7 @@
1
1
  import { watch as fsWatch } from "node:fs";
2
2
  import { open, stat } from "node:fs/promises";
3
3
  import { basename, dirname, resolve } from "node:path";
4
+ import { StringDecoder } from "node:string_decoder";
4
5
  import { action } from "../core/action.js";
5
6
  /**
6
7
  * Tails a (possibly not-yet-existing) text/log file and streams each appended
@@ -23,15 +24,13 @@ export const tailLog = action("Tail Log").run(async ({ logs, signal }, target, o
23
24
  const { source = name, level = "info", fromStart = false, encoding = "utf8", debounceMs = 50, } = opts;
24
25
  let pos = 0; // bytes consumed so far
25
26
  let pending = ""; // partial trailing line, buffered until its newline
27
+ // Decodes bytes to text across reads. It holds back an incomplete trailing
28
+ // multi-byte char (split across a chunk or read boundary) until its remaining
29
+ // bytes arrive, so a character straddling a boundary never decodes to `�`.
30
+ let decoder = new StringDecoder(encoding);
26
31
  // Append every complete line in `text`, keeping any remainder for next time.
27
32
  const feed = (text) => {
28
- // Strip NUL bytes first. Log rotators (log4js/streamroller) can pre-allocate
29
- // or leave a rotated file padded with \0, and a NUL run contains no newline —
30
- // so without this the whole padded region buffers and flushes as one huge
31
- // bogus line (the tell-tale "line of blanks/spaces"). NUL is never valid in
32
- // a text log, so dropping it is safe; a NUL-only stretch collapses to "" and
33
- // is skipped by the empty-line guard below.
34
- pending += text.replace(/\0/g, "");
33
+ pending += text;
35
34
  let newline;
36
35
  while ((newline = pending.indexOf("\n")) !== -1) {
37
36
  const line = pending.slice(0, newline).replace(/\r$/, "");
@@ -40,8 +39,6 @@ export const tailLog = action("Tail Log").run(async ({ logs, signal }, target, o
40
39
  logs.append({ source, level, message: line });
41
40
  }
42
41
  };
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
42
  const readDelta = async () => {
46
43
  let size;
47
44
  try {
@@ -51,18 +48,28 @@ export const tailLog = action("Tail Log").run(async ({ logs, signal }, target, o
51
48
  return; // missing/unreadable — wait for the next event
52
49
  }
53
50
  if (size < pos) {
51
+ // Truncation/rotation: restart from the top and drop any buffered partial
52
+ // line and half-decoded char — they belong to the old file's bytes.
54
53
  pos = 0;
55
54
  pending = "";
55
+ decoder = new StringDecoder(encoding);
56
56
  }
57
57
  if (size <= pos)
58
58
  return;
59
59
  const handle = await open(file, "r");
60
60
  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));
61
+ const CHUNK = 64 * 1024;
62
+ const buffer = Buffer.allocUnsafe(CHUNK);
63
+ while (pos < size) {
64
+ const length = Math.min(CHUNK, size - pos);
65
+ const { bytesRead } = await handle.read(buffer, 0, length, pos);
66
+ if (bytesRead === 0)
67
+ break;
68
+ pos += bytesRead;
69
+ // decoder.write returns only fully-decoded chars, buffering any partial
70
+ // trailing multi-byte char for the next chunk/read.
71
+ feed(decoder.write(buffer.subarray(0, bytesRead)));
72
+ }
66
73
  }
67
74
  finally {
68
75
  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. */
@@ -50,13 +65,27 @@ export interface ActionContext<Events extends object, Handle = void> extends Sys
50
65
  export interface ActionInstance<Result = unknown, Events extends object = NoEvents, Handle = void> {
51
66
  readonly name: string;
52
67
  readonly args: readonly unknown[];
53
- /** Runs the action with the captured args and the injected system services. */
54
- readonly start: (system: SystemGlobal) => Promise<Result>;
55
68
  /**
56
- * Resolves with the handle the action attached via `ctx.attach` (set once). If
57
- * the action settles *without* attaching, this never hangs: it resolves
58
- * (`undefined` for the default `void` handle) on success, or rejects with the
59
- * 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`.
60
89
  */
61
90
  readonly ref: Promise<Handle>;
62
91
  /** Subscribe to a declared or system event (`done`/`error`) with a handler. */
@@ -88,7 +117,7 @@ export interface ActionInstance<Result = unknown, Events extends object = NoEven
88
117
  }
89
118
  /**
90
119
  * Any constructed instance, whatever its result, events, or handle. The launch
91
- * surface ({@link ActionInstance}-takers like `run` and `Run.run`) accepts this
120
+ * surface ({@link ActionInstance}-takers like `run` and `Spin.run`) accepts this
92
121
  * so handle-bearing actions — those declaring `.ref<H>()` — can be launched: the
93
122
  * bare `ActionInstance` pins the handle to `void`, so an instance whose `ref` is
94
123
  * a `Promise<Page>` (not `Promise<void>`) would otherwise be rejected. `unknown`
@@ -12,6 +12,11 @@ const deferred = () => {
12
12
  });
13
13
  return { promise, resolve, reject };
14
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
+ };
15
20
  /**
16
21
  * A promise for the next emit of `key`. It races the event against the
17
22
  * instance's terminal events: if the action errors first it rejects with that
@@ -75,10 +80,14 @@ const build = (name, exec) => {
75
80
  // handled so a settle-time rejection on such an instance doesn't surface as an
76
81
  // unhandled rejection. Real awaiters of `ref` still receive the rejection.
77
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();
78
86
  const instance = {
79
87
  name,
80
88
  args,
81
89
  ref: handle.promise,
90
+ done: settled.promise,
82
91
  start: (system) =>
83
92
  // `Promise.resolve().then` normalizes a sync result and routes a sync
84
93
  // throw to the `error` path, so exec may be sync or async.
@@ -91,25 +100,38 @@ const build = (name, exec) => {
91
100
  artifactsFolder: artifactsFolder(),
92
101
  }, ...args))
93
102
  .then((result) => {
94
- systemEmit("done", result);
103
+ const outcome = { ok: true, value: result };
104
+ settled.resolve(outcome);
105
+ systemEmit("done", outcome);
95
106
  // Settle `ref` if the action finished without attaching, so awaiters
96
107
  // resolve (with `undefined` for the default void handle) rather than
97
108
  // hang. Idempotent — a no-op when a handle was already attached.
98
109
  handle.resolve(undefined);
99
- return result;
110
+ return outcome;
100
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`.
101
116
  systemEmit("error", error);
117
+ settled.resolve(outcome);
118
+ systemEmit("done", outcome);
102
119
  // The action failed, so its handle will never arrive — reject `ref`
103
120
  // with the same error instead of leaving awaiters hung. No-op if a
104
121
  // handle was already attached before the failure.
105
122
  handle.reject(error);
106
- throw error;
123
+ // Resolve (never rethrow): failure is the Outcome, not a rejection.
124
+ return outcome;
107
125
  }),
108
126
  on(key, handler) {
109
127
  emitter.listen(key, handler);
110
128
  },
111
129
  once(key) {
112
- 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));
113
135
  },
114
136
  // Wrap this instance in a fresh one (reusing the engine for its own
115
137
  // emitter/ref/on/once) that fingerprints `paths` and either skips or
@@ -127,9 +149,13 @@ const build = (name, exec) => {
127
149
  ctx.logs.append({ source: name, level: "info", message: "cached, skipping" });
128
150
  return undefined;
129
151
  }
130
- const result = await instance.start({ logs: ctx.logs, signal: ctx.signal });
131
- await write(targets, fp); // only on success — a throw above skips this
132
- 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;
133
159
  })();
134
160
  },
135
161
  };
@@ -113,10 +113,20 @@ export const streamLog = async (logs) => {
113
113
  }
114
114
  }
115
115
  finally {
116
- jsonl.end();
117
- pretty.end();
116
+ // End AND wait for both streams to fully close before resolving. {@link writeLog}
117
+ // truncates and rewrites these same files at finalize; if a buffered chunk from
118
+ // this stream landed *after* that truncation, the OS would zero-fill the gap —
119
+ // a multi-MB run of NUL bytes in run.log. `run` awaits this promise before
120
+ // calling `writeLog`, so this writer is guaranteed done before the rewrite.
121
+ await Promise.all([streamClosed(jsonl), streamClosed(pretty)]);
118
122
  }
119
123
  };
124
+ /** Resolve once a write stream has flushed and closed its file descriptor. */
125
+ const streamClosed = (stream) => new Promise((resolve) => {
126
+ stream.on("close", () => resolve());
127
+ stream.on("error", () => resolve()); // a stream error still frees the file; don't hang
128
+ stream.end();
129
+ });
120
130
  let seq = 0;
121
131
  /** Serialize a value for a snapshot file, tolerating circular refs / non-JSON values. */
122
132
  const serialize = (value) => {
@@ -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);
@@ -114,14 +126,19 @@ export function run(...args) {
114
126
  process.on("unhandledRejection", onUnhandled);
115
127
  narrate(`run started · ${scriptPath()}`);
116
128
  // Start streaming log entries to disk immediately — incremental writes survive
117
- // hard kills where the final writeLog never runs.
118
- void streamLog(logs).catch(() => { });
129
+ // hard kills where the final writeLog never runs. Kept so finalize can await it
130
+ // *before* writeLog rewrites the same files: otherwise a late flush from this
131
+ // stream lands past writeLog's truncation and the OS zero-fills the gap (a
132
+ // multi-MB NUL run in run.log).
133
+ const streaming = streamLog(logs).catch(() => { });
119
134
  const launch = (instance) => {
120
135
  narrate(`launch ${instance.name}`);
121
136
  // 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
137
+ // grouping is applied at render time) and gets the spin's signal directly; the
123
138
  // framework injects a `proc` bound to both, so there's no ambient context.
124
- const promise = instance.start({ logs, signal: controller.signal }).catch((error) => {
139
+ // `start()` resolves with the action's Outcome — it never rejects for the
140
+ // action's own failure — so a failure is handled here as data, not a throw.
141
+ const record = (error) => {
125
142
  // Always record the fault in the action's own log, under its name.
126
143
  logs.append({
127
144
  source: instance.name,
@@ -129,13 +146,20 @@ export function run(...args) {
129
146
  message: error instanceof Error ? (error.stack ?? error.message) : String(error),
130
147
  });
131
148
  // 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.
149
+ // when this surfaced, the failure is almost certainly the abort killing the
150
+ // action (a cascade), not its own fault — so it must not pollute the verdict.
136
151
  if (!controller.signal.aborted)
137
152
  recordFailure(instance.name, error);
138
- });
153
+ };
154
+ const promise = instance
155
+ .start({ logs, signal: controller.signal })
156
+ .then((outcome) => {
157
+ if (!outcome.ok)
158
+ record(outcome.error);
159
+ })
160
+ // Defensive: `start()` shouldn't reject, but if the framework itself throws,
161
+ // treat it as a fault rather than losing it to an unhandled rejection.
162
+ .catch(record);
139
163
  inFlight.add(promise);
140
164
  void promise.finally(() => inFlight.delete(promise));
141
165
  };
@@ -163,6 +187,10 @@ export function run(...args) {
163
187
  process.off("uncaughtException", onUncaught);
164
188
  process.off("unhandledRejection", onUnhandled);
165
189
  narrate("finished");
190
+ // Wait for the incremental streamer to fully close its files, so its last
191
+ // flush can't land after writeLog truncates+rewrites them (which would leave
192
+ // a NUL gap). The log is already closed, so this resolves promptly.
193
+ await streaming;
166
194
  const file = await writeLog(logs);
167
195
  // Direct to the terminal — the file's already written, so this line isn't in it.
168
196
  console.log(marker("run", `log → ${file}`));
@@ -198,7 +226,7 @@ export function run(...args) {
198
226
  signal: controller.signal,
199
227
  run(...more) {
200
228
  if (state !== "open") {
201
- throw new Error(`run.run(…) rejected: the run is "${state}", not "open". Launch actions before ` +
229
+ throw new Error(`spin.run(…) rejected: the spin is "${state}", not "open". Launch actions before ` +
202
230
  `teardown, or from a lifecycle event of an action that is still running.`);
203
231
  }
204
232
  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,23 +20,23 @@ 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;
35
38
  /**
36
- * Absolute path to this run's output folder (`logs/<name>/<date>/<time>/`),
39
+ * Absolute path to this spin's output folder (`logs/<name>/<date>/<time>/`),
37
40
  * created up front. Hand it to actions (or use `ctx.artifactsFolder` inside
38
41
  * one) to write artifacts beside `run.log`.
39
42
  */
@@ -42,23 +45,28 @@ export interface SpinContext {
42
45
  /** The body of a {@link spin}: any (async) function — it isn't named or wrapped by you. */
43
46
  export type SpinBody = (ctx: SpinContext) => void | Promise<void>;
44
47
  /**
45
- * 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
46
49
  * in a single in-flight action and hands it a {@link Nod}. Because that host
47
- * stays in-flight while your body `await`s, the run never idles shut between
48
- * steps — so you write `await` / `if` control flow instead of nested `done`
49
- * callbacks. When the body returns, the daemons it nodded in keep the run alive
50
- * 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.
51
54
  *
52
- * spin({ failFast: false }, async ({ nod }) => {
53
- * if ((await nod(prompt({ … })).done) === "yes") await nod(test).done;
54
- * nod(devServer); // fire-and-forget daemon
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):
58
+ *
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
55
63
  * const chrome = nod(chromedriver({ … }));
56
- * nod(chromePage(chrome.ref)); // data deps via .ref
64
+ * nod(chromePage(chrome.ref)); // data deps via .ref
57
65
  * });
58
66
  *
59
67
  * Auto-drains by default; pass `drain: false` and call `ctx.drain()` yourself to
60
68
  * stream the log only from a chosen point (e.g. after a `prompt`). Returns the
61
- * {@link Run}, so `.done` is available on it too.
69
+ * {@link Spin} — `await` its `.done` for the verdict.
62
70
  */
63
- export declare function spin(body: SpinBody): Run;
64
- 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
@@ -3,9 +3,9 @@ import { run } from "./run.js";
3
3
  import { artifactsFolder } from "./output.js";
4
4
  export function spin(a, b) {
5
5
  const [options, body] = typeof a === "function" ? [{}, a] : [a, b];
6
- const { drain: autoDrain = true, ...runOptions } = options;
6
+ const { drain: autoDrain = true } = options;
7
7
  // `run(...)` below assigns `host` synchronously, before the action's async body
8
- // ever runs — so the closures here capture the run safely.
8
+ // ever runs — so the closures here capture the spin safely.
9
9
  let host;
10
10
  // One-shot drain: auto-drain and a manual ctx.drain() can't double-subscribe.
11
11
  let drained = false;
@@ -29,7 +29,7 @@ export function spin(a, b) {
29
29
  artifactsFolder: artifactsFolder(),
30
30
  });
31
31
  });
32
- host = run(runOptions, hosted());
32
+ host = run(hosted());
33
33
  if (autoDrain)
34
34
  startDrain();
35
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";
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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omkit",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
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",