omkit 0.2.2 → 0.4.1
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.
- package/README.md +66 -43
- package/dist/actions/browser.d.ts +28 -0
- package/dist/actions/browser.js +36 -0
- package/dist/actions/command.d.ts +11 -7
- package/dist/actions/command.js +16 -10
- package/dist/actions/index.d.ts +2 -0
- package/dist/actions/index.js +1 -0
- package/dist/actions/prompt.js +43 -17
- package/dist/cli/client/channel.d.ts +14 -0
- package/dist/cli/client/channel.js +48 -0
- package/dist/cli/client/check.d.ts +3 -0
- package/dist/cli/client/check.js +32 -0
- package/dist/cli/client/discovery.d.ts +8 -0
- package/dist/cli/client/discovery.js +163 -0
- package/dist/cli/client/index.d.ts +9 -0
- package/dist/cli/client/index.js +11 -0
- package/dist/cli/client/registry.d.ts +29 -0
- package/dist/cli/client/registry.js +1 -0
- package/dist/cli/client/runner.d.ts +15 -0
- package/dist/cli/client/runner.js +61 -0
- package/dist/cli/client/types.d.ts +53 -0
- package/dist/cli/client/types.js +1 -0
- package/dist/cli/commands/check.d.ts +6 -0
- package/dist/cli/commands/check.js +11 -0
- package/dist/cli/commands/init.d.ts +5 -0
- package/dist/cli/commands/init.js +54 -0
- package/dist/cli/commands/ls.d.ts +5 -0
- package/dist/cli/commands/ls.js +25 -0
- package/dist/cli/commands/run.d.ts +10 -0
- package/dist/cli/commands/run.js +12 -0
- package/dist/cli/commands/ui.d.ts +11 -0
- package/dist/cli/commands/ui.js +37 -0
- package/dist/cli/index.d.ts +9 -0
- package/dist/cli/index.js +110 -0
- package/dist/cli/ui/Spinner.d.ts +10 -0
- package/dist/cli/ui/Spinner.js +20 -0
- package/dist/cli/ui/app.d.ts +12 -0
- package/dist/cli/ui/app.js +95 -0
- package/dist/cli/ui/views/OmList.d.ts +9 -0
- package/dist/cli/ui/views/OmList.js +36 -0
- package/dist/cli/ui/views/RunView.d.ts +14 -0
- package/dist/cli/ui/views/RunView.js +47 -0
- package/dist/core/ActionRun.d.ts +2 -4
- package/dist/core/ActionRun.js +32 -36
- package/dist/core/ExecutionTree.d.ts +1 -0
- package/dist/core/ExecutionTree.js +25 -4
- package/dist/core/interaction.d.ts +63 -0
- package/dist/core/interaction.js +82 -0
- package/dist/core/step.js +2 -7
- package/dist/core/types.d.ts +10 -20
- package/dist/index.d.ts +1 -1
- package/dist/output/LiveRenderer.d.ts +7 -17
- package/dist/output/LiveRenderer.js +8 -82
- package/dist/output/milestones.d.ts +8 -0
- package/dist/output/milestones.js +39 -0
- package/package.json +9 -1
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* A tiny animated spinner — cycles braille frames on a timer. Self-contained (no `ink-spinner`
|
|
4
|
+
* dependency). The interval is unref'd so it never keeps the process alive on its own: it animates
|
|
5
|
+
* only while something else (the live run's IPC channel) holds the event loop open, and stops the
|
|
6
|
+
* instant the run is disposed — so it can't block the app's exit.
|
|
7
|
+
*/
|
|
8
|
+
export declare function Spinner({ label }: {
|
|
9
|
+
label?: string;
|
|
10
|
+
}): ReactElement;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Text } from "ink";
|
|
4
|
+
// Braille dots — a smooth ten-frame cycle that reads as motion in a terminal.
|
|
5
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
6
|
+
/**
|
|
7
|
+
* A tiny animated spinner — cycles braille frames on a timer. Self-contained (no `ink-spinner`
|
|
8
|
+
* dependency). The interval is unref'd so it never keeps the process alive on its own: it animates
|
|
9
|
+
* only while something else (the live run's IPC channel) holds the event loop open, and stops the
|
|
10
|
+
* instant the run is disposed — so it can't block the app's exit.
|
|
11
|
+
*/
|
|
12
|
+
export function Spinner({ label }) {
|
|
13
|
+
const [frame, setFrame] = useState(0);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const id = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 80);
|
|
16
|
+
id.unref?.();
|
|
17
|
+
return () => clearInterval(id);
|
|
18
|
+
}, []);
|
|
19
|
+
return (_jsxs(Text, { dimColor: true, children: [FRAMES[frame], label ? ` ${label}` : ""] }));
|
|
20
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
import type { OmkitClient, Verdict } from "../client/types.ts";
|
|
3
|
+
import type { DiscoveredOm } from "../client/registry.ts";
|
|
4
|
+
/** The interactive app: discover → list/search → run → live milestones + prompts. */
|
|
5
|
+
export declare function App({ client, onExit, oms: initialOms, }: {
|
|
6
|
+
client: OmkitClient;
|
|
7
|
+
/** Called with the run's verdict (if any) just before Ink unmounts, so the caller can print
|
|
8
|
+
* a durable summary — Ink erases its own frame on exit. */
|
|
9
|
+
onExit?: (verdict: Verdict | undefined) => void;
|
|
10
|
+
/** Oms discovered by the caller (pre-flight). When given, the app skips its own discovery. */
|
|
11
|
+
oms?: DiscoveredOm[];
|
|
12
|
+
}): ReactElement;
|
|
@@ -0,0 +1,95 @@
|
|
|
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, oms: initialOms, }) {
|
|
10
|
+
const { exit } = useApp();
|
|
11
|
+
const [oms, setOms] = useState(initialOms ?? []);
|
|
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
|
+
if (initialOms)
|
|
23
|
+
return; // provided by the caller's pre-flight discovery
|
|
24
|
+
let live = true;
|
|
25
|
+
void client.discover().then((r) => {
|
|
26
|
+
if (live)
|
|
27
|
+
setOms(r.oms);
|
|
28
|
+
});
|
|
29
|
+
return () => {
|
|
30
|
+
live = false;
|
|
31
|
+
};
|
|
32
|
+
}, [client, initialOms]);
|
|
33
|
+
const run = (om) => {
|
|
34
|
+
const s = client.run(om.file);
|
|
35
|
+
sessionRef.current = s;
|
|
36
|
+
s.on("log", (entry) => {
|
|
37
|
+
const rendered = format(entry);
|
|
38
|
+
if (rendered)
|
|
39
|
+
setLines((prev) => [...prev, rendered.text]);
|
|
40
|
+
});
|
|
41
|
+
s.on("prompt", (req) => setPrompt(req));
|
|
42
|
+
s.on("settled", (v) => {
|
|
43
|
+
setVerdict(v);
|
|
44
|
+
setPrompt(undefined);
|
|
45
|
+
});
|
|
46
|
+
// The single exit point. `result` resolves when the run ends — a natural completion (a build
|
|
47
|
+
// that settles on its own), a teardown after Ctrl+C, or a crash that closes the channel — and
|
|
48
|
+
// stays pending for a live daemon stack until Ctrl+C. Record the verdict, hand it to the caller
|
|
49
|
+
// so it can print the durable summary block, and unmount. Without this a self-completing run
|
|
50
|
+
// would show its verdict line but never exit or render the summary.
|
|
51
|
+
void s.result.then((v) => {
|
|
52
|
+
verdictRef.current = v;
|
|
53
|
+
setVerdict(v);
|
|
54
|
+
onExit?.(v);
|
|
55
|
+
exit();
|
|
56
|
+
});
|
|
57
|
+
setSession(s);
|
|
58
|
+
};
|
|
59
|
+
const answer = (value) => {
|
|
60
|
+
if (session && prompt)
|
|
61
|
+
session.answer(prompt.id, value);
|
|
62
|
+
setPrompt(undefined);
|
|
63
|
+
};
|
|
64
|
+
// Ctrl+C: in raw mode this is a keystroke, not a signal. Cancel the run and wait for the child
|
|
65
|
+
// to tear down and report its verdict, then quit — leaving the durable summary to `onExit`.
|
|
66
|
+
//
|
|
67
|
+
// Repeated presses are a deliberate no-op (see the `tearing` guard below): the child owns the
|
|
68
|
+
// teardown and is guaranteed to report `settled` within its grace window, so we let it finish
|
|
69
|
+
// rather than force-exiting. This mirrors the normal om run, where SIGINT is caught with
|
|
70
|
+
// `process.on` and smashing Ctrl+C just re-triggers an idempotent teardown — the process never
|
|
71
|
+
// exits before the tree is reaped. Force-exiting here (the old second-press escape hatch) killed
|
|
72
|
+
// the parent mid-teardown and orphaned the child's process tree (vite, watchers, Chrome).
|
|
73
|
+
useInput((input, key) => {
|
|
74
|
+
if (!(key.ctrl && input === "c"))
|
|
75
|
+
return;
|
|
76
|
+
const s = sessionRef.current;
|
|
77
|
+
// Nothing running or already settled → quit now.
|
|
78
|
+
if (!s || verdictRef.current) {
|
|
79
|
+
onExit?.(verdictRef.current);
|
|
80
|
+
exit();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Already tearing down → ignore. The child is reaping its tree and will report `settled` (or
|
|
84
|
+
// the channel closes and resolves a failed verdict); either way `result` resolves and the
|
|
85
|
+
// handler wired in `run()` records the verdict and unmounts. Just request the teardown here.
|
|
86
|
+
if (tearingRef.current)
|
|
87
|
+
return;
|
|
88
|
+
tearingRef.current = true;
|
|
89
|
+
setTearing(true);
|
|
90
|
+
s.cancel();
|
|
91
|
+
});
|
|
92
|
+
if (!session)
|
|
93
|
+
return _jsx(OmList, { oms: oms, onSelect: run });
|
|
94
|
+
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] }));
|
|
95
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/core/ActionRun.d.ts
CHANGED
|
@@ -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
|
|
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<
|
|
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
|
package/dist/core/ActionRun.js
CHANGED
|
@@ -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
|
-
|
|
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,26 +113,32 @@ export class ActionRun {
|
|
|
111
113
|
this.emitter.listen(key, handler);
|
|
112
114
|
}
|
|
113
115
|
once(key) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
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) => {
|
|
124
124
|
let done = false;
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
resolve(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
+
return;
|
|
136
|
+
// Awaiting the `error` event hands back the error; any other gate rejects on failure.
|
|
137
|
+
if (key === "error")
|
|
138
|
+
resolve(error);
|
|
139
|
+
else
|
|
140
|
+
reject(error);
|
|
141
|
+
});
|
|
134
142
|
});
|
|
135
143
|
}
|
|
136
144
|
tag(name) {
|
|
@@ -150,16 +158,6 @@ export class ActionRun {
|
|
|
150
158
|
this.armedExec = cacheGate(inner, targets);
|
|
151
159
|
return this;
|
|
152
160
|
}
|
|
153
|
-
handleFailure(handler) {
|
|
154
|
-
this.failureHandler = handler;
|
|
155
|
-
this.observed = true;
|
|
156
|
-
// Attached after an `await` on a body that has already failed: deliver the
|
|
157
|
-
// already-recorded failure now (it won't fail again). Cancellation is status
|
|
158
|
-
// "cancelled", not "failed", so a cancelled node never runs the handler.
|
|
159
|
-
if (this.status === "failed")
|
|
160
|
-
handler(this.error);
|
|
161
|
-
return this;
|
|
162
|
-
}
|
|
163
161
|
cancel() {
|
|
164
162
|
this.noteCancelled(); // a direct cancel of this activity
|
|
165
163
|
this.controller.abort();
|
|
@@ -295,11 +293,9 @@ export class ActionRun {
|
|
|
295
293
|
// signal.aborted, which is why the check below uses `cascadeCancelled`, not
|
|
296
294
|
// `signal.aborted`, to tell an unhandled fault from a run/ancestor teardown.
|
|
297
295
|
this.controller.abort();
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
//
|
|
301
|
-
// read, no `on("error")`) and we weren't caught in an ancestor's teardown, it's
|
|
302
|
-
// unhandled — tell the tree to tear the run down and record the fault.
|
|
296
|
+
// If, one microtask on, no one is positioned to receive this error (no `.result`/`.ref`/
|
|
297
|
+
// `.once` read, no `on("error")`, no `.result.catch`) and we weren't caught in an ancestor's
|
|
298
|
+
// teardown, it's unhandled — tell the tree to tear the run down and record the fault.
|
|
303
299
|
// `sysEmit("error")` above already ran, so a live error handler has set `observed`.
|
|
304
300
|
queueMicrotask(() => {
|
|
305
301
|
const observed = this.observed || (this.refObserved && !this.attached);
|
|
@@ -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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
}
|