omkit 0.1.8 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -51
- package/dist/actions/command.d.ts +3 -2
- package/dist/actions/command.js +11 -9
- package/dist/core/ActionRun.d.ts +13 -2
- package/dist/core/ActionRun.js +64 -6
- package/dist/core/ExecutionTree.d.ts +1 -1
- package/dist/core/ExecutionTree.js +19 -6
- package/dist/core/action.d.ts +2 -1
- package/dist/core/action.js +11 -49
- package/dist/core/context.d.ts +4 -4
- package/dist/core/context.js +4 -4
- package/dist/core/om.d.ts +7 -5
- package/dist/core/om.js +11 -6
- package/dist/core/step.js +0 -1
- package/dist/core/types.d.ts +16 -23
- package/dist/foundation/callsite.d.ts +5 -0
- package/dist/foundation/callsite.js +5 -0
- package/dist/foundation/ids.d.ts +8 -0
- package/dist/foundation/ids.js +12 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/output/LiveRenderer.js +4 -1
- package/dist/output/folder/RunFolder.d.ts +8 -5
- package/dist/output/folder/RunFolder.js +13 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,124 +1,195 @@
|
|
|
1
1
|
# omkit
|
|
2
2
|
|
|
3
|
-
**The operational-model kit.**
|
|
3
|
+
**The operational-model kit.** _Humans orchestrate. Runs narrate. AI assistants follow along._
|
|
4
4
|
|
|
5
|
-
A tiny runtime for the workflows _around_ your code —
|
|
5
|
+
A tiny runtime for the workflows _around_ your code — start servers, wait for health checks, build, watch, drive a browser, read state back, and tear it all down together. You write the orchestration as ordinary TypeScript; the run **narrates itself** into a structured, on-disk record — what launched, what came up, what attached, what failed, what the world actually looked like — that an AI assistant can read instead of guessing from terminal scrollback. Where [tskb](https://www.npmjs.com/package/tskb) is the _knowledge_ layer (what your system **is**), `omkit` is the _operational_ one (what it's **doing right now**).
|
|
6
6
|
|
|
7
7
|
## The problem
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
You know the script. Start the API, start the web server, wait until they're _actually_ serving, open the app, maybe run a smoke check — then Ctrl+C and hope everything shut down. Most of us glue this together with `concurrently`, `wait-on`, a shell script, and a pile of opaque interleaved output. When it breaks, you're grepping stdout to find out which process died.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Now hand that same script to an AI assistant. It has it even worse: interleaved stdout is all it can see, so it _infers_ — "the server probably started", "the build likely passed" — and acts on guesses. The productivity you wanted from the assistant leaks away into re-running things, misreading logs, and asserting success that never happened.
|
|
12
|
+
|
|
13
|
+
`omkit` models the workflow instead. Each step is a typed **action** in one run. Actions don't share globals — they hand each other typed **capabilities** (a port, a client, a live browser page), emit typed **events**, and land on **one log**. Bring the whole thing down together, and get a structured record of what happened — one a human can skim and an assistant can verify against.
|
|
12
14
|
|
|
13
15
|
```ts
|
|
14
16
|
import { om } from "omkit";
|
|
15
17
|
import { command, healthcheck, chromePage } from "omkit/actions";
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
// Define once. `command` names the action after the command; calling it launches.
|
|
20
|
+
const api = command("npm run dev", { cwd: "api" });
|
|
21
|
+
const web = command("npm run dev", { cwd: "web" });
|
|
22
|
+
|
|
23
|
+
om("dev", async () => {
|
|
24
|
+
api().tag("api"); // start both dev servers as daemons…
|
|
25
|
+
web().tag("web");
|
|
19
26
|
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
// …wait until the app actually serves (not just "process started")…
|
|
28
|
+
const up = await healthcheck({ url: "http://localhost:3000" }).result;
|
|
29
|
+
if (!up.ok) return; // gave up — the servers stay up so you can debug them
|
|
22
30
|
|
|
23
|
-
// …then hand the live page downstream — typed, not scraped
|
|
24
|
-
const page = chromePage("app", "localhost:9222", { url: "http://localhost:3000" })
|
|
25
|
-
await
|
|
31
|
+
// …then open it in a real browser and hand the live page downstream — typed, not scraped.
|
|
32
|
+
const page = chromePage("app", "localhost:9222", { url: "http://localhost:3000" });
|
|
33
|
+
console.log(`opened ${(await page.ref).url()}`);
|
|
26
34
|
|
|
27
|
-
console.log("up — press Ctrl+C to stop");
|
|
35
|
+
console.log("stack is up — press Ctrl+C to stop");
|
|
28
36
|
});
|
|
29
37
|
```
|
|
30
38
|
|
|
31
|
-
`page.ref` isn't a URL scraped from stdout — it's
|
|
39
|
+
`page.ref` isn't a URL scraped from stdout — it's the live Playwright `Page`, typed, that the next step drives directly. Few workflow tools pass anything but strings between steps.
|
|
32
40
|
|
|
33
41
|
```
|
|
34
42
|
om()
|
|
35
43
|
│
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
┌────────────────┼────────────────┐
|
|
45
|
+
▼ ▼ ▼
|
|
46
|
+
command healthcheck chromePage
|
|
47
|
+
api · web (gate) │ .ref → live Page
|
|
48
|
+
(daemons) ▼
|
|
49
|
+
your smoke test
|
|
42
50
|
```
|
|
43
51
|
|
|
52
|
+
## Who it's for
|
|
53
|
+
|
|
54
|
+
- **Humans orchestrate.** Pipelines are ordinary TypeScript — `await`, `if`, loops, variables — not YAML, not a DSL. Define an action once; launch it by calling it.
|
|
55
|
+
- **AI assistants follow runs.** The run folder (`result.json`, `raw.jsonl`, per-action logs, snapshots) is a machine-readable account of what really happened — an assistant can verify outcomes, cite evidence, and pick up where a run left off.
|
|
56
|
+
- **Together, faster.** You express intent; the run records ground truth; the assistant acts on the record instead of re-running things to guess. Fewer cycles lost to "did that actually work?".
|
|
57
|
+
|
|
44
58
|
## Core concepts
|
|
45
59
|
|
|
46
60
|
### Actions & Activities
|
|
47
61
|
|
|
48
|
-
An **Action** is
|
|
62
|
+
An **Action** is a named, typed description of work. **Calling it launches** it and returns a live **Activity** — the handle you configure (`withCache`, `tag`) and observe (`result`, `ref`, events).
|
|
49
63
|
|
|
50
64
|
```ts
|
|
51
65
|
import { action } from "omkit";
|
|
52
66
|
|
|
53
|
-
const
|
|
67
|
+
const build = action("build").run(({ proc }) => proc("tsc")`tsc -b`);
|
|
54
68
|
|
|
55
|
-
const activity =
|
|
69
|
+
const activity = build(); // calling launches; returns the live Activity
|
|
56
70
|
```
|
|
57
71
|
|
|
58
|
-
`om(async (ctx) => …)` hosts
|
|
72
|
+
`om(name, async (ctx) => …)` hosts the orchestration as the root of a run. The name plus the file it's defined in identify the run — logs land in `logs/<name>-<hash8>/…`, so same-named oms in different files never share a folder. You write ordinary `await` / `if` / loops / variables; the Activities you launch keep running in parallel, and because the body stays in-flight while you `await`, the run never idles shut between steps. Config chained on an Activity before your first `await` (like `withCache`) applies before its body runs. For a one-off inline step, `step(name, fn)` runs `fn` as its own node without a reusable definition.
|
|
59
73
|
|
|
60
74
|
### Typed capabilities
|
|
61
75
|
|
|
62
|
-
An action can `attach` a value — a port, a client, a page — that downstream actions receive by awaiting `activity.ref`. Not a file descriptor: a typed runtime handoff, so
|
|
76
|
+
An action can `attach` a value — a port, a client, a page — that downstream actions receive by awaiting `activity.ref`. Not a file descriptor scraped from a log: a typed runtime handoff, so steps chain without globals or string-parsing.
|
|
63
77
|
|
|
64
78
|
```ts
|
|
65
|
-
const
|
|
66
|
-
.ref<number>()
|
|
79
|
+
const server = action("server")
|
|
80
|
+
.ref<number>() // this action publishes a port
|
|
67
81
|
.run(async ({ attach, signal }) => {
|
|
68
|
-
|
|
69
|
-
|
|
82
|
+
const port = await listen(); // start listening…
|
|
83
|
+
attach(port); // …publish the port to downstream actions…
|
|
84
|
+
await until(signal); // …and stay up until teardown
|
|
70
85
|
});
|
|
71
86
|
|
|
72
|
-
const
|
|
87
|
+
const migrate = action("migrate").run((_ctx, port: Promise<number>) => runMigrations(port));
|
|
73
88
|
|
|
74
|
-
om(async () => {
|
|
75
|
-
const
|
|
76
|
-
|
|
89
|
+
om("migrate", async () => {
|
|
90
|
+
const s = server();
|
|
91
|
+
migrate(s.ref); // migrate receives the port the moment the server attaches it — typed
|
|
77
92
|
});
|
|
78
93
|
```
|
|
79
94
|
|
|
80
95
|
### Outcomes — a result that doesn't throw
|
|
81
96
|
|
|
82
|
-
Every Activity settles to a typed **`Outcome`**, read via `activity.result
|
|
97
|
+
Every Activity settles to a typed **`Outcome`**, read via `activity.result`, which **never throws**. Operational failures are values you inspect, not exceptions you catch:
|
|
83
98
|
|
|
84
99
|
```ts
|
|
85
100
|
type Outcome<T> = { ok: true; value: T } | { ok: false; error: unknown };
|
|
86
101
|
|
|
87
|
-
const
|
|
88
|
-
if (!
|
|
89
|
-
// an expected
|
|
102
|
+
const tests = await command("npm test")().result;
|
|
103
|
+
if (!tests.ok) {
|
|
104
|
+
// red tests are an expected outcome here — decide what to do, don't crash
|
|
90
105
|
}
|
|
91
106
|
```
|
|
92
107
|
|
|
93
|
-
Awaiting `.result` also _observes_ the Activity (see teardown
|
|
108
|
+
Awaiting `.result` also _observes_ the Activity (see teardown). A cancelled Activity resolves `{ ok: false, error: CancelledError }`.
|
|
94
109
|
|
|
95
110
|
### Failure & teardown
|
|
96
111
|
|
|
97
|
-
omkit uses **structured supervision**: a failure that **nobody is watching** tears the whole run down and marks it failed. An Activity is "watched" if — before it fails — you awaited its `.result` (or `.ref`),
|
|
112
|
+
omkit uses **structured supervision**: a failure that **nobody is watching** tears the whole run down and marks it failed. An Activity is "watched" if — before it fails — you awaited its `.result` (or `.ref`), added an `on("error")` listener, or attached `.handleFailure`.
|
|
98
113
|
|
|
99
|
-
- **Await it** (`await
|
|
100
|
-
- **Fire-and-forget it** (`
|
|
114
|
+
- **Await it** (`await task().result`) → the failure is yours to inspect as `{ ok: false }`; the run stays green.
|
|
115
|
+
- **Fire-and-forget it** (`task()`, never awaited) → an unobserved crash tears the run down. This is the guardrail for daemons: a dev server that dies fails the run instead of leaving it wedged.
|
|
101
116
|
- **`.handleFailure(fn)`** → own a fire-and-forget Activity's failure: `fn(error)` runs instead of tearing down.
|
|
102
117
|
|
|
103
118
|
```ts
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
.exec()
|
|
107
|
-
.handleFailure((e) => log(e)); // …unless you own it
|
|
119
|
+
web().tag("web"); // if the web daemon crashes, the run tears down — you'll know
|
|
120
|
+
flakyBackgroundJob().handleFailure((e) => console.warn("job failed, carrying on", e));
|
|
108
121
|
```
|
|
109
122
|
|
|
110
|
-
**Ctrl+C** and **`ctx.cancel()`** tear everything down gracefully — the log is always written. And **success is keep-alive**: when your `om` body returns, daemons it
|
|
123
|
+
**Ctrl+C** and **`ctx.cancel()`** tear everything down gracefully — procs are killed, waits unblock, the log is always written. And **success is keep-alive**: when your `om` body returns, the daemons it started keep running until Ctrl+C or `cancel()`, so wiring things up doesn't kill the servers you just started.
|
|
111
124
|
|
|
112
|
-
### One log, on disk
|
|
125
|
+
### One log, on disk — a run an assistant can follow
|
|
113
126
|
|
|
114
|
-
Every action's output, events, asserts, snapshots, and lifecycle land on **one timeline
|
|
127
|
+
Every action's output, events, asserts, snapshots, and lifecycle land on **one timeline** — streamed live to the terminal (curated milestones) and written to a per-run folder:
|
|
115
128
|
|
|
116
129
|
- `result.json` — the run's tree, per-node status, and verdict.
|
|
117
130
|
- `raw.jsonl` — every entry, machine-readable.
|
|
118
|
-
- `main.log` + one `.log` per
|
|
131
|
+
- `main.log` + one `.log` per action — the human-readable timelines.
|
|
119
132
|
- `events.log` / `asserts.log` / `snapshots.log` — cross-cutting rollups.
|
|
120
133
|
|
|
121
|
-
`ctx` also gives each action `assert(
|
|
134
|
+
`ctx` also gives each action `assert(cond, msg)` (tallies into the run's verdict) and `snapshot(name, value)` (captures JSON state to the run folder) — so a run is an inspectable artifact, not just an exit code.
|
|
135
|
+
|
|
136
|
+
Run folders have a **stable identity**: `logs/<name>-<hash8>/<date>/<time>/`, keyed by the om's name and the file that defines it. An assistant (or a script) can always find "the latest `tskb-dev` run" without parsing scrollback, diff two runs of the same pipeline, or answer questions with evidence instead of inference: _did the server actually pass its healthcheck? which process died first? what config did the build run with?_ It's all in the folder — attributed per action, timestamped, with a verdict.
|
|
137
|
+
|
|
138
|
+
### Observing the real world, not assuming it
|
|
139
|
+
|
|
140
|
+
The batteries are built around **ground truth**. `healthcheck` gates on the service actually answering, not on "process started". `chromePage` hands downstream steps a live browser page — real DOM, real network — so a smoke check inspects the app a user would see. `snapshot` freezes the inputs and state a run saw, and `assert` turns observations into a tallied verdict. For a human, that means less babysitting; for an AI assistant, it means the run folder **is** its view of the world — it follows what happened rather than imagining it.
|
|
141
|
+
|
|
142
|
+
## Common recipes
|
|
143
|
+
|
|
144
|
+
**Skip work that's already done.** `withCache` fingerprints inputs and skips the body when nothing changed since the last successful run (a hit resolves `undefined`):
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
const build = action("build").run(({ proc }) => proc("tsc")`tsc -b`);
|
|
148
|
+
|
|
149
|
+
om("build", async () => {
|
|
150
|
+
const out = await build().withCache(`${process.cwd()}/src`).result;
|
|
151
|
+
if (out.ok && out.value === undefined) console.log("no changes — skipped the build");
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**Watch and react.** Launch a watcher, subscribe to its events, and let the run stay alive:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { watchDir } from "omkit/actions";
|
|
159
|
+
|
|
160
|
+
om("watch", async () => {
|
|
161
|
+
const watcher = watchDir("src").tag("watch");
|
|
162
|
+
watcher.on("update", (file) => console.log(`changed: ${file}`));
|
|
163
|
+
// body returns, but the watcher keeps the run alive until Ctrl+C
|
|
164
|
+
});
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
**Gate a deploy on green tests.** Observe the outcome and branch — no try/catch:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
om("ship", async () => {
|
|
171
|
+
const tests = await command("npm test")().result;
|
|
172
|
+
if (!tests.ok) return; // red → stop; the run stays green because you observed it
|
|
173
|
+
await command("./deploy.sh")().result;
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**Keep a human in the loop.** `prompt` asks the terminal and falls back to a default when unattended, so it never blocks CI or an agent-driven run:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
import { prompt } from "omkit/actions";
|
|
181
|
+
|
|
182
|
+
om("deploy", async () => {
|
|
183
|
+
const answer = await prompt({
|
|
184
|
+
kind: "choice",
|
|
185
|
+
message: "Deploy to production?",
|
|
186
|
+
choices: ["no", "yes"],
|
|
187
|
+
default: "no",
|
|
188
|
+
timeoutMs: 10_000, // no answer in 10s → "no"
|
|
189
|
+
}).result;
|
|
190
|
+
if (answer.ok && answer.value === "yes") await command("./deploy.sh")().result;
|
|
191
|
+
});
|
|
192
|
+
```
|
|
122
193
|
|
|
123
194
|
## Events
|
|
124
195
|
|
|
@@ -128,7 +199,7 @@ An Activity exposes its declared events plus the lifecycle ones (`done`, `error`
|
|
|
128
199
|
- **`activity.once(event)`** — a **promise** for the next emit; resolves `undefined` if the Activity settles first, so an `await` never hangs. A retained snapshot (like `healthy`) is replayed immediately even if you subscribe late.
|
|
129
200
|
|
|
130
201
|
```ts
|
|
131
|
-
const probe = healthcheck({
|
|
202
|
+
const probe = healthcheck({ url: "http://localhost:3000" });
|
|
132
203
|
probe.on("healthy", (r) => console.log(`up after ${r.attempts} tries`));
|
|
133
204
|
```
|
|
134
205
|
|
|
@@ -136,7 +207,7 @@ probe.on("healthy", (r) => console.log(`up after ${r.attempts} tries`));
|
|
|
136
207
|
|
|
137
208
|
Reusable actions built on the core engine. For anything beyond these, drop down to `action(...).run(...)`.
|
|
138
209
|
|
|
139
|
-
- **`command`** —
|
|
210
|
+
- **`command`** — define a shell-command action, named after the command; output streams to the log, killed on teardown.
|
|
140
211
|
- **`watch`** / **`watchDir`** — watch a file or directory for changes (survives wipe + recreate).
|
|
141
212
|
- **`untilLog`** — gate on the first log entry matching a predicate.
|
|
142
213
|
- **`tailLog`** — tail a file another process writes, folding its lines into the combined log.
|
|
@@ -13,11 +13,12 @@ export interface CommandOptions {
|
|
|
13
13
|
inheritDebugger?: boolean;
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
16
|
+
* Defines a command action, named after the command itself (add a semantic label with
|
|
17
|
+
* `.tag(...)`). Call the returned action to launch it.
|
|
17
18
|
*
|
|
18
19
|
* By default the command is executed through the shell.
|
|
19
20
|
*
|
|
20
21
|
* If `options.args` is provided, `cmdOrFile` is treated as an executable and
|
|
21
22
|
* launched directly without a shell.
|
|
22
23
|
*/
|
|
23
|
-
export declare function command(
|
|
24
|
+
export declare function command(cmdOrFile: string, options?: CommandOptions): import("../index.ts").Action<[], import("zx").ProcessOutput, import("../core/types.ts").NoEvents, void> | import("../index.ts").Action<[], void, import("../core/types.ts").NoEvents, void>;
|
package/dist/actions/command.js
CHANGED
|
@@ -7,32 +7,34 @@ import { debuggerFreeEnv, killTree, streamLines } from "../system/proc.js";
|
|
|
7
7
|
*/
|
|
8
8
|
const verbatim = (cmd) => Object.assign([cmd], { raw: [cmd] });
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
10
|
+
* Defines a command action, named after the command itself (add a semantic label with
|
|
11
|
+
* `.tag(...)`). Call the returned action to launch it.
|
|
11
12
|
*
|
|
12
13
|
* By default the command is executed through the shell.
|
|
13
14
|
*
|
|
14
15
|
* If `options.args` is provided, `cmdOrFile` is treated as an executable and
|
|
15
16
|
* launched directly without a shell.
|
|
16
17
|
*/
|
|
17
|
-
export function command(
|
|
18
|
+
export function command(cmdOrFile, options = {}) {
|
|
18
19
|
const cwd = path.resolve(options.cwd ?? ".");
|
|
19
|
-
// Shell command.
|
|
20
|
+
// Shell command — the command string is the action (and proc) name.
|
|
20
21
|
if (options.args === undefined) {
|
|
21
|
-
return action(
|
|
22
|
+
return action(cmdOrFile).run((ctx) => ctx.proc(cmdOrFile, {
|
|
22
23
|
cwd,
|
|
23
24
|
...(options.env && { env: options.env }),
|
|
24
25
|
...(options.inheritDebugger && {
|
|
25
26
|
inheritDebugger: true,
|
|
26
27
|
}),
|
|
27
|
-
})(verbatim(cmdOrFile)))
|
|
28
|
+
})(verbatim(cmdOrFile)));
|
|
28
29
|
}
|
|
29
|
-
// Direct executable.
|
|
30
|
+
// Direct executable — the file plus its args form the action name.
|
|
31
|
+
const args = options.args;
|
|
32
|
+
const name = [cmdOrFile, ...args].join(" ");
|
|
30
33
|
return action(name).run(async (ctx) => {
|
|
31
|
-
const inherit = options.inheritDebugger
|
|
34
|
+
const inherit = options.inheritDebugger;
|
|
32
35
|
const env = inherit
|
|
33
36
|
? (options.env ?? process.env)
|
|
34
37
|
: debuggerFreeEnv(options.env ?? process.env);
|
|
35
|
-
const args = options.args ?? [];
|
|
36
38
|
const child = spawn(cmdOrFile, args, {
|
|
37
39
|
cwd,
|
|
38
40
|
env,
|
|
@@ -75,5 +77,5 @@ export function command(name, cmdOrFile, options = {}) {
|
|
|
75
77
|
finished = true;
|
|
76
78
|
ctx.signal.removeEventListener("abort", onAbort);
|
|
77
79
|
}
|
|
78
|
-
})
|
|
80
|
+
});
|
|
79
81
|
}
|
package/dist/core/ActionRun.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type EventHandler } from "../foundation/events.ts";
|
|
|
2
2
|
import type { ActionRef } from "../foundation/ActionRef.ts";
|
|
3
3
|
import type { LogStore } from "../output/log/LogStore.ts";
|
|
4
4
|
import type { SnapshotStore } from "../output/snapshot/SnapshotStore.ts";
|
|
5
|
-
import type { Exec, InstanceEvents, NodeStatus, Outcome
|
|
5
|
+
import type { Activity, Exec, InstanceEvents, NodeStatus, Outcome } from "./types.ts";
|
|
6
6
|
/** Everything a node needs to exist, minus its behavior. */
|
|
7
7
|
export interface NodeInit {
|
|
8
8
|
readonly store: LogStore;
|
|
@@ -34,7 +34,7 @@ export interface NodeInit {
|
|
|
34
34
|
* `run(exec, args)` executes the body inside the error boundary and settles the
|
|
35
35
|
* node; it never throws (the failure is delivered through `.result`).
|
|
36
36
|
*/
|
|
37
|
-
export declare class ActionRun<Result = unknown, Events extends object = object, Handle = unknown> implements
|
|
37
|
+
export declare class ActionRun<Result = unknown, Events extends object = object, Handle = unknown> implements Activity<Result, Events, Handle> {
|
|
38
38
|
readonly id: string;
|
|
39
39
|
readonly uuid: string;
|
|
40
40
|
readonly name: string;
|
|
@@ -66,6 +66,9 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
|
|
|
66
66
|
private observed;
|
|
67
67
|
private refObserved;
|
|
68
68
|
private cancelLogged;
|
|
69
|
+
private armedExec;
|
|
70
|
+
private armedArgs;
|
|
71
|
+
private committed;
|
|
69
72
|
private cascadeCancelled;
|
|
70
73
|
constructor(init: NodeInit);
|
|
71
74
|
/** A stable reference for headers/attribution (tags are the accumulated set now). */
|
|
@@ -78,6 +81,7 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
|
|
|
78
81
|
on<K extends keyof InstanceEvents<Events, Result>>(key: K, handler: EventHandler<InstanceEvents<Events, Result>[K]>): void;
|
|
79
82
|
once<K extends keyof InstanceEvents<Events, Result>>(key: K): Promise<InstanceEvents<Events, Result>[K] | undefined>;
|
|
80
83
|
tag(name: string): this;
|
|
84
|
+
withCache(...paths: string[]): Activity<Result | undefined, Events, Handle>;
|
|
81
85
|
handleFailure(handler: (error: unknown) => void): this;
|
|
82
86
|
cancel(): void;
|
|
83
87
|
/**
|
|
@@ -87,6 +91,13 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
|
|
|
87
91
|
* never for the root, whose cancellation is the run-level `tearing down · …` narration.
|
|
88
92
|
*/
|
|
89
93
|
private noteCancelled;
|
|
94
|
+
/**
|
|
95
|
+
* Arm the node with its body + args. The body is not run here — {@link commit} runs it
|
|
96
|
+
* one microtask later, leaving a window in which `withCache` can still wrap it.
|
|
97
|
+
*/
|
|
98
|
+
arm(exec: Exec<Events, Handle, Result>, args: readonly unknown[]): void;
|
|
99
|
+
/** Run the armed body once (idempotent). Called on a microtask after the launching call. */
|
|
100
|
+
commit(): Promise<void>;
|
|
90
101
|
/** Run the body inside the error boundary, settling the node. Never rejects. */
|
|
91
102
|
run(exec: Exec<Events, Handle, Result>, args: readonly unknown[]): Promise<void>;
|
|
92
103
|
private context;
|
package/dist/core/ActionRun.js
CHANGED
|
@@ -3,6 +3,7 @@ import { events } from "../foundation/events.js";
|
|
|
3
3
|
import { CancelledError } from "../foundation/CancelledError.js";
|
|
4
4
|
import { renderValue } from "../foundation/format.js";
|
|
5
5
|
import { createProc } from "../system/proc.js";
|
|
6
|
+
import { FolderCache } from "../system/fs/FolderCache.js";
|
|
6
7
|
/**
|
|
7
8
|
* One node in the {@link ExecutionTree}: its identity, tags, timing, and terminal
|
|
8
9
|
* state, plus the run handle (`result`/`ref`/`once`/`tag`/`cancel`). Self-contained —
|
|
@@ -41,6 +42,11 @@ export class ActionRun {
|
|
|
41
42
|
observed = false;
|
|
42
43
|
refObserved = false;
|
|
43
44
|
cancelLogged = false;
|
|
45
|
+
// The armed body + args, set at launch and run once by `commit()` on a microtask — the
|
|
46
|
+
// window during which `withCache` may still wrap the body. `committed` closes that window.
|
|
47
|
+
armedExec;
|
|
48
|
+
armedArgs = [];
|
|
49
|
+
committed = false;
|
|
44
50
|
// Set only by the parent-signal abort listener (an ANCESTOR cancelling this node).
|
|
45
51
|
// Distinguishes a run/ancestor teardown from this node's own subtree-abort on failure,
|
|
46
52
|
// so the unobserved-failure check below isn't fooled by our own controller.abort().
|
|
@@ -97,9 +103,9 @@ export class ActionRun {
|
|
|
97
103
|
return this.handle.promise;
|
|
98
104
|
}
|
|
99
105
|
on(key, handler) {
|
|
100
|
-
// A late `on("error")` (e.g. attached after
|
|
101
|
-
// failed
|
|
102
|
-
//
|
|
106
|
+
// A late `on("error")` (e.g. attached after an `await`, once the body has already
|
|
107
|
+
// failed) still receives the failure: the emitter snapshots the last "error" payload
|
|
108
|
+
// and replays it to a new listener.
|
|
103
109
|
if (key === "error")
|
|
104
110
|
this.observed = true;
|
|
105
111
|
this.emitter.listen(key, handler);
|
|
@@ -128,12 +134,24 @@ export class ActionRun {
|
|
|
128
134
|
this.log("tag", "tag", name);
|
|
129
135
|
return this;
|
|
130
136
|
}
|
|
137
|
+
withCache(...paths) {
|
|
138
|
+
if (this.committed) {
|
|
139
|
+
throw new Error("withCache: already launched — chain it before the first await");
|
|
140
|
+
}
|
|
141
|
+
// Validate + canonicalize eagerly (fails fast on a relative path), then wrap the armed
|
|
142
|
+
// body so a fingerprint hit skips it. Applied before `commit()` runs the body.
|
|
143
|
+
const targets = FolderCache.resolvePaths(paths);
|
|
144
|
+
const inner = this.armedExec;
|
|
145
|
+
if (inner)
|
|
146
|
+
this.armedExec = cacheGate(inner, targets);
|
|
147
|
+
return this;
|
|
148
|
+
}
|
|
131
149
|
handleFailure(handler) {
|
|
132
150
|
this.failureHandler = handler;
|
|
133
151
|
this.observed = true;
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
152
|
+
// Attached after an `await` on a body that has already failed: deliver the
|
|
153
|
+
// already-recorded failure now (it won't fail again). Cancellation is status
|
|
154
|
+
// "cancelled", not "failed", so a cancelled node never runs the handler.
|
|
137
155
|
if (this.status === "failed")
|
|
138
156
|
handler(this.error);
|
|
139
157
|
return this;
|
|
@@ -156,11 +174,36 @@ export class ActionRun {
|
|
|
156
174
|
this.bubble("⊘ cancelled");
|
|
157
175
|
}
|
|
158
176
|
// ── Execution ──────────────────────────────────────────────────────────────
|
|
177
|
+
/**
|
|
178
|
+
* Arm the node with its body + args. The body is not run here — {@link commit} runs it
|
|
179
|
+
* one microtask later, leaving a window in which `withCache` can still wrap it.
|
|
180
|
+
*/
|
|
181
|
+
arm(exec, args) {
|
|
182
|
+
this.armedExec = exec;
|
|
183
|
+
this.armedArgs = args;
|
|
184
|
+
}
|
|
185
|
+
/** Run the armed body once (idempotent). Called on a microtask after the launching call. */
|
|
186
|
+
commit() {
|
|
187
|
+
if (this.committed)
|
|
188
|
+
return Promise.resolve();
|
|
189
|
+
this.committed = true;
|
|
190
|
+
const exec = this.armedExec;
|
|
191
|
+
this.armedExec = undefined;
|
|
192
|
+
return exec ? this.run(exec, this.armedArgs) : Promise.resolve();
|
|
193
|
+
}
|
|
159
194
|
/** Run the body inside the error boundary, settling the node. Never rejects. */
|
|
160
195
|
async run(exec, args) {
|
|
161
196
|
this.args = args;
|
|
162
197
|
this.startedAt = Date.now();
|
|
163
198
|
this.lifecycle("launched");
|
|
199
|
+
// Cancelled before the body could start — the run tore down between the launching call
|
|
200
|
+
// and this microtask. Record a clean stop without invoking the body, so a daemon that
|
|
201
|
+
// only listens for `abort` (its listener would never fire, the abort having already
|
|
202
|
+
// happened) can't wedge teardown.
|
|
203
|
+
if (this.controller.signal.aborted) {
|
|
204
|
+
this.fail(new CancelledError());
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
164
207
|
try {
|
|
165
208
|
const result = await exec(this.context(), ...args);
|
|
166
209
|
this.succeed(result);
|
|
@@ -282,3 +325,18 @@ export class ActionRun {
|
|
|
282
325
|
}
|
|
283
326
|
const messageOf = (error) => error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
284
327
|
const firstLine = (message) => message.split("\n", 1)[0] ?? message;
|
|
328
|
+
/**
|
|
329
|
+
* Wrap an exec so it skips (returns `undefined`, logging `cached, skipping`) when the
|
|
330
|
+
* input `targets` are unchanged since the last successful run, else runs the inner body
|
|
331
|
+
* and records the fresh fingerprint. Backs {@link ActionRun.withCache}.
|
|
332
|
+
*/
|
|
333
|
+
const cacheGate = (inner, targets) => (async (ctx, ...args) => {
|
|
334
|
+
const fp = await FolderCache.fingerprint(targets);
|
|
335
|
+
if ((await FolderCache.read(targets)) === fp) {
|
|
336
|
+
console.log("cached, skipping");
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
const result = await inner(ctx, ...args);
|
|
340
|
+
await FolderCache.write(targets, fp);
|
|
341
|
+
return result;
|
|
342
|
+
});
|
|
@@ -39,7 +39,7 @@ export declare class ExecutionTree {
|
|
|
39
39
|
private readonly onSigint;
|
|
40
40
|
private readonly onUncaught;
|
|
41
41
|
private readonly onUnhandled;
|
|
42
|
-
constructor(definedAt?: string);
|
|
42
|
+
constructor(name: string, definedAt?: string);
|
|
43
43
|
/** Launch an instance under the ambient current node (or the root). */
|
|
44
44
|
launch(spec: LaunchSpec): ActionRun;
|
|
45
45
|
private append;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { newUuid, makeId } from "../foundation/ids.js";
|
|
2
|
+
import { newUuid, makeId, omHash } from "../foundation/ids.js";
|
|
3
3
|
import { fsSafe } from "../foundation/fsSafe.js";
|
|
4
|
+
import { siteFile } from "../foundation/callsite.js";
|
|
4
5
|
import { defer } from "../foundation/Deferred.js";
|
|
5
6
|
import { LogStore } from "../output/log/LogStore.js";
|
|
6
7
|
import { RunFolder } from "../output/folder/RunFolder.js";
|
|
@@ -36,11 +37,11 @@ export class ExecutionTree {
|
|
|
36
37
|
ExecutionTree.current = null;
|
|
37
38
|
}
|
|
38
39
|
store = new LogStore();
|
|
39
|
-
folder
|
|
40
|
+
folder;
|
|
40
41
|
root;
|
|
41
42
|
rawStream;
|
|
42
43
|
consoleCapture;
|
|
43
|
-
snapshotStore
|
|
44
|
+
snapshotStore;
|
|
44
45
|
registry = [];
|
|
45
46
|
unsettled = new Set();
|
|
46
47
|
originalLog = console.log.bind(console);
|
|
@@ -62,7 +63,11 @@ export class ExecutionTree {
|
|
|
62
63
|
// Process hooks — installed at run start, removed at finalize (they must not outlive the run).
|
|
63
64
|
onUncaught = (e) => this.onFatal("uncaughtException", e);
|
|
64
65
|
onUnhandled = (e) => this.onFatal("unhandledRejection", e);
|
|
65
|
-
constructor(definedAt) {
|
|
66
|
+
constructor(name, definedAt) {
|
|
67
|
+
// The run's identity: its name + where om() is written (not how it was launched),
|
|
68
|
+
// so same-named oms in different files get distinct log folders.
|
|
69
|
+
this.folder = new RunFolder(name, omHash(name, siteFile(definedAt)));
|
|
70
|
+
this.snapshotStore = new SnapshotStore(this.folder);
|
|
66
71
|
this.folder.ensure();
|
|
67
72
|
this.root = new ActionRun({
|
|
68
73
|
...this.nodeDeps(),
|
|
@@ -110,8 +115,16 @@ export class ExecutionTree {
|
|
|
110
115
|
this.append(parent, "child", "launch", `→ ${id} · ${this.logFile(nodePath)}${defined}`);
|
|
111
116
|
for (const t of spec.tags)
|
|
112
117
|
node.tag(t);
|
|
113
|
-
|
|
114
|
-
|
|
118
|
+
// Arm the body now, but commit it on a microtask: this lets `withCache`/`tag` chained on
|
|
119
|
+
// the returned handle (before the caller's first await) apply before the body starts.
|
|
120
|
+
// The tracked promise is registered synchronously (so `drive()` waits on it), and the
|
|
121
|
+
// microtask is scheduled unconditionally so a fire-and-forget launch still commits.
|
|
122
|
+
node.arm(spec.body, spec.args);
|
|
123
|
+
const settled = defer();
|
|
124
|
+
this.trackNode(settled.promise);
|
|
125
|
+
queueMicrotask(() => {
|
|
126
|
+
void currentNode.run(node, () => node.commit()).then(() => settled.resolve());
|
|
127
|
+
});
|
|
115
128
|
return node;
|
|
116
129
|
}
|
|
117
130
|
append(node, level, source, message) {
|
package/dist/core/action.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ActionBuilderEvents, NoEvents } from "./types.ts";
|
|
2
2
|
/**
|
|
3
3
|
* Define an action. Provide the impl via `.run(…)`, optionally after declaring
|
|
4
|
-
* events (`.emits<…>()`) and/or an imperative handle (`.ref<…>()`).
|
|
4
|
+
* events (`.emits<…>()`) and/or an imperative handle (`.ref<…>()`). Call the result
|
|
5
|
+
* to launch it.
|
|
5
6
|
*/
|
|
6
7
|
export declare function action(name: string): ActionBuilderEvents<NoEvents, void>;
|
package/dist/core/action.js
CHANGED
|
@@ -1,54 +1,11 @@
|
|
|
1
1
|
import { ExecutionTree } from "./ExecutionTree.js";
|
|
2
|
-
import { FolderCache } from "../system/fs/FolderCache.js";
|
|
3
2
|
import { callerSite } from "../foundation/callsite.js";
|
|
4
3
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
4
|
+
* The builder returned by `action(name)`: declare events (`.emits<…>()`) and/or an
|
|
5
|
+
* imperative handle (`.ref<…>()`), then bind the impl with `.run(…)`. `.run` returns a
|
|
6
|
+
* callable {@link Action} — **invoking it launches** the action under the ambient current
|
|
7
|
+
* node and returns the live {@link Activity}, on which `withCache`/`tag`/… chain.
|
|
8
8
|
*/
|
|
9
|
-
class Instance {
|
|
10
|
-
name;
|
|
11
|
-
body;
|
|
12
|
-
args;
|
|
13
|
-
definedAt;
|
|
14
|
-
tags = [];
|
|
15
|
-
constructor(name, body, args, definedAt) {
|
|
16
|
-
this.name = name;
|
|
17
|
-
this.body = body;
|
|
18
|
-
this.args = args;
|
|
19
|
-
this.definedAt = definedAt;
|
|
20
|
-
}
|
|
21
|
-
tag(name) {
|
|
22
|
-
this.tags.push(name);
|
|
23
|
-
return this;
|
|
24
|
-
}
|
|
25
|
-
withCache(...paths) {
|
|
26
|
-
// Validate + canonicalize eagerly here (fails fast on a relative path).
|
|
27
|
-
const targets = FolderCache.resolvePaths(paths);
|
|
28
|
-
const innerBody = this.body;
|
|
29
|
-
const innerArgs = this.args;
|
|
30
|
-
// Wrap in a fresh instance that keeps this action's name/tags and, at run time,
|
|
31
|
-
// fingerprints the inputs — skipping (return undefined) on a hit, else running
|
|
32
|
-
// the inner body and recording the fingerprint on success.
|
|
33
|
-
const body = (async (ctx, ...args) => {
|
|
34
|
-
const fp = await FolderCache.fingerprint(targets);
|
|
35
|
-
if ((await FolderCache.read(targets)) === fp) {
|
|
36
|
-
console.log("cached, skipping");
|
|
37
|
-
return undefined;
|
|
38
|
-
}
|
|
39
|
-
const result = (await innerBody(ctx, ...args));
|
|
40
|
-
await FolderCache.write(targets, fp);
|
|
41
|
-
return result;
|
|
42
|
-
});
|
|
43
|
-
const cached = new Instance(this.name, body, innerArgs, this.definedAt);
|
|
44
|
-
cached.tags.push(...this.tags);
|
|
45
|
-
return cached;
|
|
46
|
-
}
|
|
47
|
-
exec() {
|
|
48
|
-
return ExecutionTree.require().launch(this);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
/** The builder returned by `action(name)`: declare events/handle, then bind the impl. */
|
|
52
9
|
class Builder {
|
|
53
10
|
name;
|
|
54
11
|
constructor(name) {
|
|
@@ -63,13 +20,18 @@ class Builder {
|
|
|
63
20
|
run(body) {
|
|
64
21
|
const name = this.name;
|
|
65
22
|
const definedAt = callerSite(); // the `.run(...)` call site — where this action lives
|
|
66
|
-
const
|
|
23
|
+
const exec = body;
|
|
24
|
+
const create = (...args) => {
|
|
25
|
+
const spec = { name, args, tags: [], body: exec, definedAt };
|
|
26
|
+
return ExecutionTree.require().launch(spec);
|
|
27
|
+
};
|
|
67
28
|
return Object.assign(create, { actionName: name, definedAt });
|
|
68
29
|
}
|
|
69
30
|
}
|
|
70
31
|
/**
|
|
71
32
|
* Define an action. Provide the impl via `.run(…)`, optionally after declaring
|
|
72
|
-
* events (`.emits<…>()`) and/or an imperative handle (`.ref<…>()`).
|
|
33
|
+
* events (`.emits<…>()`) and/or an imperative handle (`.ref<…>()`). Call the result
|
|
34
|
+
* to launch it.
|
|
73
35
|
*/
|
|
74
36
|
export function action(name) {
|
|
75
37
|
return new Builder(name);
|
package/dist/core/context.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import type { ActionRun } from "./ActionRun.ts";
|
|
3
3
|
/**
|
|
4
|
-
* Holds the **current node** for the duration of an exec. Read
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Holds the **current node** for the duration of an exec. Read when an action is
|
|
5
|
+
* launched (to find its parent) and by console capture (to attribute a `console.*`
|
|
6
|
+
* line) — the single ambient mechanism that replaces `nod` and makes scoping
|
|
7
|
+
* compose without threading anything by hand.
|
|
8
8
|
*/
|
|
9
9
|
export declare const currentNode: AsyncLocalStorage<ActionRun<unknown, object, unknown>>;
|
package/dist/core/context.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
/**
|
|
3
|
-
* Holds the **current node** for the duration of an exec. Read
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* Holds the **current node** for the duration of an exec. Read when an action is
|
|
4
|
+
* launched (to find its parent) and by console capture (to attribute a `console.*`
|
|
5
|
+
* line) — the single ambient mechanism that replaces `nod` and makes scoping
|
|
6
|
+
* compose without threading anything by hand.
|
|
7
7
|
*/
|
|
8
8
|
export const currentNode = new AsyncLocalStorage();
|
package/dist/core/om.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { Awaitable, OmContext } from "./types.ts";
|
|
2
2
|
/**
|
|
3
3
|
* Runs a linear orchestration as the root of a fresh {@link ExecutionTree}. The
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* required `name` plus the absolute path of the file calling `om()` form the run's
|
|
5
|
+
* identity — its log folder is `logs/<name>-<hash8>/…`, so same-named oms defined
|
|
6
|
+
* in different files never share a folder. The body runs inside the root node's
|
|
7
|
+
* ambient scope, so any action call / `step(...)` / `console.*` it reaches
|
|
8
|
+
* attributes correctly. Resolves once the run has torn down and produced its
|
|
9
|
+
* artifacts; never rejects (failures are recorded in the tree and set the exit code).
|
|
8
10
|
*/
|
|
9
|
-
export declare function om(body: (ctx: OmContext) => Awaitable<void>): Promise<void>;
|
|
11
|
+
export declare function om(name: string, body: (ctx: OmContext) => Awaitable<void>): Promise<void>;
|
package/dist/core/om.js
CHANGED
|
@@ -2,16 +2,21 @@ import { ExecutionTree } from "./ExecutionTree.js";
|
|
|
2
2
|
import { callerSite } from "../foundation/callsite.js";
|
|
3
3
|
/**
|
|
4
4
|
* Runs a linear orchestration as the root of a fresh {@link ExecutionTree}. The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* required `name` plus the absolute path of the file calling `om()` form the run's
|
|
6
|
+
* identity — its log folder is `logs/<name>-<hash8>/…`, so same-named oms defined
|
|
7
|
+
* in different files never share a folder. The body runs inside the root node's
|
|
8
|
+
* ambient scope, so any action call / `step(...)` / `console.*` it reaches
|
|
9
|
+
* attributes correctly. Resolves once the run has torn down and produced its
|
|
10
|
+
* artifacts; never rejects (failures are recorded in the tree and set the exit code).
|
|
9
11
|
*/
|
|
10
|
-
export function om(body) {
|
|
12
|
+
export function om(name, body) {
|
|
13
|
+
if (typeof name !== "string" || name.trim() === "") {
|
|
14
|
+
throw new Error("om(name, body) requires a non-empty name — it identifies the run in logs/");
|
|
15
|
+
}
|
|
11
16
|
if (ExecutionTree.current) {
|
|
12
17
|
throw new Error("an om() run is already active in this process");
|
|
13
18
|
}
|
|
14
|
-
const tree = new ExecutionTree(callerSite()); // where om() was called — the run's script
|
|
19
|
+
const tree = new ExecutionTree(name, callerSite()); // callerSite: where om() was called — the run's script
|
|
15
20
|
ExecutionTree.current = tree;
|
|
16
21
|
const rootBody = (ctx) => body({
|
|
17
22
|
signal: ctx.signal,
|
package/dist/core/step.js
CHANGED
package/dist/core/types.d.ts
CHANGED
|
@@ -65,19 +65,29 @@ export interface OmContext {
|
|
|
65
65
|
readonly artifactsFolder: string;
|
|
66
66
|
}
|
|
67
67
|
/**
|
|
68
|
-
* A launched action: the live node's awaitable/observable surface
|
|
69
|
-
*
|
|
68
|
+
* A launched action: the live node's awaitable/observable surface, returned by
|
|
69
|
+
* **invoking an {@link Action}**. `.result` always resolves an {@link Outcome} (never
|
|
70
|
+
* throws); `.ref` rejects on failure/cancel. The body is committed one microtask after
|
|
71
|
+
* the launching call, so pre-body config (`withCache`) chained before the first `await`
|
|
72
|
+
* still applies.
|
|
70
73
|
*/
|
|
71
|
-
export interface
|
|
74
|
+
export interface Activity<Result = unknown, Events extends object = NoEvents, Handle = void> {
|
|
72
75
|
readonly id: string;
|
|
73
76
|
/** The terminal outcome; always resolves (never rejects). Reading it observes the failure. */
|
|
74
77
|
readonly result: Promise<Outcome<Result>>;
|
|
75
78
|
/** The attached handle; rejects on failure/cancel. */
|
|
76
79
|
readonly ref: Promise<Handle>;
|
|
80
|
+
/**
|
|
81
|
+
* Gate this activity on its **input** files/folders (absolute paths): skip running the
|
|
82
|
+
* body when they're unchanged since the last successful run (a hit resolves `undefined`
|
|
83
|
+
* and logs `cached, skipping`). **Pre-body only** — chain it before the first `await`
|
|
84
|
+
* after the launching call; calling it once the body has started throws.
|
|
85
|
+
*/
|
|
86
|
+
withCache(...paths: string[]): Activity<Result | undefined, Events, Handle>;
|
|
77
87
|
/**
|
|
78
88
|
* Handle a fire-and-forget activity's failure: attach a handler so a crash runs it
|
|
79
89
|
* instead of tearing the run down. Observes the failure; chainable; attach in the
|
|
80
|
-
* same tick as
|
|
90
|
+
* same tick as the launching call. Not called on cancellation.
|
|
81
91
|
*/
|
|
82
92
|
handleFailure(handler: (error: unknown) => void): this;
|
|
83
93
|
/** Subscribe to every emit of a declared/system event (`done`/`error`/`attached`). */
|
|
@@ -89,26 +99,9 @@ export interface RunHandle<Result = unknown, Events extends object = NoEvents, H
|
|
|
89
99
|
/** Abort this node and its subtree. */
|
|
90
100
|
cancel(): void;
|
|
91
101
|
}
|
|
92
|
-
/** A
|
|
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}. */
|
|
102
|
+
/** A callable produced by `action`: **invoking it launches** the action and returns the live {@link Activity}. */
|
|
110
103
|
export interface Action<Args extends unknown[], Result, Events extends object = NoEvents, Handle = void> {
|
|
111
|
-
(...args: Args):
|
|
104
|
+
(...args: Args): Activity<Result, Events, Handle>;
|
|
112
105
|
readonly actionName: string;
|
|
113
106
|
/** `file:line` where this action was defined (the `.run(...)` site), for the log header. */
|
|
114
107
|
readonly definedAt: string | undefined;
|
|
@@ -4,4 +4,9 @@
|
|
|
4
4
|
* where `.run(...)` was written, and `om(...)` records where the run was launched from. At
|
|
5
5
|
* runtime that's the built file (e.g. `dist/actions/chrome-page.js`). `undefined` with no stack.
|
|
6
6
|
*/
|
|
7
|
+
/**
|
|
8
|
+
* The file part of a `file:line` site — the run-identity input (the line is dropped
|
|
9
|
+
* so moving a call within its file doesn't change the run's identity).
|
|
10
|
+
*/
|
|
11
|
+
export declare const siteFile: (site: string | undefined) => string | undefined;
|
|
7
12
|
export declare const callerSite: () => string | undefined;
|
|
@@ -22,6 +22,11 @@ const nativePath = (file) => {
|
|
|
22
22
|
* where `.run(...)` was written, and `om(...)` records where the run was launched from. At
|
|
23
23
|
* runtime that's the built file (e.g. `dist/actions/chrome-page.js`). `undefined` with no stack.
|
|
24
24
|
*/
|
|
25
|
+
/**
|
|
26
|
+
* The file part of a `file:line` site — the run-identity input (the line is dropped
|
|
27
|
+
* so moving a call within its file doesn't change the run's identity).
|
|
28
|
+
*/
|
|
29
|
+
export const siteFile = (site) => site?.replace(/:\d+$/, "");
|
|
25
30
|
export const callerSite = () => {
|
|
26
31
|
const stack = new Error().stack;
|
|
27
32
|
if (!stack)
|
package/dist/foundation/ids.d.ts
CHANGED
|
@@ -7,3 +7,11 @@ export declare const shortId: (uuid: string) => string;
|
|
|
7
7
|
* plain name (`main`) and skips this.
|
|
8
8
|
*/
|
|
9
9
|
export declare const makeId: (name: string, uuid: string) => string;
|
|
10
|
+
/**
|
|
11
|
+
* A run's stable identity: first 8 hex of sha256 over the defining file's absolute
|
|
12
|
+
* path + the om name. Same file + name ⇒ same hash on every run; the same name
|
|
13
|
+
* defined in two different files diverges (consumers may define oms in same-named
|
|
14
|
+
* files across folders). With no file (no stack), the name alone still hashes
|
|
15
|
+
* deterministically.
|
|
16
|
+
*/
|
|
17
|
+
export declare const omHash: (name: string, file?: string) => string;
|
package/dist/foundation/ids.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
/** A fresh v4 uuid for a node. */
|
|
3
3
|
export const newUuid = () => randomUUID();
|
|
4
4
|
/** The short form used in ids/paths — first 8 hex of the uuid. */
|
|
@@ -8,3 +8,14 @@ export const shortId = (uuid) => uuid.slice(0, 8);
|
|
|
8
8
|
* plain name (`main`) and skips this.
|
|
9
9
|
*/
|
|
10
10
|
export const makeId = (name, uuid) => `${name}_${shortId(uuid)}`;
|
|
11
|
+
/**
|
|
12
|
+
* A run's stable identity: first 8 hex of sha256 over the defining file's absolute
|
|
13
|
+
* path + the om name. Same file + name ⇒ same hash on every run; the same name
|
|
14
|
+
* defined in two different files diverges (consumers may define oms in same-named
|
|
15
|
+
* files across folders). With no file (no stack), the name alone still hashes
|
|
16
|
+
* deterministically.
|
|
17
|
+
*/
|
|
18
|
+
export const omHash = (name, file) => createHash("sha256")
|
|
19
|
+
.update(`${file ?? ""}\0${name}`)
|
|
20
|
+
.digest("hex")
|
|
21
|
+
.slice(0, 8);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* omkit (refactored core) — the ExecutionTree model.
|
|
3
3
|
*
|
|
4
|
-
* A curated public surface. `om(body)` runs a linear orchestration as the root of
|
|
5
|
-
* one run; `action(...)` defines typed units you launch
|
|
4
|
+
* A curated public surface. `om(name, body)` runs a linear orchestration as the root of
|
|
5
|
+
* one run; `action(...)` defines typed units you launch by **calling** them;
|
|
6
6
|
* `step(name, fn)` is the inline anonymous action. The run's introspectable output
|
|
7
7
|
* is the on-disk `result.json` / `raw.jsonl` / `.log` files — the ExecutionTree
|
|
8
8
|
* engine itself is internal and intentionally not exported.
|
|
@@ -11,4 +11,4 @@ export { om } from "./core/om.ts";
|
|
|
11
11
|
export { action } from "./core/action.ts";
|
|
12
12
|
export { step } from "./core/step.ts";
|
|
13
13
|
export { CancelledError, isCancelled } from "./foundation/CancelledError.ts";
|
|
14
|
-
export type { Action,
|
|
14
|
+
export type { Action, Activity, ActionContext, OmContext, Outcome } from "./core/types.ts";
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* omkit (refactored core) — the ExecutionTree model.
|
|
3
3
|
*
|
|
4
|
-
* A curated public surface. `om(body)` runs a linear orchestration as the root of
|
|
5
|
-
* one run; `action(...)` defines typed units you launch
|
|
4
|
+
* A curated public surface. `om(name, body)` runs a linear orchestration as the root of
|
|
5
|
+
* one run; `action(...)` defines typed units you launch by **calling** them;
|
|
6
6
|
* `step(name, fn)` is the inline anonymous action. The run's introspectable output
|
|
7
7
|
* is the on-disk `result.json` / `raw.jsonl` / `.log` files — the ExecutionTree
|
|
8
8
|
* engine itself is internal and intentionally not exported.
|
|
@@ -76,7 +76,10 @@ const format = (e, tags) => {
|
|
|
76
76
|
if (e.message === "launched")
|
|
77
77
|
return null;
|
|
78
78
|
const status = e.message.replace(/^done · /, "");
|
|
79
|
-
return {
|
|
79
|
+
return {
|
|
80
|
+
text: `${doneIcon(status)} ${named(e.path, tags)} · ${status}`,
|
|
81
|
+
persist: status === "failed",
|
|
82
|
+
};
|
|
80
83
|
}
|
|
81
84
|
// Run milestones (started / tearing down / finished) — few, and worth keeping.
|
|
82
85
|
if (e.level === "run")
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Owns this run's single output directory — `logs/<name>/<date>/<time>/` —
|
|
3
|
-
* `raw.jsonl`, the per-action `.log` files, rollups, and `result.json` all
|
|
4
|
-
* The path is computed once, lazily, and always returned absolute so links
|
|
5
|
-
* into logs resolve regardless of cwd.
|
|
2
|
+
* Owns this run's single output directory — `logs/<name>-<hash>/<date>/<time>/` —
|
|
3
|
+
* where `raw.jsonl`, the per-action `.log` files, rollups, and `result.json` all
|
|
4
|
+
* live. The path is computed once, lazily, and always returned absolute so links
|
|
5
|
+
* written into logs resolve regardless of cwd.
|
|
6
6
|
*/
|
|
7
7
|
export declare class RunFolder {
|
|
8
|
+
private readonly runName;
|
|
9
|
+
private readonly hash;
|
|
8
10
|
private dir;
|
|
9
11
|
private ensured;
|
|
10
|
-
|
|
12
|
+
constructor(runName: string, hash: string);
|
|
13
|
+
/** `<name>-<hash8>` — the run's folder name, unique per (defining file, name). */
|
|
11
14
|
name(): string;
|
|
12
15
|
/** The absolute run directory, computed once and created on first access. */
|
|
13
16
|
path(): string;
|
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import { mkdirSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { ymd, hms } from "../../foundation/format.js";
|
|
4
|
+
import { fsSafe } from "../../foundation/fsSafe.js";
|
|
4
5
|
/**
|
|
5
|
-
* Owns this run's single output directory — `logs/<name>/<date>/<time>/` —
|
|
6
|
-
* `raw.jsonl`, the per-action `.log` files, rollups, and `result.json` all
|
|
7
|
-
* The path is computed once, lazily, and always returned absolute so links
|
|
8
|
-
* into logs resolve regardless of cwd.
|
|
6
|
+
* Owns this run's single output directory — `logs/<name>-<hash>/<date>/<time>/` —
|
|
7
|
+
* where `raw.jsonl`, the per-action `.log` files, rollups, and `result.json` all
|
|
8
|
+
* live. The path is computed once, lazily, and always returned absolute so links
|
|
9
|
+
* written into logs resolve regardless of cwd.
|
|
9
10
|
*/
|
|
10
11
|
export class RunFolder {
|
|
12
|
+
runName;
|
|
13
|
+
hash;
|
|
11
14
|
dir;
|
|
12
15
|
ensured = false;
|
|
13
|
-
|
|
16
|
+
constructor(runName, hash) {
|
|
17
|
+
this.runName = runName;
|
|
18
|
+
this.hash = hash;
|
|
19
|
+
}
|
|
20
|
+
/** `<name>-<hash8>` — the run's folder name, unique per (defining file, name). */
|
|
14
21
|
name() {
|
|
15
|
-
|
|
16
|
-
return entry ? path.basename(entry, path.extname(entry)) : "om";
|
|
22
|
+
return `${fsSafe(this.runName)}-${this.hash}`;
|
|
17
23
|
}
|
|
18
24
|
/** The absolute run directory, computed once and created on first access. */
|
|
19
25
|
path() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omkit",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|