omkit 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,123 +2,180 @@
2
2
 
3
3
  **The operational-model kit.** _Typed actions. One run. One log._
4
4
 
5
- A tiny runtime for the workflows _around_ your code — build, watch, spawn, probe, drive a browser, read state back, and tear it all down together. Where [tskb](https://www.npmjs.com/package/tskb) is the _knowledge_ layer, `omkit` is the _operational_ one: the running, typed model of your system in motion.
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. Where [tskb](https://www.npmjs.com/package/tskb) is the _knowledge_ layer, `omkit` is the _operational_ one: the running, typed model of your system in motion.
6
6
 
7
7
  ## The problem
8
8
 
9
- Real developer workflows aren't linear. They start processes, wait for health checks, connect a browser, read state back, watch files, react to events — and shut everything down as one. Most teams glue this together with shell scripts, npm scripts, and ad-hoc Node, and get back a pile of opaque process output.
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
- `omkit` models the workflow instead. Each step is a typed **action** running inside one runtime. Actions don't share globals — they hand each other typed runtime **capabilities** (a port, a client, a live browser page) and emit typed **events**, all onto one log. So a running system exposes structured handoffs and observations, which makes it observable, reproducible, and scriptable.
11
+ `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.
12
12
 
13
13
  ```ts
14
14
  import { om } from "omkit";
15
15
  import { command, healthcheck, chromePage } from "omkit/actions";
16
16
 
17
+ // Define once. `command` names the action after the command; calling it launches.
18
+ const api = command("npm run dev", { cwd: "api" });
19
+ const web = command("npm run dev", { cwd: "web" });
20
+
17
21
  om(async () => {
18
- command("api", "npm run dev:api").exec().tag("api:daemon"); // start a daemon…
22
+ api().tag("api"); // start both dev servers as daemons…
23
+ web().tag("web");
19
24
 
20
- const ready = await healthcheck({ port: 3000 }).exec().result; // …gate on it…
21
- if (!ready.ok) return;
25
+ // …wait until the app actually serves (not just "process started")…
26
+ const up = await healthcheck({ url: "http://localhost:3000" }).result;
27
+ if (!up.ok) return; // gave up — the servers stay up so you can debug them
22
28
 
23
- // …then hand the live page downstream — typed, not scraped from a log.
24
- const page = chromePage("app", "localhost:9222", { url: "http://localhost:3000" }).exec();
25
- await inspect(page.ref); // whatever drives the app receives the live Page
29
+ // …then open it in a real browser and hand the live page downstream — typed, not scraped.
30
+ const page = chromePage("app", "localhost:9222", { url: "http://localhost:3000" });
31
+ console.log(`opened ${(await page.ref).url()}`);
26
32
 
27
- console.log("up — press Ctrl+C to stop");
33
+ console.log("stack is up — press Ctrl+C to stop");
28
34
  });
29
35
  ```
30
36
 
31
- `page.ref` isn't a URL scraped from stdout — it's a typed capability the next step receives and drives. Few workflow tools pass anything but strings between steps.
37
+ `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
38
 
33
39
  ```
34
40
  om()
35
41
  │
36
- ┌──────────────┼──────────────┐
37
- ▼ ▼ ▼
38
- command healthcheck chromePage
39
- (daemon) (gate) │ .ref
40
- ▼
41
- your driver
42
+ ┌────────────────┼────────────────┐
43
+ ▼ ▼ ▼
44
+ command healthcheck chromePage
45
+ api · web (gate) │ .ref → live Page
46
+ (daemons) ▼
47
+ your smoke test
42
48
  ```
43
49
 
44
50
  ## Core concepts
45
51
 
46
52
  ### Actions & Activities
47
53
 
48
- An **Action** is an immutable description of work — named, typed, optionally declaring events and an imperative handle. An **Activity** is a live execution of one, created synchronously by `.exec()`. Everything _before_ `exec()` configures the Action; everything _after_ configures or observes the Activity.
54
+ 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
55
 
50
56
  ```ts
51
57
  import { action } from "omkit";
52
58
 
53
- const Build = action("Build").run(({ proc }) => proc("tsc")`tsc -b`);
59
+ const build = action("build").run(({ proc }) => proc("tsc")`tsc -b`);
54
60
 
55
- const activity = Build().exec(); // launch; returns the live Activity
61
+ const activity = build(); // calling launches; returns the live Activity
56
62
  ```
57
63
 
58
- `om(async (ctx) => …)` hosts a linear orchestration as the root of a run. You write normal `await` / `if` / loops / variables; the Activities you launch keep running in parallel. Because the body stays in-flight while you `await`, the run never idles shut between steps. For a one-off inline step, `step(name, fn)` runs `fn` as its own node without a reusable definition.
64
+ `om(async (ctx) => …)` hosts the orchestration as the root of a run. 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
65
 
60
66
  ### Typed capabilities
61
67
 
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 actions chain without globals or string-scraping.
68
+ 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
69
 
64
70
  ```ts
65
- const Server = action("Server")
66
- .ref<number>()
71
+ const server = action("server")
72
+ .ref<number>() // this action publishes a port
67
73
  .run(async ({ attach, signal }) => {
68
- attach(await listen()); // publish the capability…
69
- await until(signal); // …and stay alive until teardown
74
+ const port = await listen(); // start listening…
75
+ attach(port); // …publish the port to downstream actions…
76
+ await until(signal); // …and stay up until teardown
70
77
  });
71
78
 
72
- const Probe = action("Probe").run((_ctx, port: Promise<number>) => drive(port));
79
+ const migrate = action("migrate").run((_ctx, port: Promise<number>) => runMigrations(port));
73
80
 
74
81
  om(async () => {
75
- const server = Server().exec();
76
- Probe(server.ref).exec(); // Probe receives Server's port, typed
82
+ const s = server();
83
+ migrate(s.ref); // migrate receives the port the moment the server attaches it — typed
77
84
  });
78
85
  ```
79
86
 
80
87
  ### Outcomes — a result that doesn't throw
81
88
 
82
- Every Activity settles to a typed **`Outcome`**, read via `activity.result` — which **never throws**:
89
+ 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
90
 
84
91
  ```ts
85
92
  type Outcome<T> = { ok: true; value: T } | { ok: false; error: unknown };
86
93
 
87
- const r = await runTests().exec().result;
88
- if (!r.ok) {
89
- // an expected, operational failure — inspect it and carry on
94
+ const tests = await command("npm test")().result;
95
+ if (!tests.ok) {
96
+ // red tests are an expected outcome here — decide what to do, don't crash
90
97
  }
91
98
  ```
92
99
 
93
- Awaiting `.result` also _observes_ the Activity (see teardown, below). A cancelled Activity resolves `{ ok: false, error: CancelledError }`.
100
+ Awaiting `.result` also _observes_ the Activity (see teardown). A cancelled Activity resolves `{ ok: false, error: CancelledError }`.
94
101
 
95
102
  ### Failure & teardown
96
103
 
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`), attached an `on("error")` listener, or attached `.handleFailure`.
104
+ 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
105
 
99
- - **Await it** (`await x.exec().result`) → the failure is yours to inspect as `{ ok: false }`; the run stays green.
100
- - **Fire-and-forget it** (`x.exec()`, never awaited) → an unobserved failure tears the run down. This is the guardrail for daemons: a crashed daemon fails the run instead of leaving it wedged.
106
+ - **Await it** (`await task().result`) → the failure is yours to inspect as `{ ok: false }`; the run stays green.
107
+ - **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
108
  - **`.handleFailure(fn)`** → own a fire-and-forget Activity's failure: `fn(error)` runs instead of tearing down.
102
109
 
103
110
  ```ts
104
- watchDir(".build").exec().tag("watch:daemon"); // crash here → run tears down
105
- someFlakyDaemon()
106
- .exec()
107
- .handleFailure((e) => log(e)); // …unless you own it
111
+ web().tag("web"); // if the web daemon crashes, the run tears down — you'll know
112
+ flakyBackgroundJob().handleFailure((e) => console.warn("job failed, carrying on", e));
108
113
  ```
109
114
 
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 launched keep running until Ctrl+C or `cancel()`, so a body that finishes wiring things up doesn't kill the servers it started.
115
+ **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
116
 
112
117
  ### One log, on disk
113
118
 
114
- Every action's output, events, asserts, snapshots, and lifecycle land on **one timeline**. It streams live to the terminal (curated milestones) and is written to a per-run folder:
119
+ 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
120
 
116
121
  - `result.json` — the run's tree, per-node status, and verdict.
117
122
  - `raw.jsonl` — every entry, machine-readable.
118
- - `main.log` + one `.log` per node — the human-readable timelines.
123
+ - `main.log` + one `.log` per action — the human-readable timelines.
119
124
  - `events.log` / `asserts.log` / `snapshots.log` — cross-cutting rollups.
120
125
 
121
- `ctx` also gives each action `assert(...)` (tallies into the verdict) and `snapshot(name, value)` (captures JSON state to the run folder) — so a run is an inspectable artifact, not just exit codes.
126
+ `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.
127
+
128
+ ## Common recipes
129
+
130
+ **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`):
131
+
132
+ ```ts
133
+ const build = action("build").run(({ proc }) => proc("tsc")`tsc -b`);
134
+
135
+ om(async () => {
136
+ const out = await build().withCache(`${process.cwd()}/src`).result;
137
+ if (out.ok && out.value === undefined) console.log("no changes — skipped the build");
138
+ });
139
+ ```
140
+
141
+ **Watch and react.** Launch a watcher, subscribe to its events, and let the run stay alive:
142
+
143
+ ```ts
144
+ import { watchDir } from "omkit/actions";
145
+
146
+ om(async () => {
147
+ const watcher = watchDir("src").tag("watch");
148
+ watcher.on("update", (file) => console.log(`changed: ${file}`));
149
+ // body returns, but the watcher keeps the run alive until Ctrl+C
150
+ });
151
+ ```
152
+
153
+ **Gate a deploy on green tests.** Observe the outcome and branch — no try/catch:
154
+
155
+ ```ts
156
+ om(async () => {
157
+ const tests = await command("npm test")().result;
158
+ if (!tests.ok) return; // red → stop; the run stays green because you observed it
159
+ await command("./deploy.sh")().result;
160
+ });
161
+ ```
162
+
163
+ **Ask before doing something risky.** `prompt` falls back to a default when unattended, so it never blocks CI:
164
+
165
+ ```ts
166
+ import { prompt } from "omkit/actions";
167
+
168
+ om(async () => {
169
+ const answer = await prompt({
170
+ kind: "choice",
171
+ message: "Deploy to production?",
172
+ choices: ["no", "yes"],
173
+ default: "no",
174
+ timeoutMs: 10_000, // no answer in 10s → "no"
175
+ }).result;
176
+ if (answer.ok && answer.value === "yes") await command("./deploy.sh")().result;
177
+ });
178
+ ```
122
179
 
123
180
  ## Events
124
181
 
@@ -128,7 +185,7 @@ An Activity exposes its declared events plus the lifecycle ones (`done`, `error`
128
185
  - **`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
186
 
130
187
  ```ts
131
- const probe = healthcheck({ port: 3000 }).exec();
188
+ const probe = healthcheck({ url: "http://localhost:3000" });
132
189
  probe.on("healthy", (r) => console.log(`up after ${r.attempts} tries`));
133
190
  ```
134
191
 
@@ -136,7 +193,7 @@ probe.on("healthy", (r) => console.log(`up after ${r.attempts} tries`));
136
193
 
137
194
  Reusable actions built on the core engine. For anything beyond these, drop down to `action(...).run(...)`.
138
195
 
139
- - **`command`** — run a shell command; output streams to the log, killed on teardown.
196
+ - **`command`** — define a shell-command action, named after the command; output streams to the log, killed on teardown.
140
197
  - **`watch`** / **`watchDir`** — watch a file or directory for changes (survives wipe + recreate).
141
198
  - **`untilLog`** — gate on the first log entry matching a predicate.
142
199
  - **`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
- * Runs a command as an action instance.
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(name: string, cmdOrFile: string, options?: CommandOptions): import("../index.ts").ActionInstance<import("zx").ProcessOutput, import("../core/types.ts").NoEvents, void> | import("../index.ts").ActionInstance<void, import("../core/types.ts").NoEvents, void>;
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>;
@@ -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
- * Runs a command as an action instance.
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(name, cmdOrFile, options = {}) {
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(name).run((ctx) => ctx.proc(name, {
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
34
  const inherit = options.inheritDebugger ?? Boolean(process.env.OMKIT_INHERIT_DEBUGGER);
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
  }
@@ -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, RunHandle } from "./types.ts";
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 RunHandle<Result, Events, Handle> {
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;
@@ -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 a synchronously-throwing body already
101
- // failed inline during `.exec()`) still receives the failure: the emitter snapshots
102
- // the last "error" payload and replays it to a new listener.
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
- // A synchronously-throwing body fails inline during `.exec()`, before this attaches:
135
- // deliver the already-recorded failure now (it won't fail again). Cancellation is
136
- // status "cancelled", not "failed", so a cancelled node never runs the handler.
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
+ });
@@ -110,8 +110,16 @@ export class ExecutionTree {
110
110
  this.append(parent, "child", "launch", `→ ${id} · ${this.logFile(nodePath)}${defined}`);
111
111
  for (const t of spec.tags)
112
112
  node.tag(t);
113
- const promise = currentNode.run(node, () => node.run(spec.body, spec.args));
114
- this.trackNode(promise);
113
+ // Arm the body now, but commit it on a microtask: this lets `withCache`/`tag` chained on
114
+ // the returned handle (before the caller's first await) apply before the body starts.
115
+ // The tracked promise is registered synchronously (so `drive()` waits on it), and the
116
+ // microtask is scheduled unconditionally so a fire-and-forget launch still commits.
117
+ node.arm(spec.body, spec.args);
118
+ const settled = defer();
119
+ this.trackNode(settled.promise);
120
+ queueMicrotask(() => {
121
+ void currentNode.run(node, () => node.commit()).then(() => settled.resolve());
122
+ });
115
123
  return node;
116
124
  }
117
125
  append(node, level, source, message) {
@@ -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>;
@@ -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
- * A constructed-but-not-yet-run action. Inert: it only holds the name, body, args,
6
- * and any tags buffered before launch. `exec()` hands it to the tree, which creates
7
- * the node under the ambient current action and runs it.
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 create = (...args) => new Instance(name, body, args, definedAt);
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);
@@ -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 by
5
- * `instance.exec()` (to find its parent) and by console capture (to attribute a
6
- * `console.*` line) — the single ambient mechanism that replaces `nod` and makes
7
- * scoping compose without threading anything by hand.
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>>;
@@ -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 by
4
- * `instance.exec()` (to find its parent) and by console capture (to attribute a
5
- * `console.*` line) — the single ambient mechanism that replaces `nod` and makes
6
- * scoping compose without threading anything by hand.
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,8 +1,8 @@
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
- * body runs inside the root node's ambient scope, so any `instance.exec()` /
5
- * `step(...)` / `console.*` it reaches attributes correctly. Resolves once the run
4
+ * body runs inside the root node's ambient scope, so any action call / `step(...)` /
5
+ * `console.*` it reaches attributes correctly. Resolves once the run
6
6
  * has torn down and produced its artifacts; never rejects (failures are recorded in
7
7
  * the tree and set the exit code).
8
8
  */
package/dist/core/om.js CHANGED
@@ -2,8 +2,8 @@ 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
- * body runs inside the root node's ambient scope, so any `instance.exec()` /
6
- * `step(...)` / `console.*` it reaches attributes correctly. Resolves once the run
5
+ * body runs inside the root node's ambient scope, so any action call / `step(...)` /
6
+ * `console.*` it reaches attributes correctly. Resolves once the run
7
7
  * has torn down and produced its artifacts; never rejects (failures are recorded in
8
8
  * the tree and set the exit code).
9
9
  */
package/dist/core/step.js CHANGED
@@ -9,7 +9,6 @@ import { action } from "./action.js";
9
9
  export function step(name, fn) {
10
10
  return action(name)
11
11
  .run(fn)()
12
- .exec()
13
12
  .result.then((r) => {
14
13
  if (!r.ok)
15
14
  throw r.error;
@@ -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. `.result` always
69
- * resolves an {@link Outcome} (never throws); `.ref` rejects on failure/cancel.
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 RunHandle<Result = unknown, Events extends object = NoEvents, Handle = void> {
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 `.exec()`. Not called on cancellation.
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 constructed-but-not-yet-run action: what calling an `Action` produces. */
93
- export interface ActionInstance<Result = unknown, Events extends object = NoEvents, Handle = void> {
94
- readonly name: string;
95
- readonly args: readonly unknown[];
96
- /** Buffer a tag before launch; applied onto the node at creation. Chainable. */
97
- tag(name: string): this;
98
- /**
99
- * Gate this action on its **input** files/folders (absolute paths): skip running
100
- * when they're unchanged since the last successful run (a hit resolves
101
- * `undefined` and logs `cached, skipping`). Chain before launching.
102
- */
103
- withCache(...paths: string[]): ActionInstance<Result | undefined, Events, Handle>;
104
- /** Launch under the ambient current node; returns the live {@link RunHandle}. */
105
- exec(): RunHandle<Result, Events, Handle>;
106
- }
107
- /** Any constructed instance, whatever its result/events/handle. */
108
- export type AnyActionInstance = ActionInstance<unknown, object, unknown>;
109
- /** A callable produced by `action`: invoking it builds an {@link ActionInstance}. */
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): ActionInstance<Result, Events, Handle>;
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;
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * omkit (refactored core) — the ExecutionTree model.
3
3
  *
4
4
  * A curated public surface. `om(body)` runs a linear orchestration as the root of
5
- * one run; `action(...)` defines typed units you launch with `.exec()`;
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, ActionInstance, ActionContext, OmContext, RunHandle, Outcome, } from "./core/types.ts";
14
+ export type { Action, Activity, ActionContext, OmContext, Outcome } from "./core/types.ts";
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * omkit (refactored core) — the ExecutionTree model.
3
3
  *
4
4
  * A curated public surface. `om(body)` runs a linear orchestration as the root of
5
- * one run; `action(...)` defines typed units you launch with `.exec()`;
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 { text: `${doneIcon(status)} ${named(e.path, tags)} · ${status}`, persist: status === "failed" };
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")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omkit",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "A tiny runtime for developer workflows: typed actions that pass runtime capabilities and emit typed events, under one run and one shared log. The operational/runtime layer to tskb's knowledge layer.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",