omkit 0.0.5 → 0.0.7
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/dist/actions/command.js +11 -4
- package/dist/actions/tail-log.js +18 -7
- package/dist/core/action.d.ts +45 -10
- package/dist/core/action.js +41 -8
- package/dist/core/output.d.ts +9 -0
- package/dist/core/output.js +18 -1
- package/dist/core/run.d.ts +42 -49
- package/dist/core/run.js +47 -26
- package/dist/core/spin.d.ts +38 -24
- package/dist/core/spin.js +11 -4
- package/dist/index.d.ts +3 -2
- package/dist/index.js +5 -1
- package/package.json +1 -1
package/dist/actions/command.js
CHANGED
|
@@ -22,7 +22,10 @@ export function command(name, cmdOrFile, argsOrOptions, maybeOptions) {
|
|
|
22
22
|
// against process.cwd() — exactly the "absolute from where node runs" rule.
|
|
23
23
|
const cwd = path.resolve(options.cwd ?? ".");
|
|
24
24
|
// proc sources the LogsCollector from async context and streams output into it.
|
|
25
|
-
return proc(options.label ?? name, {
|
|
25
|
+
return proc(options.label ?? name, {
|
|
26
|
+
cwd,
|
|
27
|
+
...(options.env ? { env: options.env } : {}),
|
|
28
|
+
})(verbatim(cmdOrFile));
|
|
26
29
|
})(); // construct the instance here, so callers get one in a single call
|
|
27
30
|
}
|
|
28
31
|
/**
|
|
@@ -76,9 +79,13 @@ const spawnDirect = (logs, signal, name, file, args, options) => {
|
|
|
76
79
|
// During teardown the abort-kill is why it exited — a clean shutdown, not a
|
|
77
80
|
// fault; let it settle successfully so it isn't recorded as a failure.
|
|
78
81
|
if (signal.aborted || code === 0)
|
|
79
|
-
resolve();
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
return resolve();
|
|
83
|
+
// Non-zero exit: reject with an error carrying `exitCode`, so the framework
|
|
84
|
+
// surfaces it on the action's Outcome (`{ ok: false, exitCode }`).
|
|
85
|
+
const error = Object.assign(new Error(`${name} exited with ${code ?? sig}`), {
|
|
86
|
+
exitCode: code ?? undefined,
|
|
87
|
+
});
|
|
88
|
+
reject(error);
|
|
82
89
|
});
|
|
83
90
|
});
|
|
84
91
|
};
|
package/dist/actions/tail-log.js
CHANGED
|
@@ -40,8 +40,6 @@ export const tailLog = action("Tail Log").run(async ({ logs, signal }, target, o
|
|
|
40
40
|
logs.append({ source, level, message: line });
|
|
41
41
|
}
|
|
42
42
|
};
|
|
43
|
-
// Read [pos, size) and feed it. Catches the file being absent (rotated away)
|
|
44
|
-
// and resets on a shrink (truncation/rotation), so reading self-heals.
|
|
45
43
|
const readDelta = async () => {
|
|
46
44
|
let size;
|
|
47
45
|
try {
|
|
@@ -58,11 +56,24 @@ export const tailLog = action("Tail Log").run(async ({ logs, signal }, target, o
|
|
|
58
56
|
return;
|
|
59
57
|
const handle = await open(file, "r");
|
|
60
58
|
try {
|
|
61
|
-
const
|
|
62
|
-
const buffer = Buffer.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
59
|
+
const CHUNK = 64 * 1024;
|
|
60
|
+
const buffer = Buffer.allocUnsafe(CHUNK);
|
|
61
|
+
while (pos < size) {
|
|
62
|
+
const length = Math.min(CHUNK, size - pos);
|
|
63
|
+
const { bytesRead } = await handle.read(buffer, 0, length, pos);
|
|
64
|
+
if (bytesRead === 0)
|
|
65
|
+
break;
|
|
66
|
+
pos += bytesRead;
|
|
67
|
+
// Strip NUL bytes before decoding. Some log rotators on Windows
|
|
68
|
+
// temporarily expose sparse/preallocated regions filled with 0x00.
|
|
69
|
+
let out = 0;
|
|
70
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
71
|
+
if (buffer[i] !== 0)
|
|
72
|
+
buffer[out++] = buffer[i];
|
|
73
|
+
}
|
|
74
|
+
if (out > 0)
|
|
75
|
+
feed(buffer.subarray(0, out).toString(encoding));
|
|
76
|
+
}
|
|
66
77
|
}
|
|
67
78
|
finally {
|
|
68
79
|
await handle.close();
|
package/dist/core/action.d.ts
CHANGED
|
@@ -5,6 +5,21 @@ import { type Proc } from "./process.ts";
|
|
|
5
5
|
export type NoEvents = Record<never, never>;
|
|
6
6
|
/** A value an action's `exec` may return: a result, or a promise of one. */
|
|
7
7
|
export type Awaitable<T> = T | Promise<T>;
|
|
8
|
+
/**
|
|
9
|
+
* How an action settles. An action **never throws into the spin body** — its
|
|
10
|
+
* failure is data, not control flow: it completes with `{ ok: false, error }`
|
|
11
|
+
* instead of rejecting. On success it carries the exec's `value`. `exitCode` is
|
|
12
|
+
* present when the failure came from a process (see {@link import("../actions/command.ts").command}).
|
|
13
|
+
* Await it via `instance.done` or `once("done")`; both resolve, never reject.
|
|
14
|
+
*/
|
|
15
|
+
export type Outcome<T> = {
|
|
16
|
+
readonly ok: true;
|
|
17
|
+
readonly value: T;
|
|
18
|
+
} | {
|
|
19
|
+
readonly ok: false;
|
|
20
|
+
readonly error: unknown;
|
|
21
|
+
readonly exitCode?: number;
|
|
22
|
+
};
|
|
8
23
|
/**
|
|
9
24
|
* Lifecycle events the framework emits for every action — on top of whatever it
|
|
10
25
|
* declares via `.emits<…>()`. They make key moments first-class signals (no
|
|
@@ -13,9 +28,9 @@ export type Awaitable<T> = T | Promise<T>;
|
|
|
13
28
|
export interface SystemEvents<Result> {
|
|
14
29
|
/** The action published its imperative handle via `ctx.attach` (fires once). */
|
|
15
30
|
attached: void;
|
|
16
|
-
/** The
|
|
17
|
-
done: Result
|
|
18
|
-
/** The
|
|
31
|
+
/** The action settled; payload is its {@link Outcome} (success or failure). Fires once, always. */
|
|
32
|
+
done: Outcome<Result>;
|
|
33
|
+
/** The action failed; payload is the thrown error. Fires only on failure (alongside `done`). */
|
|
19
34
|
error: unknown;
|
|
20
35
|
}
|
|
21
36
|
/** The full event map a constructed instance exposes: the declared events plus the system ones. */
|
|
@@ -39,18 +54,38 @@ export interface ActionContext<Events extends object, Handle = void> extends Sys
|
|
|
39
54
|
* one (e.g. CDP) follows the same pattern over `logs`/`signal`.
|
|
40
55
|
*/
|
|
41
56
|
readonly proc: Proc;
|
|
57
|
+
/**
|
|
58
|
+
* Absolute path to this run's output folder (`logs/<name>/<date>/<time>/`) —
|
|
59
|
+
* created and shared across the run. Write action artifacts here so they land
|
|
60
|
+
* beside `run.log`; the same folder is on the `spin` context.
|
|
61
|
+
*/
|
|
62
|
+
readonly artifactsFolder: string;
|
|
42
63
|
}
|
|
43
64
|
/** A constructed-but-not-yet-run action: what calling an `Action` produces. */
|
|
44
65
|
export interface ActionInstance<Result = unknown, Events extends object = NoEvents, Handle = void> {
|
|
45
66
|
readonly name: string;
|
|
46
67
|
readonly args: readonly unknown[];
|
|
47
|
-
/** Runs the action with the captured args and the injected system services. */
|
|
48
|
-
readonly start: (system: SystemGlobal) => Promise<Result>;
|
|
49
68
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
69
|
+
* Runs the action with the captured args and the injected system services.
|
|
70
|
+
* **Resolves with the {@link Outcome}, never rejects** — a failing action is
|
|
71
|
+
* reported as `{ ok: false, error }`, not a thrown promise, so it can't tear a
|
|
72
|
+
* caller down. The run reads this to build its verdict.
|
|
73
|
+
*/
|
|
74
|
+
readonly start: (system: SystemGlobal) => Promise<Outcome<Result>>;
|
|
75
|
+
/**
|
|
76
|
+
* The action's {@link Outcome}, as a memoized promise that **never rejects** —
|
|
77
|
+
* the ergonomic way to await completion in a spin body:
|
|
78
|
+
* `const r = await nod(x).done; if (!r.ok) …`. Same value as `once("done")`.
|
|
79
|
+
*/
|
|
80
|
+
readonly done: Promise<Outcome<Result>>;
|
|
81
|
+
/**
|
|
82
|
+
* Resolves with the handle the action attached via `ctx.attach` (set once).
|
|
83
|
+
* Used to thread a handle into another action (`chromePage(driver.ref)`). If
|
|
84
|
+
* the action settles *without* attaching, this resolves (`undefined` for the
|
|
85
|
+
* default `void` handle) on success and **rejects on failure** — so, unlike
|
|
86
|
+
* `done`, awaiting `ref` in a body can throw. It's meant to be consumed as a
|
|
87
|
+
* constructor arg *inside* another action, where that rejection becomes that
|
|
88
|
+
* action's failure Outcome. For control flow, await `done`.
|
|
54
89
|
*/
|
|
55
90
|
readonly ref: Promise<Handle>;
|
|
56
91
|
/** Subscribe to a declared or system event (`done`/`error`) with a handler. */
|
|
@@ -82,7 +117,7 @@ export interface ActionInstance<Result = unknown, Events extends object = NoEven
|
|
|
82
117
|
}
|
|
83
118
|
/**
|
|
84
119
|
* Any constructed instance, whatever its result, events, or handle. The launch
|
|
85
|
-
* surface ({@link ActionInstance}-takers like `run` and `
|
|
120
|
+
* surface ({@link ActionInstance}-takers like `run` and `Spin.run`) accepts this
|
|
86
121
|
* so handle-bearing actions — those declaring `.ref<H>()` — can be launched: the
|
|
87
122
|
* bare `ActionInstance` pins the handle to `void`, so an instance whose `ref` is
|
|
88
123
|
* a `Promise<Page>` (not `Promise<void>`) would otherwise be rejected. `unknown`
|
package/dist/core/action.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { events } from "./events.js";
|
|
2
2
|
import { createProc } from "./process.js";
|
|
3
3
|
import { fingerprint, read, write, resolvePaths } from "./folder-cache.js";
|
|
4
|
+
import { artifactsFolder } from "./output.js";
|
|
4
5
|
/** A promise paired with its settlers, for the set-once handle. */
|
|
5
6
|
const deferred = () => {
|
|
6
7
|
let resolve;
|
|
@@ -11,6 +12,11 @@ const deferred = () => {
|
|
|
11
12
|
});
|
|
12
13
|
return { promise, resolve, reject };
|
|
13
14
|
};
|
|
15
|
+
/** Duck-type an exit code off a thrown error (zx's ProcessOutput, command's exit error). */
|
|
16
|
+
const exitCodeOf = (error) => {
|
|
17
|
+
const code = error?.exitCode;
|
|
18
|
+
return typeof code === "number" ? code : undefined;
|
|
19
|
+
};
|
|
14
20
|
/**
|
|
15
21
|
* A promise for the next emit of `key`. It races the event against the
|
|
16
22
|
* instance's terminal events: if the action errors first it rejects with that
|
|
@@ -74,35 +80,58 @@ const build = (name, exec) => {
|
|
|
74
80
|
// handled so a settle-time rejection on such an instance doesn't surface as an
|
|
75
81
|
// unhandled rejection. Real awaiters of `ref` still receive the rejection.
|
|
76
82
|
void handle.promise.catch(() => { });
|
|
83
|
+
// The action's Outcome as a set-once promise. `start` resolves it (success or
|
|
84
|
+
// failure); `instance.done` / `once("done")` read it. It never rejects.
|
|
85
|
+
const settled = deferred();
|
|
77
86
|
const instance = {
|
|
78
87
|
name,
|
|
79
88
|
args,
|
|
80
89
|
ref: handle.promise,
|
|
90
|
+
done: settled.promise,
|
|
81
91
|
start: (system) =>
|
|
82
92
|
// `Promise.resolve().then` normalizes a sync result and routes a sync
|
|
83
93
|
// throw to the `error` path, so exec may be sync or async.
|
|
84
94
|
Promise.resolve()
|
|
85
|
-
.then(() => exec({
|
|
95
|
+
.then(() => exec({
|
|
96
|
+
...system,
|
|
97
|
+
emit,
|
|
98
|
+
attach,
|
|
99
|
+
proc: createProc(system.logs, system.signal),
|
|
100
|
+
artifactsFolder: artifactsFolder(),
|
|
101
|
+
}, ...args))
|
|
86
102
|
.then((result) => {
|
|
87
|
-
|
|
103
|
+
const outcome = { ok: true, value: result };
|
|
104
|
+
settled.resolve(outcome);
|
|
105
|
+
systemEmit("done", outcome);
|
|
88
106
|
// Settle `ref` if the action finished without attaching, so awaiters
|
|
89
107
|
// resolve (with `undefined` for the default void handle) rather than
|
|
90
108
|
// hang. Idempotent — a no-op when a handle was already attached.
|
|
91
109
|
handle.resolve(undefined);
|
|
92
|
-
return
|
|
110
|
+
return outcome;
|
|
93
111
|
}, (error) => {
|
|
112
|
+
const exitCode = exitCodeOf(error);
|
|
113
|
+
const outcome = exitCode === undefined ? { ok: false, error } : { ok: false, error, exitCode };
|
|
114
|
+
// `error` first, so a pending `once(<declaredEvent>)` rejects on it
|
|
115
|
+
// (settled before its event) rather than resolving on `done`.
|
|
94
116
|
systemEmit("error", error);
|
|
117
|
+
settled.resolve(outcome);
|
|
118
|
+
systemEmit("done", outcome);
|
|
95
119
|
// The action failed, so its handle will never arrive — reject `ref`
|
|
96
120
|
// with the same error instead of leaving awaiters hung. No-op if a
|
|
97
121
|
// handle was already attached before the failure.
|
|
98
122
|
handle.reject(error);
|
|
99
|
-
|
|
123
|
+
// Resolve (never rethrow): failure is the Outcome, not a rejection.
|
|
124
|
+
return outcome;
|
|
100
125
|
}),
|
|
101
126
|
on(key, handler) {
|
|
102
127
|
emitter.listen(key, handler);
|
|
103
128
|
},
|
|
104
129
|
once(key) {
|
|
105
|
-
|
|
130
|
+
// `done` always fires with the Outcome and never "fails" the await, so it
|
|
131
|
+
// reads straight from the settled promise; other keys race via awaitEvent.
|
|
132
|
+
return (key === "done"
|
|
133
|
+
? settled.promise
|
|
134
|
+
: awaitEvent(emitter, name, key));
|
|
106
135
|
},
|
|
107
136
|
// Wrap this instance in a fresh one (reusing the engine for its own
|
|
108
137
|
// emitter/ref/on/once) that fingerprints `paths` and either skips or
|
|
@@ -120,9 +149,13 @@ const build = (name, exec) => {
|
|
|
120
149
|
ctx.logs.append({ source: name, level: "info", message: "cached, skipping" });
|
|
121
150
|
return undefined;
|
|
122
151
|
}
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
152
|
+
const outcome = await instance.start({ logs: ctx.logs, signal: ctx.signal });
|
|
153
|
+
// Re-throw the inner failure so the wrapper fails too (the framework
|
|
154
|
+
// re-wraps it into this wrapper's own Outcome); only record on success.
|
|
155
|
+
if (!outcome.ok)
|
|
156
|
+
throw outcome.error;
|
|
157
|
+
await write(targets, fp);
|
|
158
|
+
return outcome.value;
|
|
126
159
|
})();
|
|
127
160
|
},
|
|
128
161
|
};
|
package/dist/core/output.d.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import type { LogsCollector } from "./log-collector/LogsCollector.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Absolute path to this run's output folder — `logs/<name>/<date>/<time>/`, where
|
|
4
|
+
* `run.log`, `run.jsonl`, and snapshots live. Exposed so actions can drop their
|
|
5
|
+
* own **artifacts** into the same per-run folder (one folder per run; one run per
|
|
6
|
+
* process). The folder is created on first access — synchronously, so a caller
|
|
7
|
+
* can write into it in the same tick — and the path is absolute so it's safe to
|
|
8
|
+
* hand to a child process with a different cwd.
|
|
9
|
+
*/
|
|
10
|
+
export declare const artifactsFolder: () => string;
|
|
2
11
|
/**
|
|
3
12
|
* Drains the collected log to two files in the run's output folder (the common
|
|
4
13
|
* `logs/<name>/<date>/<time>/` convention, local time):
|
package/dist/core/output.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createWriteStream } from "node:fs";
|
|
1
|
+
import { createWriteStream, mkdirSync } from "node:fs";
|
|
2
2
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { log } from "./log-collector/global.js";
|
|
@@ -24,6 +24,23 @@ const runDir = () => {
|
|
|
24
24
|
};
|
|
25
25
|
let made;
|
|
26
26
|
const ensureDir = () => (made ??= mkdir(runDir(), { recursive: true }).then(() => { }));
|
|
27
|
+
let ensuredSync = false;
|
|
28
|
+
/**
|
|
29
|
+
* Absolute path to this run's output folder — `logs/<name>/<date>/<time>/`, where
|
|
30
|
+
* `run.log`, `run.jsonl`, and snapshots live. Exposed so actions can drop their
|
|
31
|
+
* own **artifacts** into the same per-run folder (one folder per run; one run per
|
|
32
|
+
* process). The folder is created on first access — synchronously, so a caller
|
|
33
|
+
* can write into it in the same tick — and the path is absolute so it's safe to
|
|
34
|
+
* hand to a child process with a different cwd.
|
|
35
|
+
*/
|
|
36
|
+
export const artifactsFolder = () => {
|
|
37
|
+
const abs = path.resolve(runDir());
|
|
38
|
+
if (!ensuredSync) {
|
|
39
|
+
mkdirSync(abs, { recursive: true });
|
|
40
|
+
ensuredSync = true;
|
|
41
|
+
}
|
|
42
|
+
return abs;
|
|
43
|
+
};
|
|
27
44
|
/** A compact header block for the top of run.log — legend + run identity. */
|
|
28
45
|
const logHeader = () => {
|
|
29
46
|
const now = new Date();
|
package/dist/core/run.d.ts
CHANGED
|
@@ -1,95 +1,88 @@
|
|
|
1
1
|
import type { AnyActionInstance } from "./action.ts";
|
|
2
2
|
import { log as logs } from "./log-collector/global.ts";
|
|
3
3
|
/**
|
|
4
|
-
* A single action's failure, captured for the
|
|
5
|
-
* actions that
|
|
6
|
-
* excluded (see `launch`'s
|
|
4
|
+
* A single action's failure, captured for the spin's verdict. Cascade failures —
|
|
5
|
+
* actions that settled `{ ok: false }` only because teardown aborted them — are
|
|
6
|
+
* deliberately excluded (see `launch`'s outcome handling), so this records
|
|
7
|
+
* genuine faults, not fallout.
|
|
7
8
|
*/
|
|
8
9
|
export interface ActionFailure {
|
|
9
10
|
/** The failing action's name. */
|
|
10
11
|
readonly action: string;
|
|
11
|
-
/** Whatever it
|
|
12
|
+
/** Whatever it failed with (the Outcome's `error`). */
|
|
12
13
|
readonly error: unknown;
|
|
13
14
|
}
|
|
14
15
|
/**
|
|
15
|
-
* The terminal verdict of a
|
|
16
|
-
* not, what failed. {@link
|
|
16
|
+
* The terminal verdict of a spin: did everything that mattered succeed, and if
|
|
17
|
+
* not, what failed. {@link Spin.done} resolves (never rejects) with this, so a
|
|
17
18
|
* single `await` yields the outcome instead of forcing a try/catch on awaiters.
|
|
19
|
+
* A failing action is *recorded* here (so `ok` is false and the exit code is 1)
|
|
20
|
+
* but does **not** tear the spin down — see {@link Spin}.
|
|
18
21
|
*/
|
|
19
|
-
export interface
|
|
22
|
+
export interface SpinResult {
|
|
20
23
|
/** True when no genuine failure was recorded. */
|
|
21
24
|
readonly ok: boolean;
|
|
22
25
|
/** The genuine failures, in the order they surfaced. Empty when `ok`. */
|
|
23
26
|
readonly failures: readonly ActionFailure[];
|
|
24
27
|
}
|
|
25
28
|
/**
|
|
26
|
-
* The
|
|
29
|
+
* The spin's lifecycle. It only ever moves forward:
|
|
27
30
|
*
|
|
28
31
|
* open ──▶ closing ──▶ closed
|
|
29
32
|
*
|
|
30
33
|
* - **open** — launching and running actions; `.run(…)` is accepted.
|
|
31
|
-
* - **closing** — teardown has begun (cancel,
|
|
32
|
-
*
|
|
33
|
-
* `.run(…)` is refused.
|
|
34
|
+
* - **closing** — teardown has begun (cancel, Ctrl-C, natural completion, or an
|
|
35
|
+
* uncaught fatal). The signal is aborted, in-flight actions are unwinding, and
|
|
36
|
+
* new `.run(…)` is refused. Note: a failing *action* does not begin teardown.
|
|
34
37
|
* - **closed** — everything has settled, the log is flushed, the verdict is final.
|
|
35
38
|
*/
|
|
36
|
-
export type
|
|
37
|
-
/**
|
|
38
|
-
export interface
|
|
39
|
-
/**
|
|
40
|
-
* Abort the whole run the moment the first genuine failure surfaces
|
|
41
|
-
* (default `true`). Set `false` to let every action finish and collect all
|
|
42
|
-
* failures into the verdict — useful when you want the complete picture of
|
|
43
|
-
* what broke rather than just the first cause.
|
|
44
|
-
*/
|
|
45
|
-
readonly failFast?: boolean;
|
|
46
|
-
}
|
|
47
|
-
/** A live run: a set of actions sharing one abort controller and the global log. */
|
|
48
|
-
export interface Run {
|
|
49
|
-
/** The shared global log (output + events for everything in this run). */
|
|
39
|
+
export type SpinState = "open" | "closing" | "closed";
|
|
40
|
+
/** A live spin: a set of actions sharing one abort controller and the global log. */
|
|
41
|
+
export interface Spin {
|
|
42
|
+
/** The shared global log (output + events for everything in this spin). */
|
|
50
43
|
readonly logs: typeof logs;
|
|
51
|
-
/** The current lifecycle state — see {@link
|
|
52
|
-
readonly state:
|
|
53
|
-
/** Settles once the
|
|
54
|
-
readonly done: Promise<
|
|
55
|
-
/** The
|
|
44
|
+
/** The current lifecycle state — see {@link SpinState}. */
|
|
45
|
+
readonly state: SpinState;
|
|
46
|
+
/** Settles once the spin reaches `closed`, carrying the terminal {@link SpinResult}. */
|
|
47
|
+
readonly done: Promise<SpinResult>;
|
|
48
|
+
/** The spin's abort signal — for consumers that want to observe teardown. */
|
|
56
49
|
readonly signal: AbortSignal;
|
|
57
50
|
/**
|
|
58
|
-
* Launch more actions into this same
|
|
51
|
+
* Launch more actions into this same spin — same controller, same log. Chainable.
|
|
59
52
|
*
|
|
60
|
-
* Throws if the
|
|
53
|
+
* Throws if the spin is no longer `open`: launching into a closing/closed spin
|
|
61
54
|
* would bind the actions to an already-aborted signal, so they'd start and
|
|
62
55
|
* immediately unwind, silently doing nothing. Launch before teardown, or from
|
|
63
56
|
* a lifecycle event of an action that is still running (those fire while the
|
|
64
|
-
*
|
|
57
|
+
* spin is still `open`).
|
|
65
58
|
*/
|
|
66
|
-
run(...actions: AnyActionInstance[]):
|
|
59
|
+
run(...actions: AnyActionInstance[]): Spin;
|
|
67
60
|
/** Begin teardown: abort the signal (killing procs, unblocking waits, stopping daemons). */
|
|
68
61
|
cancel(): void;
|
|
69
62
|
/**
|
|
70
63
|
* Start streaming the global log live to a writer (console by default) in the
|
|
71
|
-
* background; it stops on its own when the
|
|
64
|
+
* background; it stops on its own when the spin settles. Returns the spin, so it
|
|
72
65
|
* chains. Await `.done` for the verdict.
|
|
73
66
|
*/
|
|
74
|
-
drain(write?: (message: string) => void):
|
|
67
|
+
drain(write?: (message: string) => void): Spin;
|
|
75
68
|
}
|
|
76
69
|
/**
|
|
77
|
-
* Starts the
|
|
78
|
-
* controller, against the process-wide global log. Returns a {@link
|
|
70
|
+
* Starts the spin: launches the given actions in parallel under one abort
|
|
71
|
+
* controller, against the process-wide global log. Returns a {@link Spin} — add
|
|
79
72
|
* more actions on demand with `.run(…)`, observe `.done` for the verdict, stop
|
|
80
|
-
* with `.cancel()`.
|
|
73
|
+
* with `.cancel()`.
|
|
81
74
|
*
|
|
82
|
-
* **Callable once per process.** A
|
|
83
|
-
* one global log per world; a second
|
|
84
|
-
*
|
|
85
|
-
*
|
|
75
|
+
* **Callable once per process.** A spin *is* the world being modelled, and there's
|
|
76
|
+
* one global log per world; a second call would silently share that log, so it
|
|
77
|
+
* throws instead. Attach further actions — including event-driven ones — to the
|
|
78
|
+
* returned {@link Spin} via its `.run(…)` method, not by calling this again.
|
|
86
79
|
*
|
|
87
80
|
* Each action runs against its own child logger (which headers and indents its
|
|
88
|
-
* lines into the global log) and the
|
|
81
|
+
* lines into the global log) and the spin's shared abort signal — both injected
|
|
89
82
|
* into its context, where the framework also binds a `proc` to them so child
|
|
90
|
-
* processes stream into the right log and die on teardown.
|
|
91
|
-
* the
|
|
92
|
-
*
|
|
83
|
+
* processes stream into the right log and die on teardown. A **failing action
|
|
84
|
+
* never tears the spin down** — its `{ ok: false }` outcome is recorded in the
|
|
85
|
+
* verdict (and sets exit code 1) and the spin keeps running. Teardown to `closed`
|
|
86
|
+
* happens only on Ctrl-C, `.cancel()`, natural completion, or an uncaught fatal.
|
|
93
87
|
*/
|
|
94
|
-
export declare function run(...actions: AnyActionInstance[]):
|
|
95
|
-
export declare function run(options: RunOptions, ...actions: AnyActionInstance[]): Run;
|
|
88
|
+
export declare function run(...actions: AnyActionInstance[]): Spin;
|
package/dist/core/run.js
CHANGED
|
@@ -3,7 +3,7 @@ import { log as logs } from "./log-collector/global.js";
|
|
|
3
3
|
import { createRenderer } from "./log-collector/render.js";
|
|
4
4
|
import { streamLog, writeLog } from "./output.js";
|
|
5
5
|
import { marker } from "./markers.js";
|
|
6
|
-
/** One
|
|
6
|
+
/** One spin per process — a single world over the one global log. See {@link run}. */
|
|
7
7
|
let started = false;
|
|
8
8
|
/** Narrate a run-lifecycle milestone into the global log (renders as a `[run]` line). */
|
|
9
9
|
const narrate = (message) => logs.append({ source: "run", level: "run", message });
|
|
@@ -32,19 +32,29 @@ function assertInstance(value) {
|
|
|
32
32
|
: "";
|
|
33
33
|
throw new Error(`run() expects action instances; received ${typeof value}${hint}`);
|
|
34
34
|
}
|
|
35
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Starts the spin: launches the given actions in parallel under one abort
|
|
37
|
+
* controller, against the process-wide global log. Returns a {@link Spin} — add
|
|
38
|
+
* more actions on demand with `.run(…)`, observe `.done` for the verdict, stop
|
|
39
|
+
* with `.cancel()`.
|
|
40
|
+
*
|
|
41
|
+
* **Callable once per process.** A spin *is* the world being modelled, and there's
|
|
42
|
+
* one global log per world; a second call would silently share that log, so it
|
|
43
|
+
* throws instead. Attach further actions — including event-driven ones — to the
|
|
44
|
+
* returned {@link Spin} via its `.run(…)` method, not by calling this again.
|
|
45
|
+
*
|
|
46
|
+
* Each action runs against its own child logger (which headers and indents its
|
|
47
|
+
* lines into the global log) and the spin's shared abort signal — both injected
|
|
48
|
+
* into its context, where the framework also binds a `proc` to them so child
|
|
49
|
+
* processes stream into the right log and die on teardown. A **failing action
|
|
50
|
+
* never tears the spin down** — its `{ ok: false }` outcome is recorded in the
|
|
51
|
+
* verdict (and sets exit code 1) and the spin keeps running. Teardown to `closed`
|
|
52
|
+
* happens only on Ctrl-C, `.cancel()`, natural completion, or an uncaught fatal.
|
|
53
|
+
*/
|
|
54
|
+
export function run(...actions) {
|
|
36
55
|
if (started) {
|
|
37
56
|
throw new Error("run() may only be called once per process — attach more actions to the existing run with its .run(…) method.");
|
|
38
57
|
}
|
|
39
|
-
// Peel an optional leading options bag off the variadic actions, keeping the
|
|
40
|
-
// bare `run(...actions)` call form working unchanged. Options are a plain
|
|
41
|
-
// object (no `.start`); a *function* first arg is never options — it's an
|
|
42
|
-
// uncalled action, which `assertInstance` below reports clearly.
|
|
43
|
-
const first = args[0];
|
|
44
|
-
const hasOptions = first !== undefined && !isActionInstance(first) && typeof first !== "function";
|
|
45
|
-
const options = hasOptions ? first : {};
|
|
46
|
-
const actions = (hasOptions ? args.slice(1) : args);
|
|
47
|
-
const failFast = options.failFast ?? true;
|
|
48
58
|
// Validate eagerly, before the one-run latch flips, so a bad argument throws
|
|
49
59
|
// cleanly and leaves the process's single run slot still usable.
|
|
50
60
|
for (const instance of actions)
|
|
@@ -77,13 +87,15 @@ export function run(...args) {
|
|
|
77
87
|
narrate(`tearing down · ${reason}`);
|
|
78
88
|
controller.abort();
|
|
79
89
|
};
|
|
80
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* Record a genuine fault in the verdict. It does **not** tear the spin down — a
|
|
92
|
+
* failing action is data, and the spin keeps running (only Ctrl-C / cancel /
|
|
93
|
+
* completion / an uncaught fatal close it).
|
|
94
|
+
*/
|
|
81
95
|
const recordFailure = (action, error) => {
|
|
82
96
|
failures.push({ action, error });
|
|
83
|
-
if (failFast)
|
|
84
|
-
beginClosing(`failed: ${action}`);
|
|
85
97
|
};
|
|
86
|
-
// Ctrl-C tears the
|
|
98
|
+
// Ctrl-C tears the spin down gracefully through the lifecycle, like cancel().
|
|
87
99
|
const onSigint = () => beginClosing("interrupted (SIGINT)");
|
|
88
100
|
process.once("SIGINT", onSigint);
|
|
89
101
|
// A throw or rejection that escaped every action's own handling. Without a
|
|
@@ -100,10 +112,10 @@ export function run(...args) {
|
|
|
100
112
|
message: error instanceof Error ? (error.stack ?? error.message) : String(error),
|
|
101
113
|
});
|
|
102
114
|
// Genuine fault vs. teardown fallout: only one that surfaces *before* the
|
|
103
|
-
// signal aborts is the
|
|
115
|
+
// signal aborts is the spin's own (mirrors `launch`'s cascade rule); a throw
|
|
104
116
|
// during unwind is logged but doesn't pollute the verdict. Either way tear
|
|
105
|
-
// down — an
|
|
106
|
-
//
|
|
117
|
+
// down — unlike an action's own failure, an *uncaught* fatal leaves the
|
|
118
|
+
// process state undefined, so we always finish and flush.
|
|
107
119
|
if (!controller.signal.aborted)
|
|
108
120
|
failures.push({ action: kind, error });
|
|
109
121
|
beginClosing(kind);
|
|
@@ -119,9 +131,11 @@ export function run(...args) {
|
|
|
119
131
|
const launch = (instance) => {
|
|
120
132
|
narrate(`launch ${instance.name}`);
|
|
121
133
|
// The action logs into the one shared store (entries carry their `source`;
|
|
122
|
-
// grouping is applied at render time) and gets the
|
|
134
|
+
// grouping is applied at render time) and gets the spin's signal directly; the
|
|
123
135
|
// framework injects a `proc` bound to both, so there's no ambient context.
|
|
124
|
-
|
|
136
|
+
// `start()` resolves with the action's Outcome — it never rejects for the
|
|
137
|
+
// action's own failure — so a failure is handled here as data, not a throw.
|
|
138
|
+
const record = (error) => {
|
|
125
139
|
// Always record the fault in the action's own log, under its name.
|
|
126
140
|
logs.append({
|
|
127
141
|
source: instance.name,
|
|
@@ -129,13 +143,20 @@ export function run(...args) {
|
|
|
129
143
|
message: error instanceof Error ? (error.stack ?? error.message) : String(error),
|
|
130
144
|
});
|
|
131
145
|
// Genuine failure vs. teardown fallout: if the signal was already aborted
|
|
132
|
-
// when this surfaced, the
|
|
133
|
-
//
|
|
134
|
-
// verdict. The *first* real error is caught here before `recordFailure`
|
|
135
|
-
// triggers the abort, so `signal.aborted` is still false for it.
|
|
146
|
+
// when this surfaced, the failure is almost certainly the abort killing the
|
|
147
|
+
// action (a cascade), not its own fault — so it must not pollute the verdict.
|
|
136
148
|
if (!controller.signal.aborted)
|
|
137
149
|
recordFailure(instance.name, error);
|
|
138
|
-
}
|
|
150
|
+
};
|
|
151
|
+
const promise = instance
|
|
152
|
+
.start({ logs, signal: controller.signal })
|
|
153
|
+
.then((outcome) => {
|
|
154
|
+
if (!outcome.ok)
|
|
155
|
+
record(outcome.error);
|
|
156
|
+
})
|
|
157
|
+
// Defensive: `start()` shouldn't reject, but if the framework itself throws,
|
|
158
|
+
// treat it as a fault rather than losing it to an unhandled rejection.
|
|
159
|
+
.catch(record);
|
|
139
160
|
inFlight.add(promise);
|
|
140
161
|
void promise.finally(() => inFlight.delete(promise));
|
|
141
162
|
};
|
|
@@ -198,7 +219,7 @@ export function run(...args) {
|
|
|
198
219
|
signal: controller.signal,
|
|
199
220
|
run(...more) {
|
|
200
221
|
if (state !== "open") {
|
|
201
|
-
throw new Error(`
|
|
222
|
+
throw new Error(`spin.run(…) rejected: the spin is "${state}", not "open". Launch actions before ` +
|
|
202
223
|
`teardown, or from a lifecycle event of an action that is still running.`);
|
|
203
224
|
}
|
|
204
225
|
for (const instance of more)
|
package/dist/core/spin.d.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { type AnyActionInstance } from "./action.ts";
|
|
2
|
-
import { type
|
|
2
|
+
import { type Spin } from "./run.ts";
|
|
3
3
|
/**
|
|
4
|
-
* Launch an action into the spin
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Launch an action into the spin and get the same instance back, so you can
|
|
5
|
+
* `await` its `.done` (its {@link import("./action.ts").Outcome} — resolves with
|
|
6
|
+
* `{ ok, value } | { ok, error }`, **never throws**), its `.ref` (the attached
|
|
7
|
+
* handle; rejects on failure, so it's for data deps consumed inside other
|
|
8
|
+
* actions), or subscribe with `.on` / `.once`. A nod is how the body waves an
|
|
9
|
+
* action in.
|
|
7
10
|
*/
|
|
8
11
|
export type Nod = <I extends AnyActionInstance>(instance: I) => I;
|
|
9
|
-
/** Tunes a {@link spin} —
|
|
10
|
-
export interface SpinOptions
|
|
12
|
+
/** Tunes a {@link spin} — currently just how it drains. */
|
|
13
|
+
export interface SpinOptions {
|
|
11
14
|
/**
|
|
12
15
|
* Stream the log to the console automatically from the start. Default `true`.
|
|
13
16
|
* Set `false` when you need to drain later via {@link SpinContext.drain} —
|
|
@@ -17,42 +20,53 @@ export interface SpinOptions extends RunOptions {
|
|
|
17
20
|
}
|
|
18
21
|
/** What a {@link spin} body receives. */
|
|
19
22
|
export interface SpinContext {
|
|
20
|
-
/** Launch an action into the
|
|
23
|
+
/** Launch an action into the spin; returns the instance for awaiting/chaining. */
|
|
21
24
|
readonly nod: Nod;
|
|
22
25
|
/**
|
|
23
|
-
* Start streaming the
|
|
26
|
+
* Start streaming the spin's log to the console now. Only needed when the spin
|
|
24
27
|
* was created with `drain: false`; idempotent, so it's safe to call regardless.
|
|
25
28
|
*/
|
|
26
|
-
readonly drain:
|
|
29
|
+
readonly drain: Spin["drain"];
|
|
27
30
|
/**
|
|
28
|
-
* Tear the
|
|
31
|
+
* Tear the spin down (abort the signal, stopping daemons). Use it to end a spin
|
|
29
32
|
* whose work is finished but which is held open by a daemon — e.g. once a
|
|
30
33
|
* one-shot build completes while a watcher is still running.
|
|
31
34
|
*/
|
|
32
|
-
readonly cancel:
|
|
33
|
-
/** The
|
|
35
|
+
readonly cancel: Spin["cancel"];
|
|
36
|
+
/** The spin's abort signal — honor it to stop early. */
|
|
34
37
|
readonly signal: AbortSignal;
|
|
38
|
+
/**
|
|
39
|
+
* Absolute path to this spin's output folder (`logs/<name>/<date>/<time>/`),
|
|
40
|
+
* created up front. Hand it to actions (or use `ctx.artifactsFolder` inside
|
|
41
|
+
* one) to write artifacts beside `run.log`.
|
|
42
|
+
*/
|
|
43
|
+
readonly artifactsFolder: string;
|
|
35
44
|
}
|
|
36
45
|
/** The body of a {@link spin}: any (async) function — it isn't named or wrapped by you. */
|
|
37
46
|
export type SpinBody = (ctx: SpinContext) => void | Promise<void>;
|
|
38
47
|
/**
|
|
39
|
-
* Runs a linear orchestration. You pass a plain (async) function;
|
|
48
|
+
* Runs a linear orchestration. You pass a plain (async) function; omkit hosts it
|
|
40
49
|
* in a single in-flight action and hands it a {@link Nod}. Because that host
|
|
41
|
-
* stays in-flight while your body `await`s, the
|
|
42
|
-
* steps — so you write `await` / `if` control flow instead of nested
|
|
43
|
-
*
|
|
44
|
-
*
|
|
50
|
+
* stays in-flight while your body `await`s, the spin never idles shut between
|
|
51
|
+
* steps — so you write `await` / `if` control flow instead of nested callbacks.
|
|
52
|
+
* When the body returns, the daemons it nodded in keep the spin alive until
|
|
53
|
+
* teardown.
|
|
54
|
+
*
|
|
55
|
+
* A failing action **never throws into the body** — `await nod(x).done` yields an
|
|
56
|
+
* {@link import("./action.ts").Outcome} you inspect, and the spin keeps going
|
|
57
|
+
* (the failure is recorded in the verdict and sets exit code 1):
|
|
45
58
|
*
|
|
46
|
-
* spin({
|
|
47
|
-
*
|
|
48
|
-
* nod(
|
|
59
|
+
* spin({ drain: false }, async ({ nod }) => {
|
|
60
|
+
* const ans = await nod(prompt({ … })).done; // Outcome — never throws
|
|
61
|
+
* if (ans.ok && ans.value === "yes") await nod(test).done;
|
|
62
|
+
* nod(devServer); // fire-and-forget daemon
|
|
49
63
|
* const chrome = nod(chromedriver({ … }));
|
|
50
|
-
* nod(chromePage(chrome.ref));
|
|
64
|
+
* nod(chromePage(chrome.ref)); // data deps via .ref
|
|
51
65
|
* });
|
|
52
66
|
*
|
|
53
67
|
* Auto-drains by default; pass `drain: false` and call `ctx.drain()` yourself to
|
|
54
68
|
* stream the log only from a chosen point (e.g. after a `prompt`). Returns the
|
|
55
|
-
* {@link
|
|
69
|
+
* {@link Spin} — `await` its `.done` for the verdict.
|
|
56
70
|
*/
|
|
57
|
-
export declare function spin(body: SpinBody):
|
|
58
|
-
export declare function spin(options: SpinOptions, body: SpinBody):
|
|
71
|
+
export declare function spin(body: SpinBody): Spin;
|
|
72
|
+
export declare function spin(options: SpinOptions, body: SpinBody): Spin;
|
package/dist/core/spin.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { action } from "./action.js";
|
|
2
2
|
import { run } from "./run.js";
|
|
3
|
+
import { artifactsFolder } from "./output.js";
|
|
3
4
|
export function spin(a, b) {
|
|
4
5
|
const [options, body] = typeof a === "function" ? [{}, a] : [a, b];
|
|
5
|
-
const { drain: autoDrain = true
|
|
6
|
+
const { drain: autoDrain = true } = options;
|
|
6
7
|
// `run(...)` below assigns `host` synchronously, before the action's async body
|
|
7
|
-
// ever runs — so the closures here capture the
|
|
8
|
+
// ever runs — so the closures here capture the spin safely.
|
|
8
9
|
let host;
|
|
9
10
|
// One-shot drain: auto-drain and a manual ctx.drain() can't double-subscribe.
|
|
10
11
|
let drained = false;
|
|
@@ -20,9 +21,15 @@ export function spin(a, b) {
|
|
|
20
21
|
host.run(instance);
|
|
21
22
|
return instance;
|
|
22
23
|
};
|
|
23
|
-
await body({
|
|
24
|
+
await body({
|
|
25
|
+
nod,
|
|
26
|
+
drain: startDrain,
|
|
27
|
+
cancel: () => host.cancel(),
|
|
28
|
+
signal,
|
|
29
|
+
artifactsFolder: artifactsFolder(),
|
|
30
|
+
});
|
|
24
31
|
});
|
|
25
|
-
host = run(
|
|
32
|
+
host = run(hosted());
|
|
26
33
|
if (autoDrain)
|
|
27
34
|
startDrain();
|
|
28
35
|
return host;
|
package/dist/index.d.ts
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* renderer, marker prefixing) are intentionally not exported.
|
|
10
10
|
*/
|
|
11
11
|
export { action } from "./core/action.ts";
|
|
12
|
-
export type { Action, ActionBuilder, ActionInstance, AnyActionInstance, ActionContext, SystemGlobal, SystemEvents, InstanceEvents, NoEvents, Awaitable, } from "./core/action.ts";
|
|
12
|
+
export type { Action, ActionBuilder, ActionInstance, AnyActionInstance, ActionContext, SystemGlobal, SystemEvents, InstanceEvents, NoEvents, Awaitable, Outcome, } from "./core/action.ts";
|
|
13
13
|
export { run } from "./core/run.ts";
|
|
14
|
-
export type {
|
|
14
|
+
export type { Spin, SpinResult, SpinState, ActionFailure } from "./core/run.ts";
|
|
15
15
|
export { spin } from "./core/spin.ts";
|
|
16
16
|
export type { Nod, SpinOptions, SpinContext, SpinBody } from "./core/spin.ts";
|
|
17
17
|
export { events } from "./core/events.ts";
|
|
@@ -23,4 +23,5 @@ export type { Logger, LogEntry, LogInput, SubscribeOptions, } from "./core/log-c
|
|
|
23
23
|
export { log } from "./core/log-collector/global.ts";
|
|
24
24
|
export { snapshot, captureSnapshot } from "./core/output.ts";
|
|
25
25
|
export type { SnapshotRef } from "./core/output.ts";
|
|
26
|
+
export { artifactsFolder } from "./core/output.ts";
|
|
26
27
|
export { fingerprint, cacheDir } from "./core/folder-cache.ts";
|
package/dist/index.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
// ── Actions ────────────────────────────────────────────────────────────────
|
|
12
12
|
export { action } from "./core/action.js";
|
|
13
|
-
// ──
|
|
13
|
+
// ── Spin (the run handle) ────────────────────────────────────────────────────
|
|
14
14
|
export { run } from "./core/run.js";
|
|
15
15
|
// ── Spin (linear orchestration) ──────────────────────────────────────────────
|
|
16
16
|
export { spin } from "./core/spin.js";
|
|
@@ -23,6 +23,10 @@ export { LogsCollector } from "./core/log-collector/LogsCollector.js";
|
|
|
23
23
|
export { log } from "./core/log-collector/global.js";
|
|
24
24
|
// ── Snapshots ──────────────────────────────────────────────────────────────
|
|
25
25
|
export { snapshot, captureSnapshot } from "./core/output.js";
|
|
26
|
+
// ── Artifacts ────────────────────────────────────────────────────────────────
|
|
27
|
+
// This run's output folder (absolute) — also on the `spin`/action `ctx`. Import
|
|
28
|
+
// it directly for artifact-writing helpers that aren't inside an action.
|
|
29
|
+
export { artifactsFolder } from "./core/output.js";
|
|
26
30
|
// ── Folder cache ─────────────────────────────────────────────────────────────
|
|
27
31
|
// Backs the `.withCache(...inputs)` method on instances (fingerprints an
|
|
28
32
|
// action's input files/folders, not its outputs); exported for scripts that
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omkit",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7",
|
|
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",
|