omkit 0.2.1 → 0.4.0

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.
Files changed (56) hide show
  1. package/README.md +66 -42
  2. package/dist/actions/browser.d.ts +28 -0
  3. package/dist/actions/browser.js +36 -0
  4. package/dist/actions/command.d.ts +11 -7
  5. package/dist/actions/command.js +16 -10
  6. package/dist/actions/index.d.ts +2 -0
  7. package/dist/actions/index.js +1 -0
  8. package/dist/actions/prompt.js +43 -17
  9. package/dist/cli/client/channel.d.ts +14 -0
  10. package/dist/cli/client/channel.js +48 -0
  11. package/dist/cli/client/check.d.ts +3 -0
  12. package/dist/cli/client/check.js +32 -0
  13. package/dist/cli/client/discovery.d.ts +7 -0
  14. package/dist/cli/client/discovery.js +147 -0
  15. package/dist/cli/client/index.d.ts +9 -0
  16. package/dist/cli/client/index.js +11 -0
  17. package/dist/cli/client/registry.d.ts +29 -0
  18. package/dist/cli/client/registry.js +1 -0
  19. package/dist/cli/client/runner.d.ts +15 -0
  20. package/dist/cli/client/runner.js +61 -0
  21. package/dist/cli/client/types.d.ts +53 -0
  22. package/dist/cli/client/types.js +1 -0
  23. package/dist/cli/commands/check.d.ts +6 -0
  24. package/dist/cli/commands/check.js +11 -0
  25. package/dist/cli/commands/init.d.ts +5 -0
  26. package/dist/cli/commands/init.js +54 -0
  27. package/dist/cli/commands/ls.d.ts +5 -0
  28. package/dist/cli/commands/ls.js +25 -0
  29. package/dist/cli/commands/run.d.ts +10 -0
  30. package/dist/cli/commands/run.js +12 -0
  31. package/dist/cli/commands/ui.d.ts +7 -0
  32. package/dist/cli/commands/ui.js +30 -0
  33. package/dist/cli/index.d.ts +9 -0
  34. package/dist/cli/index.js +91 -0
  35. package/dist/cli/ui/Spinner.d.ts +10 -0
  36. package/dist/cli/ui/Spinner.js +20 -0
  37. package/dist/cli/ui/app.d.ts +9 -0
  38. package/dist/cli/ui/app.js +93 -0
  39. package/dist/cli/ui/views/OmList.d.ts +9 -0
  40. package/dist/cli/ui/views/OmList.js +36 -0
  41. package/dist/cli/ui/views/RunView.d.ts +14 -0
  42. package/dist/cli/ui/views/RunView.js +47 -0
  43. package/dist/core/ActionRun.d.ts +2 -4
  44. package/dist/core/ActionRun.js +27 -32
  45. package/dist/core/ExecutionTree.d.ts +1 -0
  46. package/dist/core/ExecutionTree.js +25 -4
  47. package/dist/core/interaction.d.ts +63 -0
  48. package/dist/core/interaction.js +82 -0
  49. package/dist/core/step.js +2 -7
  50. package/dist/core/types.d.ts +12 -17
  51. package/dist/index.d.ts +1 -1
  52. package/dist/output/LiveRenderer.d.ts +7 -17
  53. package/dist/output/LiveRenderer.js +8 -82
  54. package/dist/output/milestones.d.ts +8 -0
  55. package/dist/output/milestones.js +39 -0
  56. package/package.json +9 -1
@@ -0,0 +1,9 @@
1
+ import { type ReactElement } from "react";
2
+ import type { OmkitClient, Verdict } from "../client/types.ts";
3
+ /** The interactive app: discover → list/search → run → live milestones + prompts. */
4
+ export declare function App({ client, onExit, }: {
5
+ client: OmkitClient;
6
+ /** Called with the run's verdict (if any) just before Ink unmounts, so the caller can print
7
+ * a durable summary — Ink erases its own frame on exit. */
8
+ onExit?: (verdict: Verdict | undefined) => void;
9
+ }): ReactElement;
@@ -0,0 +1,93 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { useApp, useInput } from "ink";
4
+ import { format } from "../../output/milestones.js";
5
+ import { OmList } from "./views/OmList.js";
6
+ import { RunView } from "./views/RunView.js";
7
+ import { Spinner } from "./Spinner.js";
8
+ /** The interactive app: discover → list/search → run → live milestones + prompts. */
9
+ export function App({ client, onExit, }) {
10
+ const { exit } = useApp();
11
+ const [oms, setOms] = useState([]);
12
+ const [session, setSession] = useState(null);
13
+ const [lines, setLines] = useState([]);
14
+ const [prompt, setPrompt] = useState();
15
+ const [verdict, setVerdict] = useState();
16
+ const [tearing, setTearing] = useState(false);
17
+ // Refs so the Ctrl+C handler always sees the live session/verdict, not a stale render closure.
18
+ const sessionRef = useRef(null);
19
+ const verdictRef = useRef(undefined);
20
+ const tearingRef = useRef(false);
21
+ useEffect(() => {
22
+ let live = true;
23
+ void client.discover().then((r) => {
24
+ if (live)
25
+ setOms(r.oms);
26
+ });
27
+ return () => {
28
+ live = false;
29
+ };
30
+ }, [client]);
31
+ const run = (om) => {
32
+ const s = client.run(om.file);
33
+ sessionRef.current = s;
34
+ s.on("log", (entry) => {
35
+ const rendered = format(entry);
36
+ if (rendered)
37
+ setLines((prev) => [...prev, rendered.text]);
38
+ });
39
+ s.on("prompt", (req) => setPrompt(req));
40
+ s.on("settled", (v) => {
41
+ setVerdict(v);
42
+ setPrompt(undefined);
43
+ });
44
+ // The single exit point. `result` resolves when the run ends — a natural completion (a build
45
+ // that settles on its own), a teardown after Ctrl+C, or a crash that closes the channel — and
46
+ // stays pending for a live daemon stack until Ctrl+C. Record the verdict, hand it to the caller
47
+ // so it can print the durable summary block, and unmount. Without this a self-completing run
48
+ // would show its verdict line but never exit or render the summary.
49
+ void s.result.then((v) => {
50
+ verdictRef.current = v;
51
+ setVerdict(v);
52
+ onExit?.(v);
53
+ exit();
54
+ });
55
+ setSession(s);
56
+ };
57
+ const answer = (value) => {
58
+ if (session && prompt)
59
+ session.answer(prompt.id, value);
60
+ setPrompt(undefined);
61
+ };
62
+ // Ctrl+C: in raw mode this is a keystroke, not a signal. Cancel the run and wait for the child
63
+ // to tear down and report its verdict, then quit — leaving the durable summary to `onExit`.
64
+ //
65
+ // Repeated presses are a deliberate no-op (see the `tearing` guard below): the child owns the
66
+ // teardown and is guaranteed to report `settled` within its grace window, so we let it finish
67
+ // rather than force-exiting. This mirrors the normal om run, where SIGINT is caught with
68
+ // `process.on` and smashing Ctrl+C just re-triggers an idempotent teardown — the process never
69
+ // exits before the tree is reaped. Force-exiting here (the old second-press escape hatch) killed
70
+ // the parent mid-teardown and orphaned the child's process tree (vite, watchers, Chrome).
71
+ useInput((input, key) => {
72
+ if (!(key.ctrl && input === "c"))
73
+ return;
74
+ const s = sessionRef.current;
75
+ // Nothing running or already settled → quit now.
76
+ if (!s || verdictRef.current) {
77
+ onExit?.(verdictRef.current);
78
+ exit();
79
+ return;
80
+ }
81
+ // Already tearing down → ignore. The child is reaping its tree and will report `settled` (or
82
+ // the channel closes and resolves a failed verdict); either way `result` resolves and the
83
+ // handler wired in `run()` records the verdict and unmounts. Just request the teardown here.
84
+ if (tearingRef.current)
85
+ return;
86
+ tearingRef.current = true;
87
+ setTearing(true);
88
+ s.cancel();
89
+ });
90
+ if (!session)
91
+ return _jsx(OmList, { oms: oms, onSelect: run });
92
+ return (_jsxs(_Fragment, { children: [_jsx(RunView, { lines: lines, prompt: prompt, verdict: verdict, onAnswer: answer }), !verdict && tearing ? _jsx(Spinner, { label: "tearing down\u2026 (stopping the run cleanly)" }) : null, !verdict && !tearing && !prompt ? _jsx(Spinner, { label: "running\u2026" }) : null] }));
93
+ }
@@ -0,0 +1,9 @@
1
+ import { type ReactElement } from "react";
2
+ import type { DiscoveredOm } from "../../client/registry.ts";
3
+ /** Case-insensitive substring filter on om names. */
4
+ export declare function filterOms(oms: DiscoveredOm[], query: string): DiscoveredOm[];
5
+ /** A searchable, arrow-navigable list of oms; Enter selects the highlighted one. */
6
+ export declare function OmList({ oms, onSelect, }: {
7
+ oms: DiscoveredOm[];
8
+ onSelect: (om: DiscoveredOm) => void;
9
+ }): ReactElement;
@@ -0,0 +1,36 @@
1
+ import { jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ /** Case-insensitive substring filter on om names. */
5
+ export function filterOms(oms, query) {
6
+ const q = query.toLowerCase();
7
+ return oms.filter((o) => o.name.toLowerCase().includes(q));
8
+ }
9
+ /** A searchable, arrow-navigable list of oms; Enter selects the highlighted one. */
10
+ export function OmList({ oms, onSelect, }) {
11
+ const [query, setQuery] = useState("");
12
+ const [index, setIndex] = useState(0);
13
+ const filtered = filterOms(oms, query);
14
+ useInput((input, key) => {
15
+ if (key.upArrow) {
16
+ setIndex((i) => Math.max(0, i - 1));
17
+ }
18
+ else if (key.downArrow) {
19
+ setIndex((i) => Math.min(filtered.length - 1, i + 1));
20
+ }
21
+ else if (key.return) {
22
+ const picked = filtered[index];
23
+ if (picked)
24
+ onSelect(picked);
25
+ }
26
+ else if (key.backspace || key.delete) {
27
+ setQuery((q) => q.slice(0, -1));
28
+ setIndex(0);
29
+ }
30
+ else if (input && !key.ctrl && !key.meta) {
31
+ setQuery((q) => q + input);
32
+ setIndex(0);
33
+ }
34
+ });
35
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["search: ", query] }), filtered.map((o, i) => (_jsxs(Text, { inverse: i === index, children: [o.name, " ", o.file] }, o.name)))] }));
36
+ }
@@ -0,0 +1,14 @@
1
+ import { type ReactElement } from "react";
2
+ import type { PromptRequest, Verdict } from "../../client/types.ts";
3
+ /** Answer one pending prompt — free-text input, or arrow-navigable choices. */
4
+ export declare function PromptView({ request, onAnswer, }: {
5
+ request: PromptRequest;
6
+ onAnswer: (value: string) => void;
7
+ }): ReactElement;
8
+ /** The live run surface: milestone lines, a pending prompt, and the final verdict. */
9
+ export declare function RunView({ lines, prompt, verdict, onAnswer, }: {
10
+ lines: string[];
11
+ prompt?: PromptRequest;
12
+ verdict?: Verdict;
13
+ onAnswer: (value: string) => void;
14
+ }): ReactElement;
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef, useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ /** Answer one pending prompt — free-text input, or arrow-navigable choices. */
5
+ export function PromptView({ request, onAnswer, }) {
6
+ const { spec } = request;
7
+ const choices = spec.choices ?? [];
8
+ // Refs hold the authoritative value: Ink can deliver several key events before React
9
+ // re-renders, so the Enter handler must read a ref, not a possibly-stale render closure.
10
+ // State mirrors the ref only to drive the display.
11
+ const textRef = useRef("");
12
+ const indexRef = useRef(0);
13
+ const [text, setText] = useState("");
14
+ const [index, setIndex] = useState(0);
15
+ const onChoiceKey = (key) => {
16
+ if (key.upArrow) {
17
+ indexRef.current = Math.max(0, indexRef.current - 1);
18
+ setIndex(indexRef.current);
19
+ }
20
+ else if (key.downArrow) {
21
+ indexRef.current = Math.min(choices.length - 1, indexRef.current + 1);
22
+ setIndex(indexRef.current);
23
+ }
24
+ else if (key.return) {
25
+ onAnswer(choices[indexRef.current]?.value ?? spec.default);
26
+ }
27
+ };
28
+ const onInputKey = (input, key) => {
29
+ if (key.return) {
30
+ onAnswer(textRef.current === "" ? spec.default : textRef.current);
31
+ }
32
+ else if (key.backspace || key.delete) {
33
+ textRef.current = textRef.current.slice(0, -1);
34
+ setText(textRef.current);
35
+ }
36
+ else if (input && !key.ctrl && !key.meta) {
37
+ textRef.current += input;
38
+ setText(textRef.current);
39
+ }
40
+ };
41
+ useInput((input, key) => (spec.kind === "choice" ? onChoiceKey(key) : onInputKey(input, key)));
42
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [_jsx(Text, { children: spec.message }), spec.kind === "choice" ? (choices.map((c, i) => (_jsxs(Text, { inverse: i === index, children: [c.value === spec.default ? "* " : " ", c.label] }, c.value)))) : (_jsxs(Text, { children: ["> ", text] }))] }));
43
+ }
44
+ /** The live run surface: milestone lines, a pending prompt, and the final verdict. */
45
+ export function RunView({ lines, prompt, verdict, onAnswer, }) {
46
+ return (_jsxs(Box, { flexDirection: "column", children: [lines.map((line, i) => (_jsx(Text, { children: line }, i))), prompt ? _jsx(PromptView, { request: prompt, onAnswer: onAnswer }) : null, verdict ? (_jsxs(Text, { color: verdict.ok ? "green" : "red", children: [verdict.ok ? "✓ ok" : "✗ failed", " \u00B7 ", verdict.folder] })) : null] }));
47
+ }
@@ -2,7 +2,7 @@ import { type EventHandler } from "../foundation/events.ts";
2
2
  import type { ActionRef } from "../foundation/ActionRef.ts";
3
3
  import type { LogStore } from "../output/log/LogStore.ts";
4
4
  import type { SnapshotStore } from "../output/snapshot/SnapshotStore.ts";
5
- import type { Activity, Exec, InstanceEvents, NodeStatus, Outcome } from "./types.ts";
5
+ import type { Activity, Exec, InstanceEvents, NodeStatus } from "./types.ts";
6
6
  /** Everything a node needs to exist, minus its behavior. */
7
7
  export interface NodeInit {
8
8
  readonly store: LogStore;
@@ -61,7 +61,6 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
61
61
  private readonly emitter;
62
62
  private readonly settled;
63
63
  private readonly handle;
64
- private failureHandler;
65
64
  private attached;
66
65
  private observed;
67
66
  private refObserved;
@@ -75,14 +74,13 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
75
74
  toRef(): ActionRef;
76
75
  /** This node's abort signal — handed to the exec as `ctx.signal`. */
77
76
  get signal(): AbortSignal;
78
- get result(): Promise<Outcome<Result>>;
77
+ get result(): Promise<Result>;
79
78
  /** The attached handle; rejects on failure/cancel. */
80
79
  get ref(): Promise<Handle>;
81
80
  on<K extends keyof InstanceEvents<Events, Result>>(key: K, handler: EventHandler<InstanceEvents<Events, Result>[K]>): void;
82
81
  once<K extends keyof InstanceEvents<Events, Result>>(key: K): Promise<InstanceEvents<Events, Result>[K] | undefined>;
83
82
  tag(name: string): this;
84
83
  withCache(...paths: string[]): Activity<Result | undefined, Events, Handle>;
85
- handleFailure(handler: (error: unknown) => void): this;
86
84
  cancel(): void;
87
85
  /**
88
86
  * Log a cancellation milestone on this node (once) and bubble it to the parent, so a
@@ -37,7 +37,6 @@ export class ActionRun {
37
37
  emitter = events();
38
38
  settled = defer();
39
39
  handle = defer();
40
- failureHandler;
41
40
  attached = false;
42
41
  observed = false;
43
42
  refObserved = false;
@@ -95,7 +94,10 @@ export class ActionRun {
95
94
  // ── Run handle surface ─────────────────────────────────────────────────────
96
95
  get result() {
97
96
  this.observed = true;
98
- return this.settled.promise.then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
97
+ // The outcome itself: resolves the value, rejects with the error (CancelledError on cancel).
98
+ // Reading it marks the failure observed, so a `.result.catch(fn)` handles a fire-and-forget
99
+ // crash without tearing the run down.
100
+ return this.settled.promise;
99
101
  }
100
102
  /** The attached handle; rejects on failure/cancel. */
101
103
  get ref() {
@@ -111,22 +113,27 @@ export class ActionRun {
111
113
  this.emitter.listen(key, handler);
112
114
  }
113
115
  once(key) {
114
- if (key === "error")
115
- this.observed = true;
116
- if (key === "done") {
117
- return this.settled.promise.then((v) => v, () => undefined);
118
- }
119
- return new Promise((resolve) => {
116
+ // `done` is the outcome itself — identical to `.result` (resolve the value, reject on failure).
117
+ if (key === "done")
118
+ return this.result;
119
+ // Awaiting an event observes the activity: resolve the payload when it fires, but if the
120
+ // activity settles first, resolve `undefined` on success and **reject** on failure/cancel —
121
+ // so an event gate surfaces a failure instead of hanging (or, for `error`, hand back the error).
122
+ this.observed = true;
123
+ return new Promise((resolve, reject) => {
120
124
  let done = false;
121
- const settle = (v) => {
122
- if (!done) {
123
- done = true;
124
- resolve(v);
125
- }
126
- };
127
- const doneKey = "done";
128
- this.emitter.listenOnce(key, (p) => settle(p));
129
- this.emitter.listenOnce(doneKey, () => settle(undefined));
125
+ const win = () => (done ? false : (done = true));
126
+ this.emitter.listenOnce(key, (p) => {
127
+ if (win())
128
+ resolve(p);
129
+ });
130
+ this.settled.promise.then(() => {
131
+ if (win())
132
+ resolve(undefined);
133
+ }, (error) => {
134
+ if (win())
135
+ key === "error" ? resolve(error) : reject(error);
136
+ });
130
137
  });
131
138
  }
132
139
  tag(name) {
@@ -146,16 +153,6 @@ export class ActionRun {
146
153
  this.armedExec = cacheGate(inner, targets);
147
154
  return this;
148
155
  }
149
- handleFailure(handler) {
150
- this.failureHandler = handler;
151
- this.observed = true;
152
- // Attached after an `await` on a body that has already failed: deliver the
153
- // already-recorded failure now (it won't fail again). Cancellation is status
154
- // "cancelled", not "failed", so a cancelled node never runs the handler.
155
- if (this.status === "failed")
156
- handler(this.error);
157
- return this;
158
- }
159
156
  cancel() {
160
157
  this.noteCancelled(); // a direct cancel of this activity
161
158
  this.controller.abort();
@@ -291,11 +288,9 @@ export class ActionRun {
291
288
  // signal.aborted, which is why the check below uses `cascadeCancelled`, not
292
289
  // `signal.aborted`, to tell an unhandled fault from a run/ancestor teardown.
293
290
  this.controller.abort();
294
- // A registered handler owns a fire-and-forget failure (side effect only; not on cancel).
295
- this.failureHandler?.(error);
296
- // If, one microtask on, no one is positioned to receive this error (no `.result`/`.ref`
297
- // read, no `on("error")`) and we weren't caught in an ancestor's teardown, it's
298
- // unhandled — tell the tree to tear the run down and record the fault.
291
+ // If, one microtask on, no one is positioned to receive this error (no `.result`/`.ref`/
292
+ // `.once` read, no `on("error")`, no `.result.catch`) and we weren't caught in an ancestor's
293
+ // teardown, it's unhandled — tell the tree to tear the run down and record the fault.
299
294
  // `sysEmit("error")` above already ran, so a live error handler has set `observed`.
300
295
  queueMicrotask(() => {
301
296
  const observed = this.observed || (this.refObserved && !this.attached);
@@ -28,6 +28,7 @@ export declare class ExecutionTree {
28
28
  private readonly originalLog;
29
29
  private streaming;
30
30
  private liveRendering;
31
+ private forwarding;
31
32
  private assertionsPassed;
32
33
  private assertionsFailed;
33
34
  private readonly faults;
@@ -15,6 +15,7 @@ import { writeResult } from "../output/writers/ResultWriter.js";
15
15
  import { ActionRun } from "./ActionRun.js";
16
16
  import { procRegistry } from "../system/proc.js";
17
17
  import { currentNode } from "./context.js";
18
+ import { activeSupervisor } from "./interaction.js";
18
19
  /**
19
20
  * The run: a module singleton that owns the log store, the run folder, the node
20
21
  * registry, and the process-wide teardown. `launch` creates a child node under the
@@ -47,6 +48,7 @@ export class ExecutionTree {
47
48
  originalLog = console.log.bind(console);
48
49
  streaming;
49
50
  liveRendering;
51
+ forwarding;
50
52
  assertionsPassed = 0;
51
53
  assertionsFailed = 0;
52
54
  faults = [];
@@ -202,9 +204,21 @@ export class ExecutionTree {
202
204
  process.on("uncaughtException", this.onUncaught);
203
205
  process.on("unhandledRejection", this.onUnhandled);
204
206
  this.streaming = this.rawStream.run(this.store);
205
- // Live, curated milestones to the terminal — a single in-place status line on a TTY.
206
- // Straight to process.stdout, which patch-console doesn't intercept (no capture feedback).
207
- this.liveRendering = new LiveRenderer(process.stdout).run(this.store);
207
+ const supervisor = activeSupervisor();
208
+ if (supervisor) {
209
+ // Supervised: the supervisor owns the terminal. Forward every entry up the channel
210
+ // instead of rendering, and tear down on its cancel request.
211
+ supervisor.onCancel(() => this.cancel());
212
+ this.forwarding = (async () => {
213
+ for await (const entry of this.store.subscribe({ replay: true }))
214
+ supervisor.log(entry);
215
+ })();
216
+ }
217
+ else {
218
+ // Bare: live, curated milestones to the terminal — a single in-place status line on a TTY.
219
+ // Straight to process.stdout, which patch-console doesn't intercept (no capture feedback).
220
+ this.liveRendering = new LiveRenderer(process.stdout).run(this.store);
221
+ }
208
222
  const rootPromise = currentNode.run(this.root, () => this.root.run(body, []));
209
223
  this.trackNode(rootPromise);
210
224
  await rootPromise;
@@ -252,6 +266,7 @@ export class ExecutionTree {
252
266
  this.store.close();
253
267
  await this.streaming;
254
268
  await this.liveRendering;
269
+ await this.forwarding;
255
270
  const flat = this.registry.map((node) => this.nodeView(node));
256
271
  const entries = this.store.entries();
257
272
  const at = (name) => path.join(this.folder.path(), name);
@@ -269,7 +284,13 @@ export class ExecutionTree {
269
284
  process.off("unhandledRejection", this.onUnhandled);
270
285
  this.consoleCapture.uninstall();
271
286
  ExecutionTree.current = null;
272
- this.printSummary(summary);
287
+ const supervisor = activeSupervisor();
288
+ // Forward the same recap block the bare run prints to its terminal, so a supervising
289
+ // frontend (the ink console) can show it verbatim — see printSummary.
290
+ if (supervisor)
291
+ supervisor.settled(this.verdictOk(), this.folder.path(), summary);
292
+ else
293
+ this.printSummary(summary);
273
294
  if (!this.verdictOk())
274
295
  process.exitCode = 1;
275
296
  }
@@ -0,0 +1,63 @@
1
+ import type { LogEntry } from "../foundation/LogEntry.ts";
2
+ /**
3
+ * A normalized prompt request the supervisor renders. The `prompt` action builds it;
4
+ * `interaction.ts` treats it as an opaque payload (no dependency on the action layer,
5
+ * so there is no core→actions import cycle).
6
+ */
7
+ export interface PromptSpec {
8
+ kind: "input" | "choice";
9
+ message: string;
10
+ default: string;
11
+ choices?: {
12
+ label: string;
13
+ value: string;
14
+ }[];
15
+ }
16
+ export type ChildMessage = {
17
+ kind: "prompt";
18
+ id: string;
19
+ spec: PromptSpec;
20
+ } | {
21
+ kind: "log";
22
+ entry: LogEntry;
23
+ } | {
24
+ kind: "settled";
25
+ ok: boolean;
26
+ folder: string;
27
+ summary: string[];
28
+ };
29
+ export type AnswerMessage = {
30
+ kind: "answer";
31
+ id: string;
32
+ value: string;
33
+ via: string;
34
+ };
35
+ export type SupervisorMessage = AnswerMessage | {
36
+ kind: "cancel";
37
+ };
38
+ type Send = (message: ChildMessage) => void;
39
+ type OnMessage = (handler: (message: SupervisorMessage) => void) => void;
40
+ /**
41
+ * The child-side handle to an out-of-process supervisor. Sends prompt requests, live log
42
+ * entries, and the final verdict up the channel; resolves prompts when the matching answer
43
+ * comes back; and invokes a cancel handler on a `cancel` message. Purely a transport — it
44
+ * decides nothing (the child keeps its own prompt timeout; see the `prompt` action).
45
+ */
46
+ export declare class Supervisor {
47
+ private readonly send;
48
+ private seq;
49
+ private readonly pending;
50
+ private cancelHandler;
51
+ constructor(send: Send, onMessage: OnMessage);
52
+ request(spec: PromptSpec, signal: AbortSignal): Promise<AnswerMessage>;
53
+ log(entry: LogEntry): void;
54
+ settled(ok: boolean, folder: string, summary: string[]): void;
55
+ onCancel(handler: () => void): void;
56
+ }
57
+ /** Build a supervisor over an arbitrary channel — used in tests and by {@link detect}. */
58
+ export declare function createSupervisor(send: Send, onMessage: OnMessage): Supervisor;
59
+ /** The active supervisor, or `null` when running bare. */
60
+ export declare function activeSupervisor(): Supervisor | null;
61
+ /** Test seam: override the active supervisor (pass `null` to restore bare mode). */
62
+ export declare function installSupervisor(supervisor: Supervisor | null): void;
63
+ export {};
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The child-side handle to an out-of-process supervisor. Sends prompt requests, live log
3
+ * entries, and the final verdict up the channel; resolves prompts when the matching answer
4
+ * comes back; and invokes a cancel handler on a `cancel` message. Purely a transport — it
5
+ * decides nothing (the child keeps its own prompt timeout; see the `prompt` action).
6
+ */
7
+ export class Supervisor {
8
+ send;
9
+ seq = 0;
10
+ pending = new Map();
11
+ cancelHandler;
12
+ constructor(send, onMessage) {
13
+ this.send = send;
14
+ onMessage((message) => {
15
+ if (message.kind === "answer") {
16
+ const resolve = this.pending.get(message.id);
17
+ if (resolve) {
18
+ this.pending.delete(message.id);
19
+ resolve(message);
20
+ }
21
+ }
22
+ else if (message.kind === "cancel") {
23
+ this.cancelHandler?.();
24
+ }
25
+ });
26
+ }
27
+ request(spec, signal) {
28
+ const id = `p${++this.seq}`;
29
+ return new Promise((resolve, reject) => {
30
+ const onAbort = () => {
31
+ this.pending.delete(id);
32
+ reject(new Error("prompt aborted"));
33
+ };
34
+ if (signal.aborted)
35
+ return onAbort();
36
+ signal.addEventListener("abort", onAbort, { once: true });
37
+ this.pending.set(id, (answer) => {
38
+ signal.removeEventListener("abort", onAbort);
39
+ resolve(answer);
40
+ });
41
+ this.send({ kind: "prompt", id, spec });
42
+ });
43
+ }
44
+ log(entry) {
45
+ this.send({ kind: "log", entry });
46
+ }
47
+ settled(ok, folder, summary) {
48
+ this.send({ kind: "settled", ok, folder, summary });
49
+ }
50
+ onCancel(handler) {
51
+ this.cancelHandler = handler;
52
+ }
53
+ }
54
+ /** Build a supervisor over an arbitrary channel — used in tests and by {@link detect}. */
55
+ export function createSupervisor(send, onMessage) {
56
+ return new Supervisor(send, onMessage);
57
+ }
58
+ /**
59
+ * The real supervisor when this process was `fork`ed with an IPC channel and marked
60
+ * supervised (`OMKIT_SUPERVISED=1`), else `null`. Detected once at import.
61
+ */
62
+ function detect() {
63
+ const supervised = process.env.OMKIT_SUPERVISED === "1" && typeof process.send === "function";
64
+ // Read the flag once, then clear it from the environment so any subprocess this run spawns
65
+ // (a shell command, a nested `npm test`, …) does not inherit it and spuriously enter
66
+ // supervised mode. Each omkit child's status is set explicitly by whoever forks it.
67
+ delete process.env.OMKIT_SUPERVISED;
68
+ if (!supervised)
69
+ return null;
70
+ const send = (message) => void process.send(message);
71
+ const onMessage = (handler) => process.on("message", (m) => handler(m));
72
+ return createSupervisor(send, onMessage);
73
+ }
74
+ let current = detect();
75
+ /** The active supervisor, or `null` when running bare. */
76
+ export function activeSupervisor() {
77
+ return current;
78
+ }
79
+ /** Test seam: override the active supervisor (pass `null` to restore bare mode). */
80
+ export function installSupervisor(supervisor) {
81
+ current = supervisor;
82
+ }
package/dist/core/step.js CHANGED
@@ -7,11 +7,6 @@ import { action } from "./action.js";
7
7
  * units use `action(...)`.
8
8
  */
9
9
  export function step(name, fn) {
10
- return action(name)
11
- .run(fn)()
12
- .result.then((r) => {
13
- if (!r.ok)
14
- throw r.error;
15
- return r.value;
16
- });
10
+ // `.result` already resolves the value and rejects on failure/cancel — exactly step's contract.
11
+ return action(name).run(fn)().result;
17
12
  }
@@ -5,14 +5,6 @@ import type { Proc } from "../system/proc.ts";
5
5
  export type Awaitable<T> = T | Promise<T>;
6
6
  /** An action that declares no events. */
7
7
  export type NoEvents = Record<never, never>;
8
- /** An activity's terminal outcome: a value on success, an error on failure/cancel. Never thrown. */
9
- export type Outcome<T> = {
10
- ok: true;
11
- value: T;
12
- } | {
13
- ok: false;
14
- error: unknown;
15
- };
16
8
  /** A node's lifecycle state, mirrored into `result.json`. */
17
9
  export type NodeStatus = "running" | "ok" | "failed" | "cancelled";
18
10
  /**
@@ -73,8 +65,12 @@ export interface OmContext {
73
65
  */
74
66
  export interface Activity<Result = unknown, Events extends object = NoEvents, Handle = void> {
75
67
  readonly id: string;
76
- /** The terminal outcome; always resolves (never rejects). Reading it observes the failure. */
77
- readonly result: Promise<Outcome<Result>>;
68
+ /**
69
+ * The terminal outcome: resolves the value on success, **rejects** with the action's error on
70
+ * failure (or `CancelledError` on cancel). Reading it observes the activity — a handled failure,
71
+ * so it won't tear the run down. For fire-and-forget handling, `activity.result.catch(fn)`.
72
+ */
73
+ readonly result: Promise<Result>;
78
74
  /** The attached handle; rejects on failure/cancel. */
79
75
  readonly ref: Promise<Handle>;
80
76
  /**
@@ -84,15 +80,14 @@ export interface Activity<Result = unknown, Events extends object = NoEvents, Ha
84
80
  * after the launching call; calling it once the body has started throws.
85
81
  */
86
82
  withCache(...paths: string[]): Activity<Result | undefined, Events, Handle>;
87
- /**
88
- * Handle a fire-and-forget activity's failure: attach a handler so a crash runs it
89
- * instead of tearing the run down. Observes the failure; chainable; attach in the
90
- * same tick as the launching call. Not called on cancellation.
91
- */
92
- handleFailure(handler: (error: unknown) => void): this;
93
83
  /** Subscribe to every emit of a declared/system event (`done`/`error`/`attached`). */
94
84
  on<K extends keyof InstanceEvents<Events, Result>>(key: K, handler: EventHandler<InstanceEvents<Events, Result>[K]>): void;
95
- /** Next emit of `key`; resolves `undefined` if the node settles first (never rejects). */
85
+ /**
86
+ * Await the next emit of `key`. Resolves the event's payload when it fires; if the activity
87
+ * settles first, resolves `undefined` on success and **rejects** on failure/cancellation — so a
88
+ * gate on an event surfaces a failure instead of hanging or sailing past it. Observes the
89
+ * activity. `once("done")` is the outcome itself — identical to `.result`.
90
+ */
96
91
  once<K extends keyof InstanceEvents<Events, Result>>(key: K): Promise<InstanceEvents<Events, Result>[K] | undefined>;
97
92
  /** Tag this run; chainable. */
98
93
  tag(name: string): this;
package/dist/index.d.ts CHANGED
@@ -11,4 +11,4 @@ export { om } from "./core/om.ts";
11
11
  export { action } from "./core/action.ts";
12
12
  export { step } from "./core/step.ts";
13
13
  export { CancelledError, isCancelled } from "./foundation/CancelledError.ts";
14
- export type { Action, Activity, ActionContext, OmContext, Outcome } from "./core/types.ts";
14
+ export type { Action, Activity, ActionContext, OmContext } from "./core/types.ts";