omkit 0.0.14 → 0.1.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 (110) hide show
  1. package/README.md +110 -97
  2. package/dist/actions/chrome-page.d.ts +12 -29
  3. package/dist/actions/chrome-page.js +19 -32
  4. package/dist/actions/command.d.ts +8 -41
  5. package/dist/actions/command.js +15 -82
  6. package/dist/actions/healthcheck.d.ts +3 -11
  7. package/dist/actions/healthcheck.js +8 -22
  8. package/dist/actions/index.d.ts +6 -8
  9. package/dist/actions/index.js +4 -6
  10. package/dist/actions/prompt.d.ts +5 -19
  11. package/dist/actions/prompt.js +10 -31
  12. package/dist/actions/tail-log.d.ts +7 -21
  13. package/dist/actions/tail-log.js +13 -34
  14. package/dist/actions/until-log.d.ts +4 -7
  15. package/dist/actions/until-log.js +3 -3
  16. package/dist/actions/watch-dir.d.ts +5 -19
  17. package/dist/actions/watch-dir.js +13 -28
  18. package/dist/actions/watch.d.ts +4 -12
  19. package/dist/actions/watch.js +9 -16
  20. package/dist/core/ActionRun.d.ts +97 -0
  21. package/dist/core/ActionRun.js +266 -0
  22. package/dist/core/ExecutionTree.d.ts +88 -0
  23. package/dist/core/ExecutionTree.js +320 -0
  24. package/dist/core/action.d.ts +6 -0
  25. package/dist/core/action.js +72 -0
  26. package/dist/core/context.d.ts +9 -0
  27. package/dist/core/context.js +8 -0
  28. package/dist/core/om.d.ts +9 -0
  29. package/dist/core/om.js +25 -0
  30. package/dist/core/step.d.ts +9 -0
  31. package/dist/core/step.js +18 -0
  32. package/dist/core/types.d.ts +128 -0
  33. package/dist/foundation/ActionRef.d.ts +18 -0
  34. package/dist/foundation/ActionRef.js +5 -0
  35. package/dist/{utils → foundation}/AsyncQueue.d.ts +2 -4
  36. package/dist/{utils → foundation}/AsyncQueue.js +6 -19
  37. package/dist/foundation/CancelledError.d.ts +10 -0
  38. package/dist/foundation/CancelledError.js +13 -0
  39. package/dist/foundation/Deferred.d.ts +8 -0
  40. package/dist/foundation/Deferred.js +10 -0
  41. package/dist/foundation/LogEntry.d.ts +29 -0
  42. package/dist/{orchestration/events → foundation}/events.d.ts +2 -11
  43. package/dist/{orchestration/events → foundation}/events.js +1 -7
  44. package/dist/{utils → foundation}/format.d.ts +3 -4
  45. package/dist/{utils → foundation}/format.js +12 -4
  46. package/dist/foundation/fsSafe.d.ts +7 -0
  47. package/dist/foundation/fsSafe.js +7 -0
  48. package/dist/foundation/ids.d.ts +9 -0
  49. package/dist/foundation/ids.js +10 -0
  50. package/dist/index.d.ts +11 -22
  51. package/dist/index.js +10 -30
  52. package/dist/output/LiveRenderer.d.ts +13 -0
  53. package/dist/output/LiveRenderer.js +56 -0
  54. package/dist/output/console/ConsoleCapture.d.ts +20 -0
  55. package/dist/output/console/ConsoleCapture.js +47 -0
  56. package/dist/output/folder/RunFolder.d.ts +11 -22
  57. package/dist/output/folder/RunFolder.js +18 -36
  58. package/dist/output/log/LogStore.d.ts +22 -0
  59. package/dist/output/log/LogStore.js +56 -0
  60. package/dist/output/log/RawStream.d.ts +11 -0
  61. package/dist/output/log/RawStream.js +18 -0
  62. package/dist/output/snapshot/SnapshotStore.d.ts +6 -43
  63. package/dist/output/snapshot/SnapshotStore.js +12 -52
  64. package/dist/output/writers/NodeLogWriter.d.ts +8 -0
  65. package/dist/output/writers/NodeLogWriter.js +36 -0
  66. package/dist/output/writers/ResultWriter.d.ts +3 -0
  67. package/dist/output/writers/ResultWriter.js +5 -0
  68. package/dist/output/writers/RollupWriter.d.ts +9 -0
  69. package/dist/output/writers/RollupWriter.js +29 -0
  70. package/dist/output/writers/entryLine.d.ts +10 -0
  71. package/dist/output/writers/entryLine.js +39 -0
  72. package/dist/output/writers/views.d.ts +38 -0
  73. package/dist/output/writers/views.js +6 -0
  74. package/dist/system/fs/FolderCache.d.ts +6 -34
  75. package/dist/system/fs/FolderCache.js +7 -38
  76. package/dist/system/proc.d.ts +51 -0
  77. package/dist/system/proc.js +148 -0
  78. package/dist/system/procRegistry.d.ts +52 -0
  79. package/dist/system/procRegistry.js +117 -0
  80. package/package.json +3 -1
  81. package/dist/orchestration/action/action.d.ts +0 -23
  82. package/dist/orchestration/action/action.js +0 -256
  83. package/dist/orchestration/action/types.d.ts +0 -182
  84. package/dist/orchestration/assert.d.ts +0 -22
  85. package/dist/orchestration/assert.js +0 -26
  86. package/dist/orchestration/spin/SpinHost.d.ts +0 -109
  87. package/dist/orchestration/spin/SpinHost.js +0 -337
  88. package/dist/orchestration/spin/spin.d.ts +0 -24
  89. package/dist/orchestration/spin/spin.js +0 -57
  90. package/dist/orchestration/spin/types.d.ts +0 -63
  91. package/dist/output/Output.d.ts +0 -24
  92. package/dist/output/Output.js +0 -27
  93. package/dist/output/index.d.ts +0 -11
  94. package/dist/output/index.js +0 -11
  95. package/dist/output/log/LogRenderer.d.ts +0 -73
  96. package/dist/output/log/LogRenderer.js +0 -133
  97. package/dist/output/log/LogsCollector.d.ts +0 -79
  98. package/dist/output/log/LogsCollector.js +0 -141
  99. package/dist/output/log/RunLog.d.ts +0 -47
  100. package/dist/output/log/RunLog.js +0 -142
  101. package/dist/output/log/console-capture.d.ts +0 -24
  102. package/dist/output/log/console-capture.js +0 -53
  103. package/dist/system/fs/streams.d.ts +0 -11
  104. package/dist/system/fs/streams.js +0 -14
  105. package/dist/system/process/process.d.ts +0 -35
  106. package/dist/system/process/process.js +0 -104
  107. package/dist/utils/serialize.d.ts +0 -2
  108. package/dist/utils/serialize.js +0 -12
  109. /package/dist/{orchestration/action → core}/types.js +0 -0
  110. /package/dist/{orchestration/spin/types.js → foundation/LogEntry.js} +0 -0
@@ -0,0 +1,148 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import os from "node:os";
3
+ import { $, usePowerShell } from "zx";
4
+ import { ProcRegistry, winSnapshot } from "./procRegistry.js";
5
+ // zx defaults to bash, absent on a stock Windows box — use PowerShell there.
6
+ if (os.platform() === "win32")
7
+ usePowerShell();
8
+ /**
9
+ * Force-kill a process **and its whole tree, synchronously**.
10
+ *
11
+ * On POSIX the child is spawned as its own process-group leader (see createProc), so
12
+ * `kill(-pid)` reaps the whole group in one call — every descendant, re-parented or not.
13
+ *
14
+ * On Windows there are no process groups and we can't detach (a console-less child breaks
15
+ * npm/vite — see createProc), so a mashed Ctrl+C sent to the shared console can make an
16
+ * intermediate shell (powershell/npm/npx) exit and orphan its real leaf (vite,
17
+ * chromedriver.exe) before teardown reaps it. `taskkill /F /T` force-kills the live tree
18
+ * by PID, but a leaf whose intermediate already exited can't be reached via `/T` — that
19
+ * mash-Ctrl+C orphan is a known Windows edge, mitigated by a port/PID sweep on teardown
20
+ * rather than by detaching. Best-effort: an already-dead PID is ignored.
21
+ */
22
+ export const killTree = (pid) => {
23
+ if (process.platform === "win32") {
24
+ try {
25
+ execFileSync("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore" });
26
+ }
27
+ catch {
28
+ // already gone, access denied, or taskkill missing — nothing more to do
29
+ }
30
+ }
31
+ else {
32
+ try {
33
+ process.kill(-pid, "SIGKILL");
34
+ }
35
+ catch {
36
+ // already gone
37
+ }
38
+ }
39
+ };
40
+ // A single --inspect* flag in NODE_OPTIONS, matched only when it stands alone (so it
41
+ // never eats a prefix like `--inspector-foo`): --inspect / --inspect-brk / --inspect-port
42
+ // / --inspect-publish-uid, each with an optional `=value`.
43
+ const INSPECT_FLAG = /--inspect(?:-brk|-port|-publish-uid)?(?:=\S+)?(?=\s|$)/g;
44
+ // `--require <arg>` whose arg points at VS Code's js-debug auto-attach bootloader.
45
+ const VSCODE_BOOTLOADER_REQUIRE = /--require\s+("[^"]*"|\S+)/g;
46
+ /**
47
+ * A copy of `env` with the orchestrator's debugger stripped, so a spawned child runs
48
+ * clean instead of inheriting our inspector. Removes `--inspect*` flags from
49
+ * `NODE_OPTIONS`, drops VS Code auto-attach's injected bootloader `--require` and its
50
+ * `VSCODE_INSPECTOR_OPTIONS` handshake, and deletes `NODE_OPTIONS` entirely if nothing
51
+ * else remains. A legitimate non-debug `--require` and any other options are preserved.
52
+ * Does not mutate the input.
53
+ */
54
+ export const debuggerFreeEnv = (env) => {
55
+ const clean = { ...env };
56
+ delete clean.VSCODE_INSPECTOR_OPTIONS;
57
+ const nodeOptions = clean.NODE_OPTIONS;
58
+ if (nodeOptions !== undefined) {
59
+ const stripped = nodeOptions
60
+ .replace(VSCODE_BOOTLOADER_REQUIRE, (whole, arg) => /js-debug|bootloader/i.test(arg) ? "" : whole)
61
+ .replace(INSPECT_FLAG, "")
62
+ .replace(/\s+/g, " ")
63
+ .trim();
64
+ if (stripped)
65
+ clean.NODE_OPTIONS = stripped;
66
+ else
67
+ delete clean.NODE_OPTIONS;
68
+ }
69
+ return clean;
70
+ };
71
+ /**
72
+ * The run-wide orphan reaper. `createProc` registers each shell it spawns (Windows only);
73
+ * the ExecutionTree calls `procRegistry.sweep()` at teardown to reap any that a mashed
74
+ * Ctrl+C orphaned. A no-op in practice off Windows (nothing is tracked there).
75
+ */
76
+ export const procRegistry = new ProcRegistry(winSnapshot, killTree);
77
+ /**
78
+ * Build a {@link Proc} bound to a log sink and abort signal. The framework injects
79
+ * one per action as `ctx.proc`, wired to that action's log and the run's signal, so
80
+ * actions just describe the command — output streams in, the child dies on teardown.
81
+ */
82
+ export const createProc = (sink, signal) => (name, opts) => {
83
+ const { inheritDebugger, ...zxOpts } = opts ?? {};
84
+ // Children run debugger-free by default: our inspector (a VS Code auto-attach
85
+ // NODE_OPTIONS, or a plain --inspect) would otherwise be inherited by every child,
86
+ // spamming "Debugger attached", stalling teardown on "waiting for the debugger to
87
+ // disconnect" (so ports stay held into the next run), and hiding the real error.
88
+ // Opt in per-proc with `inheritDebugger`, or globally with OMKIT_INHERIT_DEBUGGER.
89
+ const inherit = inheritDebugger ?? Boolean(process.env.OMKIT_INHERIT_DEBUGGER);
90
+ const baseEnv = zxOpts.env ?? process.env;
91
+ // quiet: the log owns all output; zx must not also echo to the terminal.
92
+ // detached (POSIX only): give the child its own process group so `killTree` can reap
93
+ // the whole group atomically with `kill(-pid)`. On POSIX this also isolates it from the
94
+ // terminal's Ctrl+C (only the foreground group is signalled). We do NOT detach on
95
+ // Windows: there `detached` means DETACHED_PROCESS (no console), and console commands
96
+ // like npm/vite then exit immediately with no output — so the Windows Ctrl+C-orphan
97
+ // edge is handled another way, not by detaching.
98
+ const $$ = $({
99
+ quiet: true,
100
+ detached: process.platform !== "win32",
101
+ ...zxOpts,
102
+ env: (inherit ? { ...baseEnv } : debuggerFreeEnv(baseEnv)),
103
+ });
104
+ return (pieces, ...args) => {
105
+ const child = $$(pieces, ...args);
106
+ const cwd = opts?.cwd ?? process.cwd();
107
+ sink(name, "event", `${name} · start · ${child.cmd} · in ${cwd}`);
108
+ void streamLines(child.stdout, (line) => sink(name, "info", line));
109
+ void streamLines(child.stderr, (line) => sink(name, "error", line));
110
+ // On Windows (where we can't detach) track this shell's pid, so the run's teardown
111
+ // sweep can reap a grandchild orphaned by a mashed Ctrl+C — see procRegistry.
112
+ if (process.platform === "win32" && typeof child.pid === "number") {
113
+ procRegistry.track(child.pid);
114
+ }
115
+ // Kill the child's tree on teardown; unhook once it settles on its own.
116
+ let finished = false;
117
+ const onAbort = () => {
118
+ if (!finished && typeof child.pid === "number")
119
+ killTree(child.pid);
120
+ };
121
+ signal.addEventListener("abort", onAbort, { once: true });
122
+ const done = () => {
123
+ finished = true;
124
+ signal.removeEventListener("abort", onAbort);
125
+ if (typeof child.pid === "number")
126
+ procRegistry.untrack(child.pid);
127
+ };
128
+ void child.then(done, done);
129
+ return child;
130
+ };
131
+ };
132
+ /** Split a byte/string stream into lines, calling `onLine` for each non-empty one. */
133
+ async function streamLines(stream, onLine) {
134
+ let buffer = "";
135
+ for await (const chunk of stream) {
136
+ buffer += String(chunk);
137
+ let nl;
138
+ while ((nl = buffer.indexOf("\n")) !== -1) {
139
+ const line = buffer.slice(0, nl).replace(/\r$/, "");
140
+ buffer = buffer.slice(nl + 1);
141
+ if (line)
142
+ onLine(line);
143
+ }
144
+ }
145
+ const tail = buffer.replace(/\r$/, "");
146
+ if (tail)
147
+ onLine(tail);
148
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * One process in a snapshot: its id, its parent's id, and an opaque identity token
3
+ * (its creation time) that distinguishes a live process from a later reuse of its pid.
4
+ */
5
+ export interface ProcInfo {
6
+ pid: number;
7
+ ppid: number;
8
+ id: string;
9
+ }
10
+ /** Takes a snapshot of every live process. Injected so it's swappable + unit-testable. */
11
+ export type Snapshot = () => ProcInfo[];
12
+ /**
13
+ * A safety net for the Windows Ctrl+C-orphan edge (see {@link import("./proc.ts").killTree}).
14
+ * We only ever spawn a *shell*, so `killTree` on the shell's pid can miss a re-parented
15
+ * grandchild (vite, chromedriver) when a mashed Ctrl+C kills the intermediate first.
16
+ *
17
+ * This registry closes that gap: it polls the process table while a tracked root's tree is
18
+ * intact and **accumulates the descendant pids** (with their identity). On teardown, `sweep`
19
+ * force-kills every recorded descendant that is *still alive with the same identity* — so an
20
+ * orphan is reaped even after it re-parented, while a since-reused pid (identity changed) or
21
+ * a cleanly-exited pid is left alone. Roots themselves stay `killTree`'s job.
22
+ *
23
+ * Pure and platform-agnostic; the caller decides when it applies (POSIX reaps via the process
24
+ * group, so `proc.ts` only tracks on Windows).
25
+ */
26
+ export declare class ProcRegistry {
27
+ private readonly snapshot;
28
+ private readonly kill;
29
+ private readonly intervalMs;
30
+ private readonly roots;
31
+ private readonly known;
32
+ private timer;
33
+ constructor(snapshot: Snapshot, kill: (pid: number) => void, intervalMs?: number);
34
+ /** Start tracking a spawned root (shell) pid; begins polling if not already. */
35
+ track(pid: number): void;
36
+ /** Stop tracking a root that settled on its own. Already-recorded descendants are kept. */
37
+ untrack(pid: number): void;
38
+ /** One poll tick: record every current descendant of every tracked root. */
39
+ poll(): void;
40
+ /**
41
+ * Teardown sweep: kill every recorded descendant still alive under its original identity.
42
+ * Stops polling and clears state, so it's safe to call once at finalize.
43
+ */
44
+ sweep(): void;
45
+ private stop;
46
+ }
47
+ /**
48
+ * Default Windows snapshot: one `Get-CimInstance Win32_Process` call, projected to
49
+ * `{ pid, ppid, id }` where `id` is the process creation time (ticks) — stable per process,
50
+ * different across a pid reuse. Best-effort: any failure yields an empty snapshot.
51
+ */
52
+ export declare const winSnapshot: Snapshot;
@@ -0,0 +1,117 @@
1
+ import { execFileSync } from "node:child_process";
2
+ /**
3
+ * A safety net for the Windows Ctrl+C-orphan edge (see {@link import("./proc.ts").killTree}).
4
+ * We only ever spawn a *shell*, so `killTree` on the shell's pid can miss a re-parented
5
+ * grandchild (vite, chromedriver) when a mashed Ctrl+C kills the intermediate first.
6
+ *
7
+ * This registry closes that gap: it polls the process table while a tracked root's tree is
8
+ * intact and **accumulates the descendant pids** (with their identity). On teardown, `sweep`
9
+ * force-kills every recorded descendant that is *still alive with the same identity* — so an
10
+ * orphan is reaped even after it re-parented, while a since-reused pid (identity changed) or
11
+ * a cleanly-exited pid is left alone. Roots themselves stay `killTree`'s job.
12
+ *
13
+ * Pure and platform-agnostic; the caller decides when it applies (POSIX reaps via the process
14
+ * group, so `proc.ts` only tracks on Windows).
15
+ */
16
+ export class ProcRegistry {
17
+ snapshot;
18
+ kill;
19
+ intervalMs;
20
+ roots = new Set();
21
+ known = new Map(); // descendant pid → identity, accumulated
22
+ timer;
23
+ constructor(snapshot, kill,
24
+ // A snapshot costs ~½s (powershell startup), and daemon leaves are long-lived and stable,
25
+ // so poll unhurriedly — a mashed Ctrl+C always lands well after the platform is up, by
26
+ // which point the leaves have been recorded. Swap `winSnapshot` for a faster backend
27
+ // (wmic / a native process-list) if this churn ever matters.
28
+ intervalMs = 5000) {
29
+ this.snapshot = snapshot;
30
+ this.kill = kill;
31
+ this.intervalMs = intervalMs;
32
+ }
33
+ /** Start tracking a spawned root (shell) pid; begins polling if not already. */
34
+ track(pid) {
35
+ this.roots.add(pid);
36
+ if (!this.timer) {
37
+ this.timer = setInterval(() => this.poll(), this.intervalMs);
38
+ this.timer.unref?.();
39
+ }
40
+ }
41
+ /** Stop tracking a root that settled on its own. Already-recorded descendants are kept. */
42
+ untrack(pid) {
43
+ this.roots.delete(pid);
44
+ if (this.roots.size === 0)
45
+ this.stop();
46
+ }
47
+ /** One poll tick: record every current descendant of every tracked root. */
48
+ poll() {
49
+ if (this.roots.size === 0)
50
+ return;
51
+ const byParent = new Map();
52
+ for (const p of this.snapshot()) {
53
+ const siblings = byParent.get(p.ppid);
54
+ if (siblings)
55
+ siblings.push(p);
56
+ else
57
+ byParent.set(p.ppid, [p]);
58
+ }
59
+ const stack = [...this.roots];
60
+ const seen = new Set();
61
+ while (stack.length > 0) {
62
+ const parent = stack.pop();
63
+ for (const child of byParent.get(parent) ?? []) {
64
+ if (seen.has(child.pid))
65
+ continue;
66
+ seen.add(child.pid);
67
+ this.known.set(child.pid, child.id);
68
+ stack.push(child.pid);
69
+ }
70
+ }
71
+ }
72
+ /**
73
+ * Teardown sweep: kill every recorded descendant still alive under its original identity.
74
+ * Stops polling and clears state, so it's safe to call once at finalize.
75
+ */
76
+ sweep() {
77
+ this.stop();
78
+ if (this.known.size === 0) {
79
+ this.roots.clear();
80
+ return;
81
+ }
82
+ const current = new Map(this.snapshot().map((p) => [p.pid, p.id]));
83
+ for (const [pid, id] of this.known) {
84
+ if (current.get(pid) === id)
85
+ this.kill(pid);
86
+ }
87
+ this.known.clear();
88
+ this.roots.clear();
89
+ }
90
+ stop() {
91
+ if (this.timer) {
92
+ clearInterval(this.timer);
93
+ this.timer = undefined;
94
+ }
95
+ }
96
+ }
97
+ /**
98
+ * Default Windows snapshot: one `Get-CimInstance Win32_Process` call, projected to
99
+ * `{ pid, ppid, id }` where `id` is the process creation time (ticks) — stable per process,
100
+ * different across a pid reuse. Best-effort: any failure yields an empty snapshot.
101
+ */
102
+ export const winSnapshot = () => {
103
+ try {
104
+ const out = execFileSync("powershell", [
105
+ "-NoProfile",
106
+ "-NonInteractive",
107
+ "-Command",
108
+ "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,@{n='I';e={ if ($_.CreationDate) { $_.CreationDate.Ticks } else { 0 } }} | ConvertTo-Json -Compress",
109
+ ], { encoding: "utf8", windowsHide: true, maxBuffer: 32 * 1024 * 1024 });
110
+ const parsed = JSON.parse(out);
111
+ const rows = Array.isArray(parsed) ? parsed : [parsed];
112
+ return rows.map((r) => ({ pid: r.ProcessId, ppid: r.ParentProcessId, id: String(r.I) }));
113
+ }
114
+ catch {
115
+ return [];
116
+ }
117
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omkit",
3
- "version": "0.0.14",
3
+ "version": "0.1.0",
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",
@@ -24,6 +24,7 @@
24
24
  "dev": "tsc --watch",
25
25
  "clean": "rimraf dist",
26
26
  "typecheck": "tsc --noEmit",
27
+ "lint": "eslint src",
27
28
  "prepublishOnly": "npm run clean && npm run build"
28
29
  },
29
30
  "author": "Dimitar Mihaylov",
@@ -48,6 +49,7 @@
48
49
  "developer-tools"
49
50
  ],
50
51
  "dependencies": {
52
+ "patch-console": "^2.0.0",
51
53
  "playwright-core": "^1.49.0",
52
54
  "zx": "^8.8.5"
53
55
  },
@@ -1,23 +0,0 @@
1
- import type { ActionBuilder, AnyActionInstance, NoEvents } from "./types.ts";
2
- /**
3
- * If `value` is some action instance's `.ref` promise, the instance that owns it
4
- * (the producer of that handle); otherwise `undefined`. Used by the run to verify
5
- * a `.ref` dependency was launched before the action that consumes it.
6
- */
7
- export declare const producerOfRef: (value: unknown) => AnyActionInstance | undefined;
8
- /**
9
- * Defines an action. Always returns a builder; provide the implementation via
10
- * `.run(…)`, optionally after declaring events and/or a handle:
11
- *
12
- * action("Build").run(async (ctx, opts) => { … }) // no declared events / handle
13
- * action("Watch") // typed events + handle
14
- * .emits<{ create: string }>()
15
- * .ref<{ rescan(): void }>()
16
- * .run((ctx, opts) => { ctx.emit(…); ctx.attach(…); … })
17
- *
18
- * `.run(…)` yields a callable that, when invoked, constructs an
19
- * {@link ActionInstance} (deferred — the run executes it). Instances expose
20
- * chainable `.on` / `.once` (declared events plus `done`/`error`); the attached
21
- * handle is awaited via `instance.ref`.
22
- */
23
- export declare function action(name: string): ActionBuilder<NoEvents, void>;
@@ -1,256 +0,0 @@
1
- import { events } from "../events/events.js";
2
- import { createProc } from "../../system/process/process.js";
3
- import { FolderCache } from "../../system/fs/FolderCache.js";
4
- // Maps an instance's `.ref` promise back to the instance that produces it, so the
5
- // run can catch a consumer launched before (or without) its `.ref` producer —
6
- // which would otherwise await a handle that never arrives and hang silently.
7
- const refOwners = new WeakMap();
8
- /**
9
- * If `value` is some action instance's `.ref` promise, the instance that owns it
10
- * (the producer of that handle); otherwise `undefined`. Used by the run to verify
11
- * a `.ref` dependency was launched before the action that consumes it.
12
- */
13
- export const producerOfRef = (value) => typeof value === "object" && value !== null ? refOwners.get(value) : undefined;
14
- /**
15
- * A constructed-but-not-yet-run action — everything a single launch needs, in one
16
- * object: its own event bus, its set-once handle (`ref`) and Outcome (`done`), and
17
- * the `start` that runs the impl and settles them. Self-contained: `start()`
18
- * resolves with the {@link Outcome} and **never rejects** (a failing action is
19
- * data, not a throw), so a launcher just awaits `.done`.
20
- */
21
- class ActionRun {
22
- name;
23
- args;
24
- /** The attached handle, awaited via `.ref` (settled by `attach`, or by `start` on settle). */
25
- ref;
26
- /** The action's Outcome — resolves (never rejects) once it settles. */
27
- done;
28
- exec;
29
- // The bus covers declared + system events; `ctx.emit` is narrowed to the declared
30
- // ones, while the framework emits attached/done/error through the same bus.
31
- emitter;
32
- handle;
33
- settled;
34
- attached = false;
35
- constructor(name, exec, args) {
36
- this.name = name;
37
- this.exec = exec;
38
- this.args = args;
39
- this.emitter = events(name);
40
- this.handle = ActionRun.defer();
41
- this.settled = ActionRun.defer();
42
- this.ref = this.handle.promise;
43
- this.done = this.settled.promise;
44
- // Most actions have a `void` handle and no one awaits `ref`; mark the promise
45
- // handled so a settle-time rejection on such an instance doesn't surface as an
46
- // unhandled rejection. Real awaiters of `ref` still receive the rejection.
47
- void this.handle.promise.catch(() => { });
48
- // Register this instance as the producer of its `.ref`, so the run can catch a
49
- // consumer built with this `.ref` that gets launched before this instance.
50
- refOwners.set(this.ref, this);
51
- }
52
- start(system) {
53
- // `ctx.emit` is the declared-events view of the same bus.
54
- const emit = this.emitter.emit;
55
- // The bus is pure pub/sub; logging each emit onto the timeline is layered here,
56
- // through this action's *scoped* logger — so an emit's line inherits the action's
57
- // path (`parent › child › event`) just like its other output, and the bus stays
58
- // free of any global. Registered once per run of this instance.
59
- this.emitter.onAny((key, payload) => ActionRun.logEmit(system, this.name, String(key), payload));
60
- // `Promise.resolve().then` normalizes a sync result and routes a sync throw to
61
- // the `error` path, so exec may be sync or async.
62
- return Promise.resolve()
63
- .then(() => this.exec({
64
- ...system,
65
- emit,
66
- attach: (value) => this.attach(value),
67
- proc: createProc(system.logs, system.signal, system.output.snapshots),
68
- artifactsFolder: system.output.folder.artifacts(),
69
- snapshot: (name, value) => system.output.snapshots.snapshot(name, value),
70
- }, ...this.args))
71
- .then((result) => {
72
- const outcome = { ok: true, value: result };
73
- this.settled.resolve(outcome);
74
- this.systemEmit("done", outcome);
75
- // Settle `ref` if the action finished without attaching, so awaiters
76
- // resolve (with `undefined` for the default void handle) rather than
77
- // hang. Idempotent — a no-op when a handle was already attached.
78
- this.handle.resolve(undefined);
79
- return outcome;
80
- }, (error) => {
81
- const exitCode = ActionRun.exitCodeOf(error);
82
- const outcome = exitCode === undefined ? { ok: false, error } : { ok: false, error, exitCode };
83
- // Emit `error` (for `.on("error")` handlers), then settle `done` with the
84
- // Outcome — the canonical failure signal that `.done`/`once` read.
85
- this.systemEmit("error", error);
86
- this.settled.resolve(outcome);
87
- this.systemEmit("done", outcome);
88
- // The action failed, so its handle will never arrive — reject `ref` with
89
- // the same error instead of leaving awaiters hung. No-op if already attached.
90
- this.handle.reject(error);
91
- // Resolve (never rethrow): failure is the Outcome, not a rejection.
92
- return outcome;
93
- });
94
- }
95
- on(key, handler) {
96
- this.emitter.listen(key, handler);
97
- }
98
- once(key) {
99
- // `done` always fires with the Outcome and never "fails" the await, so it reads
100
- // straight from the settled promise; other keys race via awaitEvent (which
101
- // resolves `undefined` if the action settles before the event).
102
- return (key === "done" ? this.settled.promise : ActionRun.awaitEvent(this.emitter, key));
103
- }
104
- withCache(...paths) {
105
- // Validate + canonicalize eagerly here (at the call site), not lazily inside the
106
- // run — a relative path fails fast where it's written, and the resolved paths
107
- // give a stable cache key regardless of spelling.
108
- const targets = FolderCache.resolvePaths(paths);
109
- const { name } = this;
110
- // Wrap this instance in a fresh one (reusing the engine for its own
111
- // emitter/ref/on/once) that fingerprints `paths` and either skips or
112
- // runs+records. The wrapper keeps this action's `name`, so log lines and the
113
- // `launch <name>` line are unchanged; it forwards its injected logs/signal
114
- // straight into this inner instance's `start`.
115
- return action(name).run(async (ctx) => {
116
- const fp = await FolderCache.fingerprint(targets);
117
- if ((await FolderCache.read(targets)) === fp) {
118
- ctx.logs.append({ source: name, level: "info", message: "cached, skipping" });
119
- return undefined;
120
- }
121
- const outcome = await this.start({
122
- logs: ctx.logs,
123
- signal: ctx.signal,
124
- nod: ctx.nod,
125
- output: ctx.output,
126
- assert: ctx.assert,
127
- });
128
- // Re-throw the inner failure so the wrapper fails too (the framework re-wraps
129
- // it into this wrapper's own Outcome); only record on success.
130
- if (!outcome.ok)
131
- throw outcome.error;
132
- await FolderCache.write(targets, fp);
133
- return outcome.value;
134
- })();
135
- }
136
- /** One-time lifecycle moment: resolve `ref` and emit `attached`. Guarded (second attach no-ops). */
137
- attach(value) {
138
- if (this.attached)
139
- return;
140
- this.attached = true;
141
- this.handle.resolve(value);
142
- this.systemEmit("attached");
143
- }
144
- /** The framework's own emits (loosely typed — sidesteps EmitArgs for void results). */
145
- systemEmit(key, payload) {
146
- this.emitter.emit(key, payload);
147
- }
148
- static defer() {
149
- let resolve;
150
- let reject;
151
- const promise = new Promise((res, rej) => {
152
- resolve = res;
153
- reject = rej;
154
- });
155
- return { promise, resolve, reject };
156
- }
157
- /** Duck-type an exit code off a thrown error (zx's ProcessOutput, command's exit error). */
158
- static exitCodeOf(error) {
159
- const code = error?.exitCode;
160
- return typeof code === "number" ? code : undefined;
161
- }
162
- /**
163
- * Log one emit onto the run timeline, through the action's scoped `logs` so the
164
- * line carries its path. Fields are `·`-delimited — action name · key · payload.
165
- * A string payload rides inline; a richer one is written to a snapshot file and
166
- * linked (`→ <rel>`) so the durable record keeps it without bloating the line.
167
- * This is the sole place emits become log lines — the bus itself does no logging.
168
- */
169
- static logEmit(system, name, key, payload) {
170
- const fields = [name, key];
171
- if (typeof payload === "string") {
172
- fields.push(payload);
173
- }
174
- else if (payload !== undefined) {
175
- fields.push(`→ ${system.output.snapshots.captureJson(`event-${name}-${key}`, payload).rel}`);
176
- }
177
- system.logs.append({ source: "event", level: "event", message: fields.join(" · ") });
178
- }
179
- /**
180
- * A promise for the next emit of `key`. It **never rejects**: it resolves with
181
- * the event's payload, or with `undefined` if the action settles (`done` — which
182
- * always fires) before `key` ever does. A retained snapshot resolves it
183
- * immediately. (Not used for `key === "done"`, which reads the settled Outcome.)
184
- */
185
- static awaitEvent(emitter, key) {
186
- return new Promise((resolve) => {
187
- const offs = [];
188
- let settled = false;
189
- // First of [the event | done] to fire wins; the rest are unsubscribed.
190
- const settle = (act) => {
191
- if (settled)
192
- return;
193
- settled = true;
194
- for (const off of offs)
195
- off();
196
- act();
197
- };
198
- // listenOnce is keyed by E; the system `done` key is always present on an
199
- // instance's event map, so reach it through a loosened view.
200
- const listen = emitter.listenOnce;
201
- offs.push(listen(key, (payload) => settle(() => resolve(payload))));
202
- // `done` fires once on any settle (success or failure); if it beats `key`,
203
- // the event will never come — resolve `undefined` rather than reject.
204
- if (key !== "done") {
205
- offs.push(listen("done", () => settle(() => resolve(undefined))));
206
- }
207
- // A retained snapshot fires a listen synchronously during registration; if
208
- // that already settled us, drop any listeners registered afterwards.
209
- if (settled)
210
- for (const off of offs)
211
- off();
212
- });
213
- }
214
- }
215
- /**
216
- * The step between `action(name)` and an {@link Action}: declare typed events
217
- * and/or an imperative handle (phantom type transitions — each returns a builder
218
- * carrying the same `name`), then `run(exec)` to bind the implementation.
219
- */
220
- class Builder {
221
- name;
222
- constructor(name) {
223
- this.name = name;
224
- }
225
- emits() {
226
- return new Builder(this.name);
227
- }
228
- ref() {
229
- return new Builder(this.name);
230
- }
231
- run(exec) {
232
- const { name } = this;
233
- // Invoking the returned callable constructs a fresh instance (deferred — the run
234
- // executes it). Each call is its own {@link ActionRun}.
235
- const create = (...args) => new ActionRun(name, exec, args);
236
- return Object.assign(create, { actionName: name });
237
- }
238
- }
239
- /**
240
- * Defines an action. Always returns a builder; provide the implementation via
241
- * `.run(…)`, optionally after declaring events and/or a handle:
242
- *
243
- * action("Build").run(async (ctx, opts) => { … }) // no declared events / handle
244
- * action("Watch") // typed events + handle
245
- * .emits<{ create: string }>()
246
- * .ref<{ rescan(): void }>()
247
- * .run((ctx, opts) => { ctx.emit(…); ctx.attach(…); … })
248
- *
249
- * `.run(…)` yields a callable that, when invoked, constructs an
250
- * {@link ActionInstance} (deferred — the run executes it). Instances expose
251
- * chainable `.on` / `.once` (declared events plus `done`/`error`); the attached
252
- * handle is awaited via `instance.ref`.
253
- */
254
- export function action(name) {
255
- return new Builder(name);
256
- }