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 CHANGED
@@ -1,124 +1,195 @@
1
1
  # omkit
2
2
 
3
- **The operational-model kit.** _Typed actions. One run. One log._
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 — 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. 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
- 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
+ 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
- om(async () => {
18
- command("api", "npm run dev:api").exec().tag("api:daemon"); // start a daemon…
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
- const ready = await healthcheck({ port: 3000 }).exec().result; // …gate on it…
21
- if (!ready.ok) return;
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 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
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 a typed capability the next step receives and drives. Few workflow tools pass anything but strings between steps.
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
- command healthcheck chromePage
39
- (daemon) (gate) │ .ref
40
- ▼
41
- your driver
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 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.
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 Build = action("Build").run(({ proc }) => proc("tsc")`tsc -b`);
67
+ const build = action("build").run(({ proc }) => proc("tsc")`tsc -b`);
54
68
 
55
- const activity = Build().exec(); // launch; returns the live Activity
69
+ const activity = build(); // calling launches; returns the live Activity
56
70
  ```
57
71
 
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.
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 actions chain without globals or string-scraping.
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 Server = action("Server")
66
- .ref<number>()
79
+ const server = action("server")
80
+ .ref<number>() // this action publishes a port
67
81
  .run(async ({ attach, signal }) => {
68
- attach(await listen()); // publish the capability…
69
- await until(signal); // …and stay alive until teardown
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 Probe = action("Probe").run((_ctx, port: Promise<number>) => drive(port));
87
+ const migrate = action("migrate").run((_ctx, port: Promise<number>) => runMigrations(port));
73
88
 
74
- om(async () => {
75
- const server = Server().exec();
76
- Probe(server.ref).exec(); // Probe receives Server's port, typed
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` — which **never throws**:
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 r = await runTests().exec().result;
88
- if (!r.ok) {
89
- // an expected, operational failure — inspect it and carry on
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, below). A cancelled Activity resolves `{ ok: false, error: CancelledError }`.
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`), attached an `on("error")` listener, or attached `.handleFailure`.
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 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.
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
- watchDir(".build").exec().tag("watch:daemon"); // crash here → run tears down
105
- someFlakyDaemon()
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 launched keep running until Ctrl+C or `cancel()`, so a body that finishes wiring things up doesn't kill the servers it started.
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**. It streams live to the terminal (curated milestones) and is written to a per-run folder:
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 node — the human-readable timelines.
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(...)` (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.
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({ port: 3000 }).exec();
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`** — run a shell command; output streams to the log, killed on teardown.
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
- * 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
- const inherit = options.inheritDebugger ?? Boolean(process.env.OMKIT_INHERIT_DEBUGGER);
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
  }
@@ -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
+ });
@@ -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 = new RunFolder();
40
+ folder;
40
41
  root;
41
42
  rawStream;
42
43
  consoleCapture;
43
- snapshotStore = new SnapshotStore(this.folder);
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
- const promise = currentNode.run(node, () => node.run(spec.body, spec.args));
114
- this.trackNode(promise);
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) {
@@ -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,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
- * body runs inside the root node's ambient scope, so any `instance.exec()` /
5
- * `step(...)` / `console.*` it reaches attributes correctly. Resolves once the run
6
- * has torn down and produced its artifacts; never rejects (failures are recorded in
7
- * the tree and set the exit code).
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
- * body runs inside the root node's ambient scope, so any `instance.exec()` /
6
- * `step(...)` / `console.*` it reaches attributes correctly. Resolves once the run
7
- * has torn down and produced its artifacts; never rejects (failures are recorded in
8
- * the tree and set the exit code).
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
@@ -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;
@@ -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)
@@ -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;
@@ -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 with `.exec()`;
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, 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
@@ -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 with `.exec()`;
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 { 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")
@@ -1,13 +1,16 @@
1
1
  /**
2
- * Owns this run's single output directory — `logs/<name>/<date>/<time>/` — where
3
- * `raw.jsonl`, the per-action `.log` files, rollups, and `result.json` all live.
4
- * The path is computed once, lazily, and always returned absolute so links written
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
- /** The entry script's base name — the run's folder name. */
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>/` — where
6
- * `raw.jsonl`, the per-action `.log` files, rollups, and `result.json` all live.
7
- * The path is computed once, lazily, and always returned absolute so links written
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
- /** The entry script's base name — the run's folder name. */
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
- const entry = process.argv[1];
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.8",
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",