omkit 0.8.20 → 0.8.22

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
@@ -220,19 +220,20 @@ The wrapper is not `===` to the original object, so code that compares it agains
220
220
 
221
221
  The action `exec` receives an `ActionContext`; the om body receives an `OmContext`.
222
222
 
223
- | Member | `ActionContext` | `OmContext` | Purpose |
224
- | ----------------------------- | :-------------: | :---------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
225
- | `signal` | ✓ | ✓ | `AbortSignal` that fires on cancel or teardown. Honor it to stop early. |
226
- | `logs` | ✓ | ✓ | The run's combined log, read-only: `logs.subscribe({ replay? })` is an async iterable of entries. |
227
- | `tag(name)` | ✓ | ✓ | Tag this node. |
228
- | `assert(cond, message)` | ✓ | ✓ | Record a pass or fail in the run's verdict. Never throws. |
229
- | `snapshot(name, value)` | ✓ | ✓ | Write `value` as JSON into the run folder; resolves the file's path. |
230
- | `artifact(name, file, opts?)` | ✓ | ✓ | Label a file you wrote (optional `description`, MIME inferred). It appears in `result.json` and the report. |
231
- | `artifactsFolder` | ✓ | ✓ | Absolute path of the run's output folder. Write artifacts here. |
232
- | `emit(event, payload)` | ✓ | | Emit one of the action's declared events. |
233
- | `attach(handle)` | ✓ | | Publish the action's handle and resolve its `.ref`. The first call wins. |
234
- | `proc(name, opts?)` | ✓ | | Spawn a child process: ``proc("tsc")`tsc -b` `` ([zx](https://github.com/google/zx) syntax). Its output goes to this action's log, and it is killed on teardown. |
235
- | `cancel()` | | ✓ | Tear the whole run down. |
223
+ | Member | `ActionContext` | `OmContext` | Purpose |
224
+ | ----------------------------- | :-------------: | :---------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
225
+ | `signal` | ✓ | ✓ | `AbortSignal` that fires on cancel or teardown. Honor it to stop early. |
226
+ | `untilAborted()` | ✓ | ✓ | Resolves when `signal` fires: `await ctx.untilAborted(); cleanup()`. Code after the await stays attributed to this node, unlike an `"abort"` listener. Also exported standalone as `untilAborted(signal)`. |
227
+ | `logs` | ✓ | ✓ | The run's combined log, read-only: `logs.subscribe({ replay? })` is an async iterable of entries. |
228
+ | `tag(name)` | ✓ | ✓ | Tag this node. |
229
+ | `assert(cond, message)` | ✓ | ✓ | Record a pass or fail in the run's verdict. Never throws. |
230
+ | `snapshot(name, value)` | ✓ | ✓ | Write `value` as JSON into the run folder; resolves the file's path. |
231
+ | `artifact(name, file, opts?)` | ✓ | ✓ | Label a file you wrote (optional `description`, MIME inferred). It appears in `result.json` and the report. |
232
+ | `artifactsFolder` | ✓ | ✓ | Absolute path of the run's output folder. Write artifacts here. |
233
+ | `emit(event, payload)` | ✓ | | Emit one of the action's declared events. |
234
+ | `attach(handle)` | ✓ | | Publish the action's handle and resolve its `.ref`. The first call wins. |
235
+ | `proc(name, opts?)` | ✓ | | Spawn a child process: ``proc("tsc")`tsc -b` `` ([zx](https://github.com/google/zx) syntax). Its output goes to this action's log, and it is killed on teardown. |
236
+ | `cancel()` | | ✓ | Tear the whole run down. |
236
237
 
237
238
  `console.*` inside an action or the om body is captured and attributed to that node's log.
238
239
 
@@ -316,8 +317,9 @@ Ready-made actions built on the same engine. For anything else, write your own w
316
317
  | `command(cmd, { args?, cwd?, env? })` | Runs a shell command, or an executable when `args` is given, as an action named after it. Output goes to its log; killed on teardown. | — | Rejects on a non-zero exit. |
317
318
  | `healthcheck({ url \| host+port+path, status?, body?, intervalMs?, timeoutMs? })` | Polls until the endpoint answers with a matching status (and body). | `healthy` | Result `{ url, status, attempts }`. |
318
319
  | `portFree(port, { host?, intervalMs?, timeoutMs? })` | Polls until nothing listens on the port, so a restart can rebind. | `free` | Result `{ port, host, attempts }`. |
320
+ | `pollUntil(description, predicate, { intervalMs?, timeoutMs? })` | Calls `predicate(signal)` until it returns something other than `false`, `null` or `undefined`. The node is named after `description`. | — | Result: the predicate's value. |
319
321
  | `untilLog(matcher, { timeoutMs?, replay? })` | Waits for the first run-log entry matching `matcher(entry)`. | `match` | The matching entry. |
320
- | `tailLog(file, { fromStart?, encoding? })` | Tails a file another process writes and folds its lines into the run log. | — | — |
322
+ | `tailLog(file, { fromStart?, encoding?, debounceMs?, pollMs?, diagnostics? })` | Tails a file another process writes and folds its lines into the run log. | — | — |
321
323
  | `watch(file, { onCreate?, onUpdate?, onDelete?, onMissing? })` | Watches one file and survives it being deleted and recreated. | — | — |
322
324
  | `watchDir(dir, { debounceMs? })` | Watches a directory tree and survives it being wiped and recreated. | `ready`, `create`, `update`, `delete`, `removed` | — |
323
325
  | `prompt({ kind?, message, choices?, default?, timeoutMs?, until? })` | Asks in the terminal (or the picker, or the MCP client). `kind` is `"input"`, `"choice"` or `"multiline"`. Falls back to `default` on timeout. | — | Result: the answer. |
@@ -6,6 +6,8 @@ export { command } from "./command.ts";
6
6
  export type { CommandOptions } from "./command.ts";
7
7
  export { untilLog } from "./until-log.ts";
8
8
  export type { LogMatcher, UntilLogOptions, UntilLogEvents } from "./until-log.ts";
9
+ export { pollUntil } from "./poll-until.ts";
10
+ export type { PollUntilOptions, PollValue } from "./poll-until.ts";
9
11
  export { healthcheck } from "./healthcheck.ts";
10
12
  export type { HealthcheckOptions, HealthcheckResult, HealthcheckEvents, StatusMatcher, BodyMatcher, } from "./healthcheck.ts";
11
13
  export { portFree } from "./port-free.ts";
@@ -4,6 +4,7 @@
4
4
  */
5
5
  export { command } from "./command.js";
6
6
  export { untilLog } from "./until-log.js";
7
+ export { pollUntil } from "./poll-until.js";
7
8
  export { healthcheck } from "./healthcheck.js";
8
9
  export { portFree } from "./port-free.js";
9
10
  export { watch } from "./watch.js";
@@ -0,0 +1,16 @@
1
+ /** What a {@link pollUntil} predicate returns: `false`, `null` or `undefined` mean "not yet". */
2
+ export type PollValue<T> = T | false | null | undefined;
3
+ export interface PollUntilOptions {
4
+ /** Delay between checks. Default 200ms. */
5
+ intervalMs?: number;
6
+ /** Reject after this long overall. Default 30000ms; pass `Infinity` to wait until teardown. */
7
+ timeoutMs?: number;
8
+ }
9
+ /**
10
+ * Gate: calls `predicate` every `intervalMs` until it returns something other than `false`,
11
+ * `null` or `undefined`, and resolves with that value — so it can find a thing as well as wait
12
+ * for one. The node is named after `description` (like `command`), which also heads the
13
+ * timeout error. The predicate gets this node's abort signal to cancel its own work (a
14
+ * `fetch`, say); a predicate that throws fails the gate rather than being retried.
15
+ */
16
+ export declare function pollUntil<T>(description: string, predicate: (signal: AbortSignal) => PollValue<T> | Promise<PollValue<T>>, options?: PollUntilOptions): import("../index.ts").Activity<T, import("../core/types.ts").NoEvents, void>;
@@ -0,0 +1,30 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
2
+ import { action } from "../core/action.js";
3
+ /**
4
+ * Gate: calls `predicate` every `intervalMs` until it returns something other than `false`,
5
+ * `null` or `undefined`, and resolves with that value — so it can find a thing as well as wait
6
+ * for one. The node is named after `description` (like `command`), which also heads the
7
+ * timeout error. The predicate gets this node's abort signal to cancel its own work (a
8
+ * `fetch`, say); a predicate that throws fails the gate rather than being retried.
9
+ */
10
+ export function pollUntil(description, predicate, options = {}) {
11
+ const { intervalMs = 200, timeoutMs = 30_000 } = options;
12
+ return action(description).run(async ({ signal }) => {
13
+ const deadline = Date.now() + timeoutMs;
14
+ let attempts = 0;
15
+ for (;;) {
16
+ signal.throwIfAborted();
17
+ attempts++;
18
+ const value = await predicate(signal);
19
+ if (value !== false && value !== null && value !== undefined) {
20
+ console.log(`met after ${attempts} ${attempts === 1 ? "check" : "checks"}`);
21
+ return value;
22
+ }
23
+ const remaining = deadline - Date.now();
24
+ if (remaining <= 0) {
25
+ throw new Error(`${description}: not met within ${timeoutMs}ms (${attempts} checks)`);
26
+ }
27
+ await sleep(Math.min(intervalMs, remaining), undefined, { signal });
28
+ }
29
+ })();
30
+ }
@@ -5,6 +5,18 @@ export interface TailLogOptions {
5
5
  encoding?: BufferEncoding;
6
6
  /** Coalesce rapid writes within this window. Default 50ms. */
7
7
  debounceMs?: number;
8
+ /**
9
+ * Also re-check the file's size this often (ms), independent of watch events; 0 disables.
10
+ * Needed on Windows, where appends through a handle another process keeps open often raise
11
+ * no directory notification until that handle closes. Default 500ms.
12
+ */
13
+ pollMs?: number;
14
+ /**
15
+ * Log `[diag]` lines (watch events, filter decisions, pump/read activity, sizes and
16
+ * offsets — never file contents) into this action's log. Default: on when
17
+ * `OMKIT_DEBUG` is set.
18
+ */
19
+ diagnostics?: boolean;
8
20
  }
9
21
  /**
10
22
  * Tails a (possibly not-yet-existing) text file and streams each appended line
@@ -1,3 +1,4 @@
1
+ import { AsyncResource } from "node:async_hooks";
1
2
  import { watch as fsWatch } from "node:fs";
2
3
  import { open, stat } from "node:fs/promises";
3
4
  import { basename, dirname, resolve } from "node:path";
@@ -14,7 +15,8 @@ export const tailLog = action("tailLog").run(async ({ signal }, target, opts = {
14
15
  const file = resolve(target);
15
16
  const dir = dirname(file);
16
17
  const name = basename(file);
17
- const { fromStart = false, encoding = "utf8", debounceMs = 50 } = opts;
18
+ const { fromStart = false, encoding = "utf8", debounceMs = 50, pollMs = 500, diagnostics = Boolean(process.env.OMKIT_DEBUG), } = opts;
19
+ const diag = diagnostics ? (msg) => console.log(`[diag] ${msg}`) : undefined;
18
20
  let pos = 0; // bytes consumed so far
19
21
  let pending = ""; // partial trailing line, buffered until its newline
20
22
  let decoder = new StringDecoder(encoding);
@@ -33,11 +35,15 @@ export const tailLog = action("tailLog").run(async ({ signal }, target, opts = {
33
35
  try {
34
36
  size = (await stat(file)).size;
35
37
  }
36
- catch {
38
+ catch (err) {
39
+ diag?.(`stat failed: ${err.code ?? err}`);
37
40
  return; // missing/unreadable — wait for the next event
38
41
  }
42
+ if (size !== pos)
43
+ diag?.(`read size=${size} pos=${pos}`); // quiet on idle poll ticks
39
44
  if (size < pos) {
40
45
  // Truncation/rotation: restart from the top, drop stale buffers.
46
+ diag?.(`truncated ${pos} -> ${size}, restarting from 0`);
41
47
  pos = 0;
42
48
  pending = "";
43
49
  decoder = new StringDecoder(encoding);
@@ -54,6 +60,7 @@ export const tailLog = action("tailLog").run(async ({ signal }, target, opts = {
54
60
  if (bytesRead === 0)
55
61
  break;
56
62
  pos += bytesRead;
63
+ diag?.(`read bytes=${bytesRead} pos=${pos}`);
57
64
  feed(decoder.write(buffer.subarray(0, bytesRead)));
58
65
  }
59
66
  }
@@ -64,9 +71,13 @@ export const tailLog = action("tailLog").run(async ({ signal }, target, opts = {
64
71
  // Serialize reads; coalesce extra triggers into a single follow-up pass.
65
72
  let reading = false;
66
73
  let again = false;
74
+ let stopped = false;
67
75
  const pump = () => {
76
+ if (stopped)
77
+ return;
68
78
  if (reading) {
69
79
  again = true;
80
+ diag?.("pump: read in flight, queued follow-up");
70
81
  return;
71
82
  }
72
83
  reading = true;
@@ -77,6 +88,10 @@ export const tailLog = action("tailLog").run(async ({ signal }, target, opts = {
77
88
  await readDelta();
78
89
  } while (again);
79
90
  }
91
+ catch (err) {
92
+ diag?.(`read failed: ${err.code ?? err}`);
93
+ throw err;
94
+ }
80
95
  finally {
81
96
  reading = false;
82
97
  }
@@ -86,7 +101,11 @@ export const tailLog = action("tailLog").run(async ({ signal }, target, opts = {
86
101
  const schedule = () => {
87
102
  if (timer)
88
103
  clearTimeout(timer);
89
- timer = setTimeout(pump, debounceMs);
104
+ diag?.(`schedule pump in ${debounceMs}ms${timer ? " (reset pending timer)" : ""}`);
105
+ timer = setTimeout(() => {
106
+ timer = undefined;
107
+ pump();
108
+ }, debounceMs);
90
109
  };
91
110
  try {
92
111
  pos = fromStart ? 0 : (await stat(file)).size;
@@ -95,22 +114,42 @@ export const tailLog = action("tailLog").run(async ({ signal }, target, opts = {
95
114
  pos = 0;
96
115
  }
97
116
  console.log(`tailing ${file}${fromStart ? " (from start)" : ""}`);
117
+ diag?.(`watching dir=${dir} name=${name} startPos=${pos}`);
98
118
  if (fromStart)
99
119
  pump();
100
- const watcher = fsWatch(dir, (_event, filename) => {
101
- if (filename === null || filename === name)
120
+ // Fallback for missed notifications. Goes through the serialized pump and reads are
121
+ // offset-based, so a tick racing a watch-triggered read can't overlap or duplicate it.
122
+ const poll = pollMs > 0 ? setInterval(pump, pollMs) : undefined;
123
+ const watcher = fsWatch(dir, (event, filename) => {
124
+ const accepted = filename === null || filename === name;
125
+ diag?.(`watch ${event} filename=${filename} ${accepted ? "accepted" : "ignored"}`);
126
+ if (accepted)
102
127
  schedule();
103
128
  });
129
+ if (diag) {
130
+ // Observe only: rethrow so a watcher error stays as loud as it is without a listener.
131
+ watcher.on("error", (err) => {
132
+ diag(`watcher error: ${err.message}`);
133
+ throw err;
134
+ });
135
+ }
104
136
  return new Promise((resolveRun) => {
105
- const stop = () => {
137
+ // Bound to this action's async context: the abort listener otherwise runs in the
138
+ // canceller's, and the flushed partial line would be attributed to that node instead.
139
+ const stop = AsyncResource.bind(() => {
140
+ diag?.(`stop: aborted, pos=${pos} pendingChars=${pending.length}`);
141
+ stopped = true;
106
142
  if (timer)
107
143
  clearTimeout(timer);
144
+ if (poll)
145
+ clearInterval(poll);
108
146
  watcher.close();
109
147
  const tail = pending.replace(/\r$/, "");
148
+ pending = "";
110
149
  if (tail)
111
150
  console.log(tail);
112
151
  resolveRun();
113
- };
152
+ });
114
153
  if (signal.aborted)
115
154
  stop();
116
155
  else
@@ -1,6 +1,7 @@
1
1
  import { defer } from "../foundation/Deferred.js";
2
2
  import { events } from "../foundation/events.js";
3
3
  import { CancelledError } from "../foundation/CancelledError.js";
4
+ import { untilAborted } from "../foundation/untilAborted.js";
4
5
  import { renderValue } from "../foundation/format.js";
5
6
  import { callerSite } from "../foundation/callsite.js";
6
7
  import { currentNode } from "./context.js";
@@ -279,6 +280,7 @@ export class ActionRun {
279
280
  context() {
280
281
  return {
281
282
  signal: this.signal,
283
+ untilAborted: () => untilAborted(this.signal),
282
284
  logs: this.store,
283
285
  emit: ((key, payload) => {
284
286
  this.emitter.emit(key, payload);
package/dist/core/om.js CHANGED
@@ -42,6 +42,7 @@ function launch(name, site, description, exposure, body, schema) {
42
42
  tree.root.meta = { describeArgs: () => describeSchema(schema) };
43
43
  const rootBody = (ctx) => body({
44
44
  signal: ctx.signal,
45
+ untilAborted: ctx.untilAborted,
45
46
  logs: ctx.logs,
46
47
  tag: ctx.tag,
47
48
  cancel: () => tree.cancel(),
@@ -28,6 +28,11 @@ export type InstanceEvents<Events extends object, Result> = Events & SystemEvent
28
28
  export interface ActionContext<Events extends object = NoEvents, Handle = void> {
29
29
  /** This node's abort signal — honor it to stop early. */
30
30
  readonly signal: AbortSignal;
31
+ /**
32
+ * Resolves once `signal` aborts — the daemon idiom `await ctx.untilAborted(); cleanup()`.
33
+ * Code after the await stays attributed to this node (an `"abort"` listener would not).
34
+ */
35
+ readonly untilAborted: () => Promise<void>;
31
36
  /** The combined run log, read-only (subscribe/observe; never append). */
32
37
  readonly logs: ReadableLog;
33
38
  /** Emit one of this action's declared events (also logged onto the timeline). */
@@ -121,6 +126,11 @@ export interface OmBuilderArgs<S extends ZodTypeLike> {
121
126
  export interface OmContext {
122
127
  /** The run's (root) abort signal. */
123
128
  readonly signal: AbortSignal;
129
+ /**
130
+ * Resolves once `signal` aborts — the daemon idiom `await ctx.untilAborted(); cleanup()`.
131
+ * Code after the await stays attributed to this node (an `"abort"` listener would not).
132
+ */
133
+ readonly untilAborted: () => Promise<void>;
124
134
  /** Tag the root run. */
125
135
  readonly tag: (name: string) => void;
126
136
  /** Tear the whole run down. */
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Resolves once `signal` aborts (immediately if it already has). Never rejects. Unlike a raw
3
+ * `"abort"` listener, code after `await untilAborted(signal)` resumes in the *awaiting* async
4
+ * context, so its `console.*` output stays attributed to the node that waited — a listener
5
+ * runs in the canceller's context instead.
6
+ */
7
+ export declare function untilAborted(signal: AbortSignal): Promise<void>;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Resolves once `signal` aborts (immediately if it already has). Never rejects. Unlike a raw
3
+ * `"abort"` listener, code after `await untilAborted(signal)` resumes in the *awaiting* async
4
+ * context, so its `console.*` output stays attributed to the node that waited — a listener
5
+ * runs in the canceller's context instead.
6
+ */
7
+ export function untilAborted(signal) {
8
+ return new Promise((resolve) => {
9
+ if (signal.aborted)
10
+ resolve();
11
+ else
12
+ signal.addEventListener("abort", () => resolve(), { once: true });
13
+ });
14
+ }
package/dist/index.d.ts CHANGED
@@ -11,4 +11,5 @@ 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 { untilAborted } from "./foundation/untilAborted.ts";
14
15
  export type { Action, Activity, ActionContext, OmBuilder, OmBuilderArgs, OmContext, OmDescription, } from "./core/types.ts";
package/dist/index.js CHANGED
@@ -11,3 +11,4 @@ export { om } from "./core/om.js";
11
11
  export { action } from "./core/action.js";
12
12
  export { step } from "./core/step.js";
13
13
  export { CancelledError, isCancelled } from "./foundation/CancelledError.js";
14
+ export { untilAborted } from "./foundation/untilAborted.js";