omkit 0.0.15 → 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.
- package/README.md +110 -97
- package/dist/actions/chrome-page.d.ts +12 -29
- package/dist/actions/chrome-page.js +19 -32
- package/dist/actions/command.d.ts +8 -41
- package/dist/actions/command.js +15 -82
- package/dist/actions/healthcheck.d.ts +3 -11
- package/dist/actions/healthcheck.js +8 -22
- package/dist/actions/index.d.ts +6 -8
- package/dist/actions/index.js +4 -6
- package/dist/actions/prompt.d.ts +5 -19
- package/dist/actions/prompt.js +10 -31
- package/dist/actions/tail-log.d.ts +7 -21
- package/dist/actions/tail-log.js +13 -34
- package/dist/actions/until-log.d.ts +4 -7
- package/dist/actions/until-log.js +3 -3
- package/dist/actions/watch-dir.d.ts +5 -19
- package/dist/actions/watch-dir.js +13 -28
- package/dist/actions/watch.d.ts +4 -12
- package/dist/actions/watch.js +9 -16
- package/dist/core/ActionRun.d.ts +97 -0
- package/dist/core/ActionRun.js +266 -0
- package/dist/core/ExecutionTree.d.ts +88 -0
- package/dist/core/ExecutionTree.js +320 -0
- package/dist/core/action.d.ts +6 -0
- package/dist/core/action.js +72 -0
- package/dist/core/context.d.ts +9 -0
- package/dist/core/context.js +8 -0
- package/dist/core/om.d.ts +9 -0
- package/dist/core/om.js +25 -0
- package/dist/core/step.d.ts +9 -0
- package/dist/core/step.js +18 -0
- package/dist/core/types.d.ts +128 -0
- package/dist/foundation/ActionRef.d.ts +18 -0
- package/dist/foundation/ActionRef.js +5 -0
- package/dist/{utils → foundation}/AsyncQueue.d.ts +2 -4
- package/dist/{utils → foundation}/AsyncQueue.js +6 -19
- package/dist/foundation/CancelledError.d.ts +10 -0
- package/dist/foundation/CancelledError.js +13 -0
- package/dist/foundation/Deferred.d.ts +8 -0
- package/dist/foundation/Deferred.js +10 -0
- package/dist/foundation/LogEntry.d.ts +29 -0
- package/dist/{orchestration/events → foundation}/events.d.ts +2 -11
- package/dist/{orchestration/events → foundation}/events.js +1 -7
- package/dist/{utils → foundation}/format.d.ts +3 -4
- package/dist/{utils → foundation}/format.js +12 -4
- package/dist/foundation/fsSafe.d.ts +7 -0
- package/dist/foundation/fsSafe.js +7 -0
- package/dist/foundation/ids.d.ts +9 -0
- package/dist/foundation/ids.js +10 -0
- package/dist/index.d.ts +11 -22
- package/dist/index.js +10 -30
- package/dist/output/LiveRenderer.d.ts +13 -0
- package/dist/output/LiveRenderer.js +56 -0
- package/dist/output/console/ConsoleCapture.d.ts +20 -0
- package/dist/output/console/ConsoleCapture.js +47 -0
- package/dist/output/folder/RunFolder.d.ts +11 -22
- package/dist/output/folder/RunFolder.js +18 -36
- package/dist/output/log/LogStore.d.ts +22 -0
- package/dist/output/log/LogStore.js +56 -0
- package/dist/output/log/RawStream.d.ts +11 -0
- package/dist/output/log/RawStream.js +18 -0
- package/dist/output/snapshot/SnapshotStore.d.ts +6 -43
- package/dist/output/snapshot/SnapshotStore.js +12 -52
- package/dist/output/writers/NodeLogWriter.d.ts +8 -0
- package/dist/output/writers/NodeLogWriter.js +36 -0
- package/dist/output/writers/ResultWriter.d.ts +3 -0
- package/dist/output/writers/ResultWriter.js +5 -0
- package/dist/output/writers/RollupWriter.d.ts +9 -0
- package/dist/output/writers/RollupWriter.js +29 -0
- package/dist/output/writers/entryLine.d.ts +10 -0
- package/dist/output/writers/entryLine.js +39 -0
- package/dist/output/writers/views.d.ts +38 -0
- package/dist/output/writers/views.js +6 -0
- package/dist/system/fs/FolderCache.d.ts +6 -34
- package/dist/system/fs/FolderCache.js +7 -38
- package/dist/system/proc.d.ts +51 -0
- package/dist/system/proc.js +148 -0
- package/dist/system/procRegistry.d.ts +52 -0
- package/dist/system/procRegistry.js +117 -0
- package/package.json +3 -1
- package/dist/orchestration/action/action.d.ts +0 -23
- package/dist/orchestration/action/action.js +0 -256
- package/dist/orchestration/action/types.d.ts +0 -182
- package/dist/orchestration/assert.d.ts +0 -24
- package/dist/orchestration/assert.js +0 -26
- package/dist/orchestration/spin/SpinHost.d.ts +0 -111
- package/dist/orchestration/spin/SpinHost.js +0 -345
- package/dist/orchestration/spin/spin.d.ts +0 -24
- package/dist/orchestration/spin/spin.js +0 -57
- package/dist/orchestration/spin/types.d.ts +0 -63
- package/dist/output/Output.d.ts +0 -24
- package/dist/output/Output.js +0 -27
- package/dist/output/index.d.ts +0 -11
- package/dist/output/index.js +0 -11
- package/dist/output/log/LogRenderer.d.ts +0 -73
- package/dist/output/log/LogRenderer.js +0 -133
- package/dist/output/log/LogsCollector.d.ts +0 -79
- package/dist/output/log/LogsCollector.js +0 -141
- package/dist/output/log/RunLog.d.ts +0 -54
- package/dist/output/log/RunLog.js +0 -152
- package/dist/output/log/console-capture.d.ts +0 -24
- package/dist/output/log/console-capture.js +0 -53
- package/dist/system/fs/streams.d.ts +0 -11
- package/dist/system/fs/streams.js +0 -14
- package/dist/system/process/process.d.ts +0 -35
- package/dist/system/process/process.js +0 -104
- package/dist/utils/serialize.d.ts +0 -2
- package/dist/utils/serialize.js +0 -12
- /package/dist/{orchestration/action → core}/types.js +0 -0
- /package/dist/{orchestration/spin/types.js → foundation/LogEntry.js} +0 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { newUuid, makeId } from "../foundation/ids.js";
|
|
3
|
+
import { fsSafe } from "../foundation/fsSafe.js";
|
|
4
|
+
import { defer } from "../foundation/Deferred.js";
|
|
5
|
+
import { LogStore } from "../output/log/LogStore.js";
|
|
6
|
+
import { RunFolder } from "../output/folder/RunFolder.js";
|
|
7
|
+
import { RawStream } from "../output/log/RawStream.js";
|
|
8
|
+
import { SnapshotStore } from "../output/snapshot/SnapshotStore.js";
|
|
9
|
+
import { ConsoleCapture } from "../output/console/ConsoleCapture.js";
|
|
10
|
+
import { LiveRenderer } from "../output/LiveRenderer.js";
|
|
11
|
+
import { writeNodeLogs } from "../output/writers/NodeLogWriter.js";
|
|
12
|
+
import { writeRollup } from "../output/writers/RollupWriter.js";
|
|
13
|
+
import { writeResult } from "../output/writers/ResultWriter.js";
|
|
14
|
+
import { ActionRun } from "./ActionRun.js";
|
|
15
|
+
import { procRegistry } from "../system/proc.js";
|
|
16
|
+
import { currentNode } from "./context.js";
|
|
17
|
+
/**
|
|
18
|
+
* The run: a module singleton that owns the log store, the run folder, the node
|
|
19
|
+
* registry, and the process-wide teardown. `launch` creates a child node under the
|
|
20
|
+
* ambient current action and runs it; `runRoot` runs the `om` body as the root and
|
|
21
|
+
* drives the whole thing to `finalize`, which always produces the artifacts.
|
|
22
|
+
*/
|
|
23
|
+
export class ExecutionTree {
|
|
24
|
+
static current = null;
|
|
25
|
+
/** The most recent run — kept for test/debug introspection (not nulled at finalize). */
|
|
26
|
+
static last = null;
|
|
27
|
+
/** The ambient-run guard: an action can only run inside a root `om(...)`. */
|
|
28
|
+
static require() {
|
|
29
|
+
if (!ExecutionTree.current) {
|
|
30
|
+
throw new Error("no active om() run — an action can only run inside a root om(...)");
|
|
31
|
+
}
|
|
32
|
+
return ExecutionTree.current;
|
|
33
|
+
}
|
|
34
|
+
/** For tests: drop the singleton so the next `om(...)` starts clean. */
|
|
35
|
+
static reset() {
|
|
36
|
+
ExecutionTree.current = null;
|
|
37
|
+
}
|
|
38
|
+
store = new LogStore();
|
|
39
|
+
folder = new RunFolder();
|
|
40
|
+
root;
|
|
41
|
+
rawStream;
|
|
42
|
+
consoleCapture;
|
|
43
|
+
snapshotStore = new SnapshotStore(this.folder);
|
|
44
|
+
registry = [];
|
|
45
|
+
unsettled = new Set();
|
|
46
|
+
originalLog = console.log.bind(console);
|
|
47
|
+
streaming;
|
|
48
|
+
liveRendering;
|
|
49
|
+
assertionsPassed = 0;
|
|
50
|
+
assertionsFailed = 0;
|
|
51
|
+
faults = [];
|
|
52
|
+
static graceMs = 5000; // teardown waits this long for nodes to settle, then finalizes anyway
|
|
53
|
+
phase = "open";
|
|
54
|
+
// Memoizes the in-flight finalize work so the grace timer's fire-and-forget call
|
|
55
|
+
// and runRoot's awaited call share (and both wait on) the same execution — a plain
|
|
56
|
+
// boolean guard would let the second caller return early while the first is still
|
|
57
|
+
// mid-flight (writes/uninstall not yet done), resolving `om()` too soon.
|
|
58
|
+
finalizing;
|
|
59
|
+
graceTimer;
|
|
60
|
+
teardownGrace = defer();
|
|
61
|
+
onSigint = () => this.teardown("interrupted (SIGINT)");
|
|
62
|
+
// Process hooks — installed at run start, removed at finalize (they must not outlive the run).
|
|
63
|
+
onUncaught = (e) => this.onFatal("uncaughtException", e);
|
|
64
|
+
onUnhandled = (e) => this.onFatal("unhandledRejection", e);
|
|
65
|
+
constructor() {
|
|
66
|
+
this.folder.ensure();
|
|
67
|
+
this.root = new ActionRun({
|
|
68
|
+
...this.nodeDeps(),
|
|
69
|
+
name: "main",
|
|
70
|
+
uuid: newUuid(),
|
|
71
|
+
id: "main",
|
|
72
|
+
path: "main",
|
|
73
|
+
parentId: null,
|
|
74
|
+
parentSignal: null,
|
|
75
|
+
bubble: () => { }, // the root has no parent to bubble into
|
|
76
|
+
});
|
|
77
|
+
this.registry.push(this.root);
|
|
78
|
+
this.rawStream = new RawStream(this.rawStreamPath());
|
|
79
|
+
this.consoleCapture = new ConsoleCapture(this.store, () => (currentNode.getStore() ?? this.root).toRef());
|
|
80
|
+
ExecutionTree.last = this;
|
|
81
|
+
}
|
|
82
|
+
/** Launch an instance under the ambient current node (or the root). */
|
|
83
|
+
launch(spec) {
|
|
84
|
+
if (this.phase !== "open") {
|
|
85
|
+
throw new Error(`cannot launch ${spec.name}: the run is "${this.phase}", not "open"`);
|
|
86
|
+
}
|
|
87
|
+
const parent = currentNode.getStore() ?? this.root;
|
|
88
|
+
const uuid = newUuid();
|
|
89
|
+
const id = makeId(spec.name, uuid);
|
|
90
|
+
const nodePath = `${parent.path}/${id}`;
|
|
91
|
+
const node = new ActionRun({
|
|
92
|
+
...this.nodeDeps(),
|
|
93
|
+
name: spec.name,
|
|
94
|
+
uuid,
|
|
95
|
+
id,
|
|
96
|
+
path: nodePath,
|
|
97
|
+
parentId: parent.id,
|
|
98
|
+
parentSignal: parent.signal,
|
|
99
|
+
// The child folds its milestones (events/asserts/snapshots/done, icon-prefixed)
|
|
100
|
+
// into the parent's log, attributed to this child's id.
|
|
101
|
+
bubble: (message) => this.append(parent, "child", id, message),
|
|
102
|
+
});
|
|
103
|
+
parent.children.push(node);
|
|
104
|
+
this.registry.push(node);
|
|
105
|
+
// Launch reference — the parent's pointer to the child's own (absolute) log.
|
|
106
|
+
this.append(parent, "child", "launch", `→ ${id} · ${this.logFile(nodePath)}`);
|
|
107
|
+
for (const t of spec.tags)
|
|
108
|
+
node.tag(t);
|
|
109
|
+
const promise = currentNode.run(node, () => node.run(spec.body, spec.args));
|
|
110
|
+
this.trackNode(promise);
|
|
111
|
+
return node;
|
|
112
|
+
}
|
|
113
|
+
append(node, level, source, message) {
|
|
114
|
+
this.store.append({ nodeId: node.id, path: node.path, level, source, message });
|
|
115
|
+
}
|
|
116
|
+
/** The shared services every node is built with (log store, snapshots, assert tally). */
|
|
117
|
+
nodeDeps() {
|
|
118
|
+
return {
|
|
119
|
+
store: this.store,
|
|
120
|
+
snapshots: this.snapshotStore,
|
|
121
|
+
artifactsFolder: this.folder.path(),
|
|
122
|
+
onAssert: (pass, actionPath, message) => this.recordAssert(pass, actionPath, message),
|
|
123
|
+
onUnhandledFailure: (actionPath, error) => {
|
|
124
|
+
this.recordFault(actionPath, error);
|
|
125
|
+
this.teardown(`${actionPath} failed`);
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
recordFault(action, error) {
|
|
130
|
+
this.faults.push({ action, error: messageOf(error) });
|
|
131
|
+
}
|
|
132
|
+
recordAssert(pass, actionPath, message) {
|
|
133
|
+
if (pass) {
|
|
134
|
+
this.assertionsPassed++;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
this.assertionsFailed++;
|
|
138
|
+
// A failure during teardown is cascade, not a real fault.
|
|
139
|
+
if (!this.root.signal.aborted) {
|
|
140
|
+
this.recordFault(actionPath, `assertion failed: ${message}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Tear the whole run down (aborts the root, cascading to every node). Idempotent. */
|
|
144
|
+
cancel() {
|
|
145
|
+
this.teardown("cancelled");
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The sole teardown path: `open → closing`, narrate the reason once, abort the root
|
|
149
|
+
* (cascading to every node — procs killed, waits unblocked, daemons stopped). Later
|
|
150
|
+
* calls are no-ops. A single grace timer guarantees finalize even if a node's cleanup
|
|
151
|
+
* wedges after its proc is already dead, so the run always writes its log.
|
|
152
|
+
*/
|
|
153
|
+
teardown(reason) {
|
|
154
|
+
if (this.phase !== "open")
|
|
155
|
+
return;
|
|
156
|
+
this.phase = "closing";
|
|
157
|
+
this.narrate(`tearing down · ${reason}`);
|
|
158
|
+
this.root.cancel();
|
|
159
|
+
// Not unref'd: it must hold the process alive long enough to finalize.
|
|
160
|
+
this.graceTimer = setTimeout(() => {
|
|
161
|
+
this.teardownGrace.resolve(); // break drive()'s wait on a node that won't settle
|
|
162
|
+
void this.finalize(); // …and finalize even if drive() was never reached (hung body)
|
|
163
|
+
}, ExecutionTree.graceMs);
|
|
164
|
+
}
|
|
165
|
+
/** An escaped throw/rejection: log it, record it (unless already tearing down), tear down. */
|
|
166
|
+
onFatal(kind, error) {
|
|
167
|
+
this.store.append({
|
|
168
|
+
nodeId: "process",
|
|
169
|
+
path: "process",
|
|
170
|
+
level: "error",
|
|
171
|
+
source: kind,
|
|
172
|
+
message: messageOf(error),
|
|
173
|
+
});
|
|
174
|
+
if (!this.root.signal.aborted)
|
|
175
|
+
this.recordFault(kind, error);
|
|
176
|
+
this.teardown(kind);
|
|
177
|
+
}
|
|
178
|
+
/** Run the `om` body as the root node, then drive to finalize. */
|
|
179
|
+
async runRoot(body) {
|
|
180
|
+
this.narrate(`run started · ${this.folder.name()}`);
|
|
181
|
+
this.consoleCapture.install();
|
|
182
|
+
// `on`, not `once`: SIGINT must stay caught for the life of the run so it can't
|
|
183
|
+
// fall through to Node's hard-kill default before finalize writes the log.
|
|
184
|
+
process.on("SIGINT", this.onSigint);
|
|
185
|
+
process.on("uncaughtException", this.onUncaught);
|
|
186
|
+
process.on("unhandledRejection", this.onUnhandled);
|
|
187
|
+
this.streaming = this.rawStream.run(this.store);
|
|
188
|
+
// Live, curated milestones to the terminal, through the pre-patch writer.
|
|
189
|
+
this.liveRendering = new LiveRenderer(this.originalLog).run(this.store);
|
|
190
|
+
const rootPromise = currentNode.run(this.root, () => this.root.run(body, []));
|
|
191
|
+
this.trackNode(rootPromise);
|
|
192
|
+
await rootPromise;
|
|
193
|
+
await this.drive();
|
|
194
|
+
await this.finalize();
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Track a launched node's `run()` promise until it settles (drives `drive()`'s
|
|
198
|
+
* wait). `run()` itself never rejects — the node's own constructor already
|
|
199
|
+
* swallows its settled rejection (via the private field, not the `.result` getter,
|
|
200
|
+
* so it doesn't mark the node observed and defeat the unobserved-failure check).
|
|
201
|
+
*/
|
|
202
|
+
trackNode(promise) {
|
|
203
|
+
const tracked = promise.finally(() => this.unsettled.delete(tracked));
|
|
204
|
+
this.unsettled.add(tracked);
|
|
205
|
+
}
|
|
206
|
+
async drive() {
|
|
207
|
+
// Drain every launched node. Teardown aborts them (procs reaped, waits unblocked),
|
|
208
|
+
// so they settle and this returns. The grace timer resolves `teardownGrace` if a
|
|
209
|
+
// node's own cleanup wedges — the OS process is already dead by then.
|
|
210
|
+
while (this.unsettled.size > 0) {
|
|
211
|
+
const graced = await Promise.race([
|
|
212
|
+
Promise.allSettled([...this.unsettled]).then(() => false),
|
|
213
|
+
this.teardownGrace.promise.then(() => true),
|
|
214
|
+
]);
|
|
215
|
+
if (graced)
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
finalize() {
|
|
220
|
+
if (!this.finalizing)
|
|
221
|
+
this.finalizing = this.finalizeOnce();
|
|
222
|
+
return this.finalizing;
|
|
223
|
+
}
|
|
224
|
+
async finalizeOnce() {
|
|
225
|
+
if (this.phase === "open")
|
|
226
|
+
this.teardown("completed");
|
|
227
|
+
this.phase = "closed";
|
|
228
|
+
if (this.graceTimer)
|
|
229
|
+
clearTimeout(this.graceTimer);
|
|
230
|
+
// Reap any Windows grandchild a mashed Ctrl+C orphaned before killTree could reach it
|
|
231
|
+
// (no-op elsewhere, and when nothing was tracked).
|
|
232
|
+
procRegistry.sweep();
|
|
233
|
+
this.narrate("finished");
|
|
234
|
+
this.store.close();
|
|
235
|
+
await this.streaming;
|
|
236
|
+
await this.liveRendering;
|
|
237
|
+
const flat = this.registry.map((node) => this.nodeView(node));
|
|
238
|
+
const entries = this.store.entries();
|
|
239
|
+
const at = (name) => path.join(this.folder.path(), name);
|
|
240
|
+
const assertSummary = `⊨ ${this.assertionsPassed} passed · ⊭ ${this.assertionsFailed} failed`;
|
|
241
|
+
await writeNodeLogs(flat, entries);
|
|
242
|
+
await writeRollup(at("events.log"), flat, entries, (e) => e.level === "event");
|
|
243
|
+
await writeRollup(at("asserts.log"), flat, entries, (e) => e.level === "assert", assertSummary);
|
|
244
|
+
await writeRollup(at("snapshots.log"), flat, entries, (e) => e.level === "snapshot");
|
|
245
|
+
await writeResult(at("result.json"), this.runView());
|
|
246
|
+
// Only now let go of the process hooks — a Ctrl+C during the writes must still be
|
|
247
|
+
// caught (as a no-op) so it can't fall through to Node's kill-without-log default.
|
|
248
|
+
process.off("SIGINT", this.onSigint);
|
|
249
|
+
process.off("uncaughtException", this.onUncaught);
|
|
250
|
+
process.off("unhandledRejection", this.onUnhandled);
|
|
251
|
+
this.consoleCapture.uninstall();
|
|
252
|
+
ExecutionTree.current = null;
|
|
253
|
+
this.printSummary(at, assertSummary);
|
|
254
|
+
if (!this.verdictOk())
|
|
255
|
+
process.exitCode = 1;
|
|
256
|
+
}
|
|
257
|
+
/** The end-of-run links + assert summary, to the terminal (files are already written). */
|
|
258
|
+
printSummary(at, assertSummary) {
|
|
259
|
+
const term = this.originalLog;
|
|
260
|
+
term("");
|
|
261
|
+
term(`om → ${this.folder.path()}`);
|
|
262
|
+
term(` main log → ${at("main.log")}`);
|
|
263
|
+
term(` events → ${at("events.log")}`);
|
|
264
|
+
term(` asserts → ${at("asserts.log")} ${assertSummary}`);
|
|
265
|
+
term(` snapshots → ${at("snapshots.log")}`);
|
|
266
|
+
}
|
|
267
|
+
verdictOk() {
|
|
268
|
+
return this.faults.length === 0;
|
|
269
|
+
}
|
|
270
|
+
nodeView(node) {
|
|
271
|
+
return {
|
|
272
|
+
id: node.id,
|
|
273
|
+
uuid: node.uuid,
|
|
274
|
+
name: node.name,
|
|
275
|
+
path: node.path,
|
|
276
|
+
parentId: node.parentId,
|
|
277
|
+
tags: [...node.tags],
|
|
278
|
+
args: [...node.args],
|
|
279
|
+
status: node.status,
|
|
280
|
+
startedAt: node.startedAt,
|
|
281
|
+
endedAt: node.endedAt,
|
|
282
|
+
duration: node.duration,
|
|
283
|
+
logFile: this.logFile(node.path),
|
|
284
|
+
children: node.children.map((child) => this.nodeView(child)),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/** Test seam: the projected run view (same object written to result.json). */
|
|
288
|
+
runViewForTest() {
|
|
289
|
+
return this.runView();
|
|
290
|
+
}
|
|
291
|
+
runView() {
|
|
292
|
+
return {
|
|
293
|
+
ok: this.faults.length === 0,
|
|
294
|
+
failures: [...this.faults],
|
|
295
|
+
assertions: { passed: this.assertionsPassed, failed: this.assertionsFailed },
|
|
296
|
+
startedAt: this.root.startedAt,
|
|
297
|
+
endedAt: this.root.endedAt,
|
|
298
|
+
duration: this.root.duration,
|
|
299
|
+
rawStream: this.rawStreamPath(),
|
|
300
|
+
root: this.nodeView(this.root),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Absolute `.log` path for a node — root is `main.log`, children drop the `main/`
|
|
305
|
+
* prefix. Each id segment is sanitized so names with filesystem-illegal characters
|
|
306
|
+
* (e.g. `TSKB:root:watch:docs`) still produce a valid path.
|
|
307
|
+
*/
|
|
308
|
+
logFile(nodePath) {
|
|
309
|
+
const rel = nodePath === "main" ? "main" : nodePath.slice("main/".length);
|
|
310
|
+
const safe = rel.split("/").map(fsSafe).join(path.sep);
|
|
311
|
+
return path.join(this.folder.path(), `${safe}.log`);
|
|
312
|
+
}
|
|
313
|
+
rawStreamPath() {
|
|
314
|
+
return path.join(this.folder.path(), "raw.jsonl");
|
|
315
|
+
}
|
|
316
|
+
narrate(message) {
|
|
317
|
+
this.store.append({ nodeId: "run", path: "run", level: "run", source: "run", message });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const messageOf = (error) => error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ActionBuilderEvents, NoEvents } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Define an action. Provide the impl via `.run(…)`, optionally after declaring
|
|
4
|
+
* events (`.emits<…>()`) and/or an imperative handle (`.ref<…>()`).
|
|
5
|
+
*/
|
|
6
|
+
export declare function action(name: string): ActionBuilderEvents<NoEvents, void>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { ExecutionTree } from "./ExecutionTree.js";
|
|
2
|
+
import { FolderCache } from "../system/fs/FolderCache.js";
|
|
3
|
+
/**
|
|
4
|
+
* A constructed-but-not-yet-run action. Inert: it only holds the name, body, args,
|
|
5
|
+
* and any tags buffered before launch. `exec()` hands it to the tree, which creates
|
|
6
|
+
* the node under the ambient current action and runs it.
|
|
7
|
+
*/
|
|
8
|
+
class Instance {
|
|
9
|
+
name;
|
|
10
|
+
body;
|
|
11
|
+
args;
|
|
12
|
+
tags = [];
|
|
13
|
+
constructor(name, body, args) {
|
|
14
|
+
this.name = name;
|
|
15
|
+
this.body = body;
|
|
16
|
+
this.args = args;
|
|
17
|
+
}
|
|
18
|
+
tag(name) {
|
|
19
|
+
this.tags.push(name);
|
|
20
|
+
return this;
|
|
21
|
+
}
|
|
22
|
+
withCache(...paths) {
|
|
23
|
+
// Validate + canonicalize eagerly here (fails fast on a relative path).
|
|
24
|
+
const targets = FolderCache.resolvePaths(paths);
|
|
25
|
+
const innerBody = this.body;
|
|
26
|
+
const innerArgs = this.args;
|
|
27
|
+
// Wrap in a fresh instance that keeps this action's name/tags and, at run time,
|
|
28
|
+
// fingerprints the inputs — skipping (return undefined) on a hit, else running
|
|
29
|
+
// the inner body and recording the fingerprint on success.
|
|
30
|
+
const body = (async (ctx, ...args) => {
|
|
31
|
+
const fp = await FolderCache.fingerprint(targets);
|
|
32
|
+
if ((await FolderCache.read(targets)) === fp) {
|
|
33
|
+
console.log("cached, skipping");
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
const result = (await innerBody(ctx, ...args));
|
|
37
|
+
await FolderCache.write(targets, fp);
|
|
38
|
+
return result;
|
|
39
|
+
});
|
|
40
|
+
const cached = new Instance(this.name, body, innerArgs);
|
|
41
|
+
cached.tags.push(...this.tags);
|
|
42
|
+
return cached;
|
|
43
|
+
}
|
|
44
|
+
exec() {
|
|
45
|
+
return ExecutionTree.require().launch(this);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** The builder returned by `action(name)`: declare events/handle, then bind the impl. */
|
|
49
|
+
class Builder {
|
|
50
|
+
name;
|
|
51
|
+
constructor(name) {
|
|
52
|
+
this.name = name;
|
|
53
|
+
}
|
|
54
|
+
emits() {
|
|
55
|
+
return new Builder(this.name);
|
|
56
|
+
}
|
|
57
|
+
ref() {
|
|
58
|
+
return new Builder(this.name);
|
|
59
|
+
}
|
|
60
|
+
run(body) {
|
|
61
|
+
const name = this.name;
|
|
62
|
+
const create = (...args) => new Instance(name, body, args);
|
|
63
|
+
return Object.assign(create, { actionName: name });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Define an action. Provide the impl via `.run(…)`, optionally after declaring
|
|
68
|
+
* events (`.emits<…>()`) and/or an imperative handle (`.ref<…>()`).
|
|
69
|
+
*/
|
|
70
|
+
export function action(name) {
|
|
71
|
+
return new Builder(name);
|
|
72
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import type { ActionRun } from "./ActionRun.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Holds the **current node** for the duration of an exec. Read by
|
|
5
|
+
* `instance.exec()` (to find its parent) and by console capture (to attribute a
|
|
6
|
+
* `console.*` line) — the single ambient mechanism that replaces `nod` and makes
|
|
7
|
+
* scoping compose without threading anything by hand.
|
|
8
|
+
*/
|
|
9
|
+
export declare const currentNode: AsyncLocalStorage<ActionRun<unknown, object, unknown>>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
/**
|
|
3
|
+
* Holds the **current node** for the duration of an exec. Read by
|
|
4
|
+
* `instance.exec()` (to find its parent) and by console capture (to attribute a
|
|
5
|
+
* `console.*` line) — the single ambient mechanism that replaces `nod` and makes
|
|
6
|
+
* scoping compose without threading anything by hand.
|
|
7
|
+
*/
|
|
8
|
+
export const currentNode = new AsyncLocalStorage();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Awaitable, OmContext } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Runs a linear orchestration as the root of a fresh {@link ExecutionTree}. The
|
|
4
|
+
* body runs inside the root node's ambient scope, so any `instance.exec()` /
|
|
5
|
+
* `step(...)` / `console.*` it reaches attributes correctly. Resolves once the run
|
|
6
|
+
* has torn down and produced its artifacts; never rejects (failures are recorded in
|
|
7
|
+
* the tree and set the exit code).
|
|
8
|
+
*/
|
|
9
|
+
export declare function om(body: (ctx: OmContext) => Awaitable<void>): Promise<void>;
|
package/dist/core/om.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ExecutionTree } from "./ExecutionTree.js";
|
|
2
|
+
/**
|
|
3
|
+
* Runs a linear orchestration as the root of a fresh {@link ExecutionTree}. The
|
|
4
|
+
* body runs inside the root node's ambient scope, so any `instance.exec()` /
|
|
5
|
+
* `step(...)` / `console.*` it reaches attributes correctly. Resolves once the run
|
|
6
|
+
* has torn down and produced its artifacts; never rejects (failures are recorded in
|
|
7
|
+
* the tree and set the exit code).
|
|
8
|
+
*/
|
|
9
|
+
export function om(body) {
|
|
10
|
+
if (ExecutionTree.current) {
|
|
11
|
+
throw new Error("an om() run is already active in this process");
|
|
12
|
+
}
|
|
13
|
+
const tree = new ExecutionTree();
|
|
14
|
+
ExecutionTree.current = tree;
|
|
15
|
+
const rootBody = (ctx) => body({
|
|
16
|
+
signal: ctx.signal,
|
|
17
|
+
logs: ctx.logs,
|
|
18
|
+
tag: ctx.tag,
|
|
19
|
+
cancel: () => tree.cancel(),
|
|
20
|
+
assert: ctx.assert,
|
|
21
|
+
snapshot: ctx.snapshot,
|
|
22
|
+
artifactsFolder: ctx.artifactsFolder,
|
|
23
|
+
});
|
|
24
|
+
return tree.runRoot(rootBody);
|
|
25
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ActionContext, Awaitable } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* An inline anonymous action: creates a child node under the ambient current
|
|
4
|
+
* action and runs `fn` as that node — own id/path/log, tags, error boundary — with
|
|
5
|
+
* no reusable definition. `await step("seed-db", async () => …)` resolves the
|
|
6
|
+
* result (throws on failure/cancel). For reusable, typed, event/handle-bearing
|
|
7
|
+
* units use `action(...)`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function step<Result>(name: string, fn: (ctx: ActionContext) => Awaitable<Result>): Promise<Result>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { action } from "./action.js";
|
|
2
|
+
/**
|
|
3
|
+
* An inline anonymous action: creates a child node under the ambient current
|
|
4
|
+
* action and runs `fn` as that node — own id/path/log, tags, error boundary — with
|
|
5
|
+
* no reusable definition. `await step("seed-db", async () => …)` resolves the
|
|
6
|
+
* result (throws on failure/cancel). For reusable, typed, event/handle-bearing
|
|
7
|
+
* units use `action(...)`.
|
|
8
|
+
*/
|
|
9
|
+
export function step(name, fn) {
|
|
10
|
+
return action(name)
|
|
11
|
+
.run(fn)()
|
|
12
|
+
.exec()
|
|
13
|
+
.result.then((r) => {
|
|
14
|
+
if (!r.ok)
|
|
15
|
+
throw r.error;
|
|
16
|
+
return r.value;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { Emitter, EventHandler } from "../foundation/events.ts";
|
|
2
|
+
import type { ReadableLog } from "../foundation/LogEntry.ts";
|
|
3
|
+
import type { Proc } from "../system/proc.ts";
|
|
4
|
+
/** A value an action's exec may return: a result, or a promise of one. */
|
|
5
|
+
export type Awaitable<T> = T | Promise<T>;
|
|
6
|
+
/** An action that declares no events. */
|
|
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
|
+
/** A node's lifecycle state, mirrored into `result.json`. */
|
|
17
|
+
export type NodeStatus = "running" | "ok" | "failed" | "cancelled";
|
|
18
|
+
/**
|
|
19
|
+
* The lifecycle events every action emits on top of its declared ones — so key
|
|
20
|
+
* moments are first-class signals rather than log-scraping.
|
|
21
|
+
*/
|
|
22
|
+
export interface SystemEvents<Result> {
|
|
23
|
+
attached: void;
|
|
24
|
+
done: Result;
|
|
25
|
+
error: unknown;
|
|
26
|
+
}
|
|
27
|
+
/** The full event map a run exposes: declared events plus the system ones. */
|
|
28
|
+
export type InstanceEvents<Events extends object, Result> = Events & SystemEvents<Result>;
|
|
29
|
+
/** What an action's `exec` receives. `logs` is read-only; output is via `console.*`. */
|
|
30
|
+
export interface ActionContext<Events extends object = NoEvents, Handle = void> {
|
|
31
|
+
/** This node's abort signal — honor it to stop early. */
|
|
32
|
+
readonly signal: AbortSignal;
|
|
33
|
+
/** The combined run log, read-only (subscribe/observe; never append). */
|
|
34
|
+
readonly logs: ReadableLog;
|
|
35
|
+
/** Emit one of this action's declared events (also logged onto the timeline). */
|
|
36
|
+
readonly emit: Emitter<Events>["emit"];
|
|
37
|
+
/** Publish this action's imperative handle, resolving its run's `.ref`. */
|
|
38
|
+
readonly attach: (handle: Handle) => void;
|
|
39
|
+
/** Tag this run (timeline-visible; accumulates onto the node). */
|
|
40
|
+
readonly tag: (name: string) => void;
|
|
41
|
+
/** Assert an invariant: logs a `⊨` line and tallies into the verdict. Never throws. */
|
|
42
|
+
readonly assert: (condition: boolean, message: string) => void;
|
|
43
|
+
/** Capture a JSON snapshot; drops a timeline line and resolves the file's absolute path. */
|
|
44
|
+
readonly snapshot: (name: string, value: unknown) => Promise<string>;
|
|
45
|
+
/** Absolute path to this run's output folder — write artifacts (screenshots, dumps, …) here. */
|
|
46
|
+
readonly artifactsFolder: string;
|
|
47
|
+
/** Spawn child processes bound to this action (output → this node's log, killed on teardown). */
|
|
48
|
+
readonly proc: Proc;
|
|
49
|
+
}
|
|
50
|
+
/** What the `om` body receives — the run-level counterpart to {@link ActionContext}. */
|
|
51
|
+
export interface OmContext {
|
|
52
|
+
/** The run's (root) abort signal. */
|
|
53
|
+
readonly signal: AbortSignal;
|
|
54
|
+
/** Tag the root run. */
|
|
55
|
+
readonly tag: (name: string) => void;
|
|
56
|
+
/** Tear the whole run down. */
|
|
57
|
+
readonly cancel: () => void;
|
|
58
|
+
/** The combined run log, read-only. */
|
|
59
|
+
readonly logs: ReadableLog;
|
|
60
|
+
/** Assert an invariant from the body (attributed to the root). Never throws. */
|
|
61
|
+
readonly assert: (condition: boolean, message: string) => void;
|
|
62
|
+
/** Capture a run-level JSON snapshot; resolves the file's absolute path. */
|
|
63
|
+
readonly snapshot: (name: string, value: unknown) => Promise<string>;
|
|
64
|
+
/** Absolute path to this run's output folder — write run-level artifacts here. */
|
|
65
|
+
readonly artifactsFolder: string;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A launched action: the live node's awaitable/observable surface. `.result` always
|
|
69
|
+
* resolves an {@link Outcome} (never throws); `.ref` rejects on failure/cancel.
|
|
70
|
+
*/
|
|
71
|
+
export interface RunHandle<Result = unknown, Events extends object = NoEvents, Handle = void> {
|
|
72
|
+
readonly id: string;
|
|
73
|
+
/** The terminal outcome; always resolves (never rejects). Reading it observes the failure. */
|
|
74
|
+
readonly result: Promise<Outcome<Result>>;
|
|
75
|
+
/** The attached handle; rejects on failure/cancel. */
|
|
76
|
+
readonly ref: Promise<Handle>;
|
|
77
|
+
/**
|
|
78
|
+
* Handle a fire-and-forget activity's failure: attach a handler so a crash runs it
|
|
79
|
+
* instead of tearing the run down. Observes the failure; chainable; attach in the
|
|
80
|
+
* same tick as `.exec()`. Not called on cancellation.
|
|
81
|
+
*/
|
|
82
|
+
handleFailure(handler: (error: unknown) => void): this;
|
|
83
|
+
/** Subscribe to every emit of a declared/system event (`done`/`error`/`attached`). */
|
|
84
|
+
on<K extends keyof InstanceEvents<Events, Result>>(key: K, handler: EventHandler<InstanceEvents<Events, Result>[K]>): void;
|
|
85
|
+
/** Next emit of `key`; resolves `undefined` if the node settles first (never rejects). */
|
|
86
|
+
once<K extends keyof InstanceEvents<Events, Result>>(key: K): Promise<InstanceEvents<Events, Result>[K] | undefined>;
|
|
87
|
+
/** Tag this run; chainable. */
|
|
88
|
+
tag(name: string): this;
|
|
89
|
+
/** Abort this node and its subtree. */
|
|
90
|
+
cancel(): void;
|
|
91
|
+
}
|
|
92
|
+
/** A constructed-but-not-yet-run action: what calling an `Action` produces. */
|
|
93
|
+
export interface ActionInstance<Result = unknown, Events extends object = NoEvents, Handle = void> {
|
|
94
|
+
readonly name: string;
|
|
95
|
+
readonly args: readonly unknown[];
|
|
96
|
+
/** Buffer a tag before launch; applied onto the node at creation. Chainable. */
|
|
97
|
+
tag(name: string): this;
|
|
98
|
+
/**
|
|
99
|
+
* Gate this action on its **input** files/folders (absolute paths): skip running
|
|
100
|
+
* when they're unchanged since the last successful run (a hit resolves
|
|
101
|
+
* `undefined` and logs `cached, skipping`). Chain before launching.
|
|
102
|
+
*/
|
|
103
|
+
withCache(...paths: string[]): ActionInstance<Result | undefined, Events, Handle>;
|
|
104
|
+
/** Launch under the ambient current node; returns the live {@link RunHandle}. */
|
|
105
|
+
exec(): RunHandle<Result, Events, Handle>;
|
|
106
|
+
}
|
|
107
|
+
/** Any constructed instance, whatever its result/events/handle. */
|
|
108
|
+
export type AnyActionInstance = ActionInstance<unknown, object, unknown>;
|
|
109
|
+
/** A callable produced by `action`: invoking it builds an {@link ActionInstance}. */
|
|
110
|
+
export interface Action<Args extends unknown[], Result, Events extends object = NoEvents, Handle = void> {
|
|
111
|
+
(...args: Args): ActionInstance<Result, Events, Handle>;
|
|
112
|
+
readonly actionName: string;
|
|
113
|
+
}
|
|
114
|
+
/** Intermediate step from `action(name)`: declare events/handle, then provide the impl. */
|
|
115
|
+
export interface ActionBuilderEvents<Events extends object, Handle = void> {
|
|
116
|
+
emits<E extends object>(): ActionBuilderEvents<E, Handle>;
|
|
117
|
+
ref<H>(): ActionBuilderEvents<Events, H>;
|
|
118
|
+
run<Args extends unknown[], Result>(body: (ctx: ActionContext<Events, Handle>, ...args: Args) => Awaitable<Result>): Action<Args, Result, Events, Handle>;
|
|
119
|
+
}
|
|
120
|
+
/** The exec signature bound by `.run(...)` (arg types erased at the boundary). */
|
|
121
|
+
export type Exec<Events extends object, Handle, Result> = (ctx: ActionContext<Events, Handle>, ...args: never[]) => Awaitable<Result>;
|
|
122
|
+
/** What the {@link ExecutionTree} needs to launch an instance — the shape an instance satisfies. */
|
|
123
|
+
export interface LaunchSpec {
|
|
124
|
+
readonly name: string;
|
|
125
|
+
readonly args: readonly unknown[];
|
|
126
|
+
readonly tags: readonly string[];
|
|
127
|
+
readonly body: Exec<object, unknown, unknown>;
|
|
128
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identity carried wherever an action is pointed to — a log file's header, a
|
|
3
|
+
* rollup chunk header, a log-file reference. A plain data shape (no behavior) so
|
|
4
|
+
* both the `output` and `core` layers can use it without depending on each other.
|
|
5
|
+
* Rendered compactly as `name · path · [tags]`.
|
|
6
|
+
*/
|
|
7
|
+
export interface ActionRef {
|
|
8
|
+
/** `${name}_${shortId}`, or `main` for the root. */
|
|
9
|
+
readonly id: string;
|
|
10
|
+
/** The action's declared name. */
|
|
11
|
+
readonly name: string;
|
|
12
|
+
/** Ancestry of ids joined by `/` — `main/chromePage_9f3c/debug_77d1`. */
|
|
13
|
+
readonly path: string;
|
|
14
|
+
/** Accumulated tags at the point of reference. */
|
|
15
|
+
readonly tags: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
/** Render an {@link ActionRef} for a header/reference line: `name · path · [a, b]`. */
|
|
18
|
+
export declare const renderRef: (ref: ActionRef) => string;
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
+
/** A single-consumer async queue: `push` feeds it, `for await` drains it, `close` ends it. */
|
|
1
2
|
export declare class AsyncQueue<T> implements AsyncIterable<T> {
|
|
2
3
|
private readonly items;
|
|
3
4
|
private readonly resolvers;
|
|
4
5
|
private closed;
|
|
5
6
|
push(item: T): void;
|
|
6
|
-
/**
|
|
7
|
-
* Ends the queue: any parked reader resolves `{ done: true }` immediately, and
|
|
8
|
-
* once already-buffered items are drained, future reads do too. Idempotent.
|
|
9
|
-
*/
|
|
7
|
+
/** Ends the queue: parked readers resolve `{ done: true }`, drained reads too. Idempotent. */
|
|
10
8
|
close(): void;
|
|
11
9
|
private next;
|
|
12
10
|
[Symbol.asyncIterator](): AsyncIterator<T>;
|