omkit 0.0.8 → 0.0.9

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.
@@ -91,12 +91,13 @@ export interface ActionInstance<Result = unknown, Events extends object = NoEven
91
91
  /** Subscribe to a declared or system event (`done`/`error`) with a handler. */
92
92
  on<K extends keyof InstanceEvents<Events, Result>>(key: K, handler: EventHandler<InstanceEvents<Events, Result>[K]>): void;
93
93
  /**
94
- * A promise for the next occurrence of an event: resolves with its payload —
95
- * or rejects if the action settles first (`error`, or `done` without the
96
- * event), so an awaiter never hangs. Use {@link on} for ongoing/handler
97
- * subscriptions; `once` is the awaitable, one-value form.
94
+ * A promise for the next occurrence of an event. It **never rejects**: it
95
+ * resolves with the event's payload, or with `undefined` if the action settles
96
+ * (`done`) before the event ever fires — so awaiting it in a spin body can't
97
+ * throw and can't hang. `once("done")` yields the {@link Outcome}. Use {@link on}
98
+ * for ongoing/handler subscriptions; `once` is the awaitable, one-value form.
98
99
  */
99
- once<K extends keyof InstanceEvents<Events, Result>>(key: K): Promise<InstanceEvents<Events, Result>[K]>;
100
+ once<K extends keyof InstanceEvents<Events, Result>>(key: K): Promise<InstanceEvents<Events, Result>[K] | undefined>;
100
101
  /**
101
102
  * Gate this action on its **inputs** — the files/folders it *reads* (its
102
103
  * sources), **not** what it produces. `paths` (each a file or a folder) are
@@ -125,6 +126,12 @@ export interface ActionInstance<Result = unknown, Events extends object = NoEven
125
126
  * still excluding non-instances.
126
127
  */
127
128
  export type AnyActionInstance = ActionInstance<unknown, object, unknown>;
129
+ /**
130
+ * If `value` is some action instance's `.ref` promise, the instance that owns it
131
+ * (the producer of that handle); otherwise `undefined`. Used by the run to verify
132
+ * a `.ref` dependency was launched before the action that consumes it.
133
+ */
134
+ export declare const producerOfRef: (value: unknown) => AnyActionInstance | undefined;
128
135
  /** A callable produced by `action`: invoking it builds an {@link ActionInstance}. */
129
136
  export interface Action<Args extends unknown[], Result, Events extends object = NoEvents, Handle = void> {
130
137
  (...args: Args): ActionInstance<Result, Events, Handle>;
@@ -2,6 +2,16 @@ import { events } from "./events.js";
2
2
  import { createProc } from "./process.js";
3
3
  import { fingerprint, read, write, resolvePaths } from "./folder-cache.js";
4
4
  import { artifactsFolder } from "./output.js";
5
+ // Maps an instance's `.ref` promise back to the instance that produces it, so the
6
+ // run can catch a consumer launched before (or without) its `.ref` producer —
7
+ // which would otherwise await a handle that never arrives and hang silently.
8
+ const refOwners = new WeakMap();
9
+ /**
10
+ * If `value` is some action instance's `.ref` promise, the instance that owns it
11
+ * (the producer of that handle); otherwise `undefined`. Used by the run to verify
12
+ * a `.ref` dependency was launched before the action that consumes it.
13
+ */
14
+ export const producerOfRef = (value) => typeof value === "object" && value !== null ? refOwners.get(value) : undefined;
5
15
  /** A promise paired with its settlers, for the set-once handle. */
6
16
  const deferred = () => {
7
17
  let resolve;
@@ -18,15 +28,17 @@ const exitCodeOf = (error) => {
18
28
  return typeof code === "number" ? code : undefined;
19
29
  };
20
30
  /**
21
- * A promise for the next emit of `key`. It races the event against the
22
- * instance's terminal events: if the action errors first it rejects with that
23
- * error, and if it completes (`done`) without ever emitting `key` it rejects too
24
- * — so an awaiter can never hang. A retained snapshot resolves it immediately.
31
+ * A promise for the next emit of `key`. It **never rejects**: it resolves with
32
+ * the event's payload, or with `undefined` if the action settles (`done` — which
33
+ * always fires) before `key` ever does. So an awaiter neither hangs nor throws:
34
+ * a waited-for event that won't come simply yields `undefined`. A retained
35
+ * snapshot resolves it immediately. (Not used for `key === "done"`, which reads
36
+ * straight from the settled Outcome.)
25
37
  */
26
- const awaitEvent = (emitter, name, key) => new Promise((resolve, reject) => {
38
+ const awaitEvent = (emitter, key) => new Promise((resolve) => {
27
39
  const offs = [];
28
40
  let settled = false;
29
- // First of [the event | error | done] to fire wins; the rest are unsubscribed.
41
+ // First of [the event | done] to fire wins; the rest are unsubscribed.
30
42
  const settle = (act) => {
31
43
  if (settled)
32
44
  return;
@@ -35,15 +47,14 @@ const awaitEvent = (emitter, name, key) => new Promise((resolve, reject) => {
35
47
  off();
36
48
  act();
37
49
  };
38
- // listenOnce is keyed by E; the system keys (error/done) are always present
39
- // on an instance's event map, so reach them through a loosened view.
50
+ // listenOnce is keyed by E; the system `done` key is always present on an
51
+ // instance's event map, so reach it through a loosened view.
40
52
  const listen = emitter.listenOnce;
41
53
  offs.push(listen(key, (payload) => settle(() => resolve(payload))));
42
- if (key !== "error") {
43
- offs.push(listen("error", (err) => settle(() => reject(err))));
44
- }
54
+ // `done` fires once on any settle (success or failure); if it beats `key`,
55
+ // the event will never come — resolve `undefined` rather than reject.
45
56
  if (key !== "done") {
46
- offs.push(listen("done", () => settle(() => reject(new Error(`${name}: settled before "${String(key)}"`)))));
57
+ offs.push(listen("done", () => settle(() => resolve(undefined))));
47
58
  }
48
59
  // A retained snapshot fires a listen synchronously during registration; if
49
60
  // that already settled us, drop any listeners registered afterwards.
@@ -111,8 +122,8 @@ const build = (name, exec) => {
111
122
  }, (error) => {
112
123
  const exitCode = exitCodeOf(error);
113
124
  const outcome = exitCode === undefined ? { ok: false, error } : { ok: false, error, exitCode };
114
- // `error` first, so a pending `once(<declaredEvent>)` rejects on it
115
- // (settled before its event) rather than resolving on `done`.
125
+ // Emit `error` (for `.on("error")` handlers), then settle `done` with
126
+ // the Outcome — the canonical failure signal that `.done`/`once` read.
116
127
  systemEmit("error", error);
117
128
  settled.resolve(outcome);
118
129
  systemEmit("done", outcome);
@@ -128,10 +139,9 @@ const build = (name, exec) => {
128
139
  },
129
140
  once(key) {
130
141
  // `done` always fires with the Outcome and never "fails" the await, so it
131
- // reads straight from the settled promise; other keys race via awaitEvent.
132
- return (key === "done"
133
- ? settled.promise
134
- : awaitEvent(emitter, name, key));
142
+ // reads straight from the settled promise; other keys race via awaitEvent
143
+ // (which resolves `undefined` if the action settles before the event).
144
+ return (key === "done" ? settled.promise : awaitEvent(emitter, key));
135
145
  },
136
146
  // Wrap this instance in a fresh one (reusing the engine for its own
137
147
  // emitter/ref/on/once) that fingerprints `paths` and either skips or
@@ -159,6 +169,9 @@ const build = (name, exec) => {
159
169
  })();
160
170
  },
161
171
  };
172
+ // Register this instance as the producer of its `.ref`, so the run can catch
173
+ // a consumer built with this `.ref` that gets launched before this instance.
174
+ refOwners.set(instance.ref, instance);
162
175
  return instance;
163
176
  };
164
177
  return Object.assign(create, { actionName: name });
@@ -38,6 +38,8 @@ export interface Logger {
38
38
  export declare class LogsCollector implements Logger {
39
39
  private sequence;
40
40
  private readonly history;
41
+ private readonly maxHistory;
42
+ private readonly trimHistoryTo;
41
43
  private readonly subscribers;
42
44
  private ended;
43
45
  /**
@@ -50,12 +52,12 @@ export declare class LogsCollector implements Logger {
50
52
  /** Raw store: records the line verbatim, source included; presentation is the renderer's job. */
51
53
  append(entry: LogInput): void;
52
54
  /**
53
- * Bind the run's abort signal: when it aborts (teardown), every subscription
54
- * ends — each parked or future `for await` returns — so subscribers don't each
55
- * have to watch the signal. The collector *is* the world's log and the signal
56
- * is the world ending; the log closes with it.
55
+ * End the log: every subscription returns and no new one is accepted. Called
56
+ * once at run finalize — *after* the last entry (e.g. `finished`) is appended —
57
+ * so `streamLog` captures the whole run before its stream closes. Kept separate
58
+ * from teardown so entries logged *during* teardown still stream/drain live.
57
59
  */
58
- endOn(signal: AbortSignal): void;
60
+ close(): void;
59
61
  /** EOF for readers: end every live subscription and refuse to register new ones. */
60
62
  private closeAll;
61
63
  subscribe({ replay }?: SubscribeOptions): AsyncIterable<LogEntry>;
@@ -1,7 +1,14 @@
1
1
  import { AsyncQueue } from "../helpers/AsyncQueue.js";
2
2
  export class LogsCollector {
3
3
  sequence = 0;
4
+ // Recent entries retained in memory, only to replay to late subscribers. The
5
+ // *complete* record is streamed to run.jsonl on disk, so this is capped — an
6
+ // indefinite run (dev servers, tailing big logs) must not grow without bound.
4
7
  history = [];
8
+ // Cap and the size we trim back to when it's exceeded (trimming in batches keeps
9
+ // append amortized O(1) instead of O(n) per line once full).
10
+ maxHistory = 100_000;
11
+ trimHistoryTo = 90_000;
5
12
  subscribers = new Set();
6
13
  // Set once the bound signal aborts: the log has reached EOF for readers.
7
14
  ended = false;
@@ -24,22 +31,23 @@ export class LogsCollector {
24
31
  timestamp: Date.now(),
25
32
  };
26
33
  this.history.push(log);
34
+ // Cap the in-memory history — the durable copy is already on disk (run.jsonl).
35
+ if (this.history.length > this.maxHistory) {
36
+ this.history.splice(0, this.history.length - this.trimHistoryTo);
37
+ }
27
38
  for (const subscriber of this.subscribers) {
28
39
  // push the log into the queue for every subscriber to receive
29
40
  subscriber.push(log);
30
41
  }
31
42
  }
32
43
  /**
33
- * Bind the run's abort signal: when it aborts (teardown), every subscription
34
- * ends — each parked or future `for await` returns — so subscribers don't each
35
- * have to watch the signal. The collector *is* the world's log and the signal
36
- * is the world ending; the log closes with it.
44
+ * End the log: every subscription returns and no new one is accepted. Called
45
+ * once at run finalize — *after* the last entry (e.g. `finished`) is appended —
46
+ * so `streamLog` captures the whole run before its stream closes. Kept separate
47
+ * from teardown so entries logged *during* teardown still stream/drain live.
37
48
  */
38
- endOn(signal) {
39
- if (signal.aborted)
40
- this.closeAll();
41
- else
42
- signal.addEventListener("abort", () => this.closeAll(), { once: true });
49
+ close() {
50
+ this.closeAll();
43
51
  }
44
52
  /** EOF for readers: end every live subscription and refuse to register new ones. */
45
53
  closeAll() {
@@ -38,13 +38,24 @@ export interface SegmentOptions {
38
38
  threshold?: number;
39
39
  }
40
40
  /**
41
- * Segments a full, ordered entry list into {@link DisplayItem}s. Milestones and
42
- * runs no longer than `threshold` become `line` items byte-identical to
43
- * {@link createRenderer}; a run longer than `threshold` becomes a single
44
- * `collapsed` item carrying every line of the run. Pure — no filesystem, no
45
- * snapshot writing.
41
+ * A stateful, **streaming** segmenter: `push` each {@link LogEntry} in order and
42
+ * it returns the {@link DisplayItem}s that just became final (0, 1, or a whole
43
+ * flushed run), then `end()` flushes the last pending run. Only the current
44
+ * same-source run is held in memory, so segmenting a huge log stays bounded —
45
+ * {@link writeLog} feeds it straight from `run.jsonl` on disk. Output is
46
+ * byte-identical to {@link segmentForFile}. Pure — no filesystem.
46
47
  */
47
- export declare const segmentForFile: (entries: readonly LogEntry[], { threshold }?: SegmentOptions) => DisplayItem[];
48
+ export declare const createFileSegmenter: ({ threshold }?: SegmentOptions) => {
49
+ push(entry: LogEntry): DisplayItem[];
50
+ end(): DisplayItem[];
51
+ };
52
+ /**
53
+ * Segments a full, ordered entry list into {@link DisplayItem}s (the array form of
54
+ * {@link createFileSegmenter}). Milestones and runs no longer than `threshold`
55
+ * become `line` items byte-identical to {@link createRenderer}; a run longer than
56
+ * `threshold` becomes a single `collapsed` item carrying every line of the run.
57
+ */
58
+ export declare const segmentForFile: (entries: readonly LogEntry[], options?: SegmentOptions) => DisplayItem[];
48
59
  /**
49
60
  * Renders a {@link CollapsedRun} as its `head` lines under the `▸ <source>`
50
61
  * header, then a single indented `📎` pointer line to the off-loaded snapshot at
@@ -49,27 +49,27 @@ export const COLLAPSE_HEAD = 5;
49
49
  /** Only runs *longer than* this collapse, so a trigger always hides a meaningful amount. */
50
50
  export const COLLAPSE_THRESHOLD = 15;
51
51
  /**
52
- * Segments a full, ordered entry list into {@link DisplayItem}s. Milestones and
53
- * runs no longer than `threshold` become `line` items byte-identical to
54
- * {@link createRenderer}; a run longer than `threshold` becomes a single
55
- * `collapsed` item carrying every line of the run. Pure — no filesystem, no
56
- * snapshot writing.
52
+ * A stateful, **streaming** segmenter: `push` each {@link LogEntry} in order and
53
+ * it returns the {@link DisplayItem}s that just became final (0, 1, or a whole
54
+ * flushed run), then `end()` flushes the last pending run. Only the current
55
+ * same-source run is held in memory, so segmenting a huge log stays bounded —
56
+ * {@link writeLog} feeds it straight from `run.jsonl` on disk. Output is
57
+ * byte-identical to {@link segmentForFile}. Pure — no filesystem.
57
58
  */
58
- export const segmentForFile = (entries, { threshold = COLLAPSE_THRESHOLD } = {}) => {
59
- const items = [];
59
+ export const createFileSegmenter = ({ threshold = COLLAPSE_THRESHOLD } = {}) => {
60
60
  // The run currently being accumulated: a same-source stretch of output lines.
61
61
  let run;
62
- // Flush the pending run: collapse it if it ran long, else emit grouped lines
63
- // exactly as createRenderer would (header on the first line, indent the rest).
64
- const flush = () => {
62
+ // Flush the pending run into `out`: collapse it if it ran long, else emit
63
+ // grouped lines exactly as createRenderer would (header first, indent the rest).
64
+ const flush = (out) => {
65
65
  if (!run)
66
66
  return;
67
67
  if (run.lines.length > threshold) {
68
- items.push({ kind: "collapsed", run: { source: run.source, lines: run.lines } });
68
+ out.push({ kind: "collapsed", run: { source: run.source, lines: run.lines } });
69
69
  }
70
70
  else {
71
71
  run.lines.forEach((message, i) => {
72
- items.push({
72
+ out.push({
73
73
  kind: "line",
74
74
  text: i === 0 ? `${marker("action", run.source)}\n\t${message}` : `\t${message}`,
75
75
  });
@@ -77,23 +77,44 @@ export const segmentForFile = (entries, { threshold = COLLAPSE_THRESHOLD } = {})
77
77
  }
78
78
  run = undefined;
79
79
  };
80
- for (const entry of entries) {
81
- const milestone = MILESTONE[entry.level];
82
- if (milestone) {
83
- // A milestone breaks the current run, then renders as its own line.
84
- flush();
85
- items.push({ kind: "line", text: marker(milestone, entry.message) });
86
- continue;
87
- }
88
- if (run && run.source === entry.source) {
89
- run.lines.push(entry.message);
90
- }
91
- else {
92
- flush();
93
- run = { source: entry.source, lines: [entry.message] };
94
- }
95
- }
96
- flush();
80
+ return {
81
+ push(entry) {
82
+ const out = [];
83
+ const milestone = MILESTONE[entry.level];
84
+ if (milestone) {
85
+ // A milestone breaks the current run, then renders as its own line.
86
+ flush(out);
87
+ out.push({ kind: "line", text: marker(milestone, entry.message) });
88
+ return out;
89
+ }
90
+ if (run && run.source === entry.source) {
91
+ run.lines.push(entry.message);
92
+ }
93
+ else {
94
+ flush(out);
95
+ run = { source: entry.source, lines: [entry.message] };
96
+ }
97
+ return out;
98
+ },
99
+ end() {
100
+ const out = [];
101
+ flush(out);
102
+ return out;
103
+ },
104
+ };
105
+ };
106
+ /**
107
+ * Segments a full, ordered entry list into {@link DisplayItem}s (the array form of
108
+ * {@link createFileSegmenter}). Milestones and runs no longer than `threshold`
109
+ * become `line` items byte-identical to {@link createRenderer}; a run longer than
110
+ * `threshold` becomes a single `collapsed` item carrying every line of the run.
111
+ */
112
+ export const segmentForFile = (entries, options = {}) => {
113
+ const seg = createFileSegmenter(options);
114
+ const items = [];
115
+ for (const entry of entries)
116
+ items.push(...seg.push(entry));
117
+ items.push(...seg.end());
97
118
  return items;
98
119
  };
99
120
  /**
@@ -9,24 +9,28 @@ import type { LogsCollector } from "./log-collector/LogsCollector.ts";
9
9
  */
10
10
  export declare const artifactsFolder: () => string;
11
11
  /**
12
- * Drains the collected log to two files in the run's output folder (the common
13
- * `logs/<name>/<date>/<time>/` convention, local time):
12
+ * Renders the final, collapsed `run.log` from the authoritative `run.jsonl` (the
13
+ * complete record {@link streamLog} wrote), **streaming** it line by line so the
14
+ * whole run is never held in memory. Grouping/indentation is applied here so the
15
+ * stored `run.jsonl` stays presentation-free; large same-source runs are collapsed
16
+ * — off-loaded to a text snapshot and replaced inline by their head plus a pointer
17
+ * — so one proc's burst can't drown the timeline. `run.jsonl` is left as-is (it's
18
+ * the full raw record). Returns the `run.log` path (surfaced to the terminal).
14
19
  *
15
- * - `run.log` — the pretty, human-scannable rendering: just the message lines.
16
- * - `run.jsonl` — the structured, machine-queryable record: one full {@link LogEntry}
17
- * per line (sequence, timestamp, level, source, message), so an agent can filter
18
- * and correlate by origin/level/time instead of parsing prose.
19
- *
20
- * Returns the `run.log` path (the one surfaced to the terminal).
20
+ * Call only after {@link streamLog} has closed (the run's log is ended), so
21
+ * `run.jsonl` is complete and no longer being written.
21
22
  */
22
- export declare const writeLog: (logs: LogsCollector) => Promise<string>;
23
+ export declare const writeLog: () => Promise<string>;
23
24
  /**
24
- * Streams log entries to `run.jsonl` and `run.log` incrementally as they arrive,
25
- * so output survives a hard kill or lost SIGINT. Each entry is appended via a
26
- * write stream. The subscription ends on teardown (`logs.endOn`), after which
27
- * {@link writeLog} overwrites both files with the authoritative final snapshot
28
- * (including post-abort entries like "finished"). For dirty exits, whatever was
29
- * flushed to disk by the OS is the best available record.
25
+ * Streams every log entry to `run.jsonl` (the complete, authoritative record) and
26
+ * a live `run.log` incrementally as they arrive, so output survives a hard kill or
27
+ * lost SIGINT. The log is closed once at finalize, after which {@link writeLog}
28
+ * re-renders `run.log` from `run.jsonl` in collapsed form.
29
+ *
30
+ * Subscribes **synchronously, before any `await`** — so a fast synchronous
31
+ * producer that appends (and trims the in-memory history) before the file setup
32
+ * finishes still has its entries buffered on the subscription queue rather than
33
+ * lost. `replay: true` covers anything already logged when this is called.
30
34
  */
31
35
  export declare const streamLog: (logs: LogsCollector) => Promise<void>;
32
36
  /** A written (or in-flight) snapshot file and where to find it. */
@@ -1,8 +1,9 @@
1
- import { createWriteStream, mkdirSync } from "node:fs";
1
+ import { createReadStream, createWriteStream, mkdirSync } from "node:fs";
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
+ import { createInterface } from "node:readline";
3
4
  import path from "node:path";
4
5
  import { log } from "./log-collector/global.js";
5
- import { COLLAPSE_HEAD, createRenderer, renderCollapsed, segmentForFile, } from "./log-collector/render.js";
6
+ import { COLLAPSE_HEAD, createFileSegmenter, createRenderer, renderCollapsed, } from "./log-collector/render.js";
6
7
  const pad = (n, width = 2) => String(n).padStart(width, "0");
7
8
  /** The entry script's base name (the run's name), e.g. "tskb-build". */
8
9
  const pipelineName = () => {
@@ -53,52 +54,73 @@ const logHeader = () => {
53
54
  ].join("\n");
54
55
  };
55
56
  /**
56
- * Drains the collected log to two files in the run's output folder (the common
57
- * `logs/<name>/<date>/<time>/` convention, local time):
57
+ * Renders the final, collapsed `run.log` from the authoritative `run.jsonl` (the
58
+ * complete record {@link streamLog} wrote), **streaming** it line by line so the
59
+ * whole run is never held in memory. Grouping/indentation is applied here so the
60
+ * stored `run.jsonl` stays presentation-free; large same-source runs are collapsed
61
+ * — off-loaded to a text snapshot and replaced inline by their head plus a pointer
62
+ * — so one proc's burst can't drown the timeline. `run.jsonl` is left as-is (it's
63
+ * the full raw record). Returns the `run.log` path (surfaced to the terminal).
58
64
  *
59
- * - `run.log` — the pretty, human-scannable rendering: just the message lines.
60
- * - `run.jsonl` — the structured, machine-queryable record: one full {@link LogEntry}
61
- * per line (sequence, timestamp, level, source, message), so an agent can filter
62
- * and correlate by origin/level/time instead of parsing prose.
63
- *
64
- * Returns the `run.log` path (the one surfaced to the terminal).
65
+ * Call only after {@link streamLog} has closed (the run's log is ended), so
66
+ * `run.jsonl` is complete and no longer being written.
65
67
  */
66
- export const writeLog = async (logs) => {
68
+ export const writeLog = async () => {
67
69
  await ensureDir();
68
- const entries = logs.snapshot();
69
- // Apply grouping/indentation at write time from the raw entries, so the stored
70
- // messages stay presentation-free. Unlike the live `drain`, the final file
71
- // collapses large same-source runs: each is off-loaded to a text snapshot and
72
- // replaced inline by its head plus a pointer, so one proc's burst can't drown
73
- // the timeline. `run.jsonl` below keeps every line, raw.
70
+ const jsonlFile = path.join(runDir(), "run.jsonl");
74
71
  const file = path.join(runDir(), "run.log");
75
- const header = logHeader();
72
+ const out = createWriteStream(file);
73
+ out.write(logHeader());
74
+ const seg = createFileSegmenter();
76
75
  const writes = [];
77
- const parts = segmentForFile(entries).map((item) => {
78
- if (item.kind === "line")
79
- return item.text;
80
- const snap = captureText(`output-${item.run.source}`, item.run.lines);
81
- writes.push(snap.written);
82
- return renderCollapsed(item.run, { head: COLLAPSE_HEAD, rel: snap.rel });
83
- });
84
- const pretty = parts.join("\n");
85
- await writeFile(file, pretty ? `${header}${pretty}\n` : "", "utf8");
86
- // Ensure every off-loaded run is flushed before we report the log is written.
87
- await Promise.all(writes);
88
- const jsonlFile = path.join(runDir(), "run.jsonl");
89
- const jsonl = entries.map((entry) => JSON.stringify(entry)).join("\n");
90
- await writeFile(jsonlFile, jsonl ? `${jsonl}\n` : "", "utf8");
76
+ const emit = (item) => {
77
+ if (item.kind === "line") {
78
+ out.write(item.text + "\n");
79
+ }
80
+ else {
81
+ const snap = captureText(`output-${item.run.source}`, item.run.lines);
82
+ writes.push(snap.written);
83
+ out.write(renderCollapsed(item.run, { head: COLLAPSE_HEAD, rel: snap.rel }) + "\n");
84
+ }
85
+ };
86
+ try {
87
+ const rl = createInterface({ input: createReadStream(jsonlFile), crlfDelay: Infinity });
88
+ for await (const line of rl) {
89
+ if (!line)
90
+ continue;
91
+ let entry;
92
+ try {
93
+ entry = JSON.parse(line);
94
+ }
95
+ catch {
96
+ continue; // a torn final line (dirty exit) — skip it
97
+ }
98
+ for (const item of seg.push(entry))
99
+ emit(item);
100
+ }
101
+ }
102
+ catch {
103
+ // run.jsonl missing/unreadable (e.g. a very early crash) — write what we have.
104
+ }
105
+ for (const item of seg.end())
106
+ emit(item);
107
+ await streamClosed(out); // flush + close run.log before reporting it written
108
+ await Promise.all(writes); // ensure every off-loaded run is on disk
91
109
  return file;
92
110
  };
93
111
  /**
94
- * Streams log entries to `run.jsonl` and `run.log` incrementally as they arrive,
95
- * so output survives a hard kill or lost SIGINT. Each entry is appended via a
96
- * write stream. The subscription ends on teardown (`logs.endOn`), after which
97
- * {@link writeLog} overwrites both files with the authoritative final snapshot
98
- * (including post-abort entries like "finished"). For dirty exits, whatever was
99
- * flushed to disk by the OS is the best available record.
112
+ * Streams every log entry to `run.jsonl` (the complete, authoritative record) and
113
+ * a live `run.log` incrementally as they arrive, so output survives a hard kill or
114
+ * lost SIGINT. The log is closed once at finalize, after which {@link writeLog}
115
+ * re-renders `run.log` from `run.jsonl` in collapsed form.
116
+ *
117
+ * Subscribes **synchronously, before any `await`** — so a fast synchronous
118
+ * producer that appends (and trims the in-memory history) before the file setup
119
+ * finishes still has its entries buffered on the subscription queue rather than
120
+ * lost. `replay: true` covers anything already logged when this is called.
100
121
  */
101
122
  export const streamLog = async (logs) => {
123
+ const entries = logs.subscribe({ replay: true });
102
124
  await ensureDir();
103
125
  const jsonlFile = path.join(runDir(), "run.jsonl");
104
126
  const logFile = path.join(runDir(), "run.log");
@@ -107,17 +129,18 @@ export const streamLog = async (logs) => {
107
129
  const render = createRenderer();
108
130
  pretty.write(logHeader() + "\n");
109
131
  try {
110
- for await (const entry of logs.subscribe({ replay: true })) {
132
+ for await (const entry of entries) {
111
133
  jsonl.write(JSON.stringify(entry) + "\n");
112
134
  pretty.write(render(entry) + "\n");
113
135
  }
114
136
  }
115
137
  finally {
116
138
  // End AND wait for both streams to fully close before resolving. {@link writeLog}
117
- // truncates and rewrites these same files at finalize; if a buffered chunk from
118
- // this stream landed *after* that truncation, the OS would zero-fill the gap —
119
- // a multi-MB run of NUL bytes in run.log. `run` awaits this promise before
120
- // calling `writeLog`, so this writer is guaranteed done before the rewrite.
139
+ // re-renders run.log at finalize (from this run.jsonl); if a buffered chunk from
140
+ // this stream landed *after* writeLog truncated run.log, the OS would zero-fill
141
+ // the gap — a multi-MB run of NUL bytes. `run` awaits this promise before
142
+ // calling `writeLog`, so this writer is guaranteed done first (and run.jsonl is
143
+ // complete for writeLog to read).
121
144
  await Promise.all([streamClosed(jsonl), streamClosed(pretty)]);
122
145
  }
123
146
  };
@@ -1,4 +1,4 @@
1
- import type { AnyActionInstance } from "./action.ts";
1
+ import { type AnyActionInstance } from "./action.ts";
2
2
  import { log as logs } from "./log-collector/global.ts";
3
3
  /**
4
4
  * A single action's failure, captured for the spin's verdict. Cascade failures —
package/dist/core/run.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { relative } from "node:path";
2
+ import { producerOfRef } from "./action.js";
2
3
  import { log as logs } from "./log-collector/global.js";
3
4
  import { createRenderer } from "./log-collector/render.js";
4
5
  import { streamLog, writeLog } from "./output.js";
@@ -61,9 +62,9 @@ export function run(...actions) {
61
62
  assertInstance(instance);
62
63
  started = true;
63
64
  const controller = new AbortController();
64
- // The log closes when the run tears down, so every `logs.subscribe(…)` ends on
65
- // its own — no subscriber has to watch the signal.
66
- logs.endOn(controller.signal);
65
+ // The log stays open through teardown and is closed once at finalize (after the
66
+ // last entry), so `streamLog` captures the whole run — including lines logged
67
+ // *during* teardown — and its run.jsonl is complete before `writeLog` reads it.
67
68
  // ── Lifecycle state ──────────────────────────────────────────────────────
68
69
  // The single source of truth for "where in its life is this run". Only
69
70
  // `beginClosing` and the finalize step below mutate it, and only forward.
@@ -131,7 +132,21 @@ export function run(...actions) {
131
132
  // stream lands past writeLog's truncation and the OS zero-fills the gap (a
132
133
  // multi-MB NUL run in run.log).
133
134
  const streaming = streamLog(logs).catch(() => { });
135
+ // Instances launched so far — used to verify a `.ref` dependency was launched
136
+ // before the action that consumes its handle.
137
+ const launched = new WeakSet();
134
138
  const launch = (instance) => {
139
+ // A consumer built with another action's `.ref` (e.g. `chromePage(driver.ref)`)
140
+ // must be launched *after* that producer — otherwise it awaits a handle that
141
+ // never arrives and the spin hangs silently. Catch it and say how to fix it.
142
+ for (const arg of instance.args) {
143
+ const producer = producerOfRef(arg);
144
+ if (producer && !launched.has(producer)) {
145
+ throw new Error(`${instance.name} was launched before its dependency ${producer.name}: it ` +
146
+ `consumes ${producer.name}'s .ref, so nod ${producer.name} first.`);
147
+ }
148
+ }
149
+ launched.add(instance);
135
150
  narrate(`launch ${instance.name}`);
136
151
  // The action logs into the one shared store (entries carry their `source`;
137
152
  // grouping is applied at render time) and gets the spin's signal directly; the
@@ -187,11 +202,13 @@ export function run(...actions) {
187
202
  process.off("uncaughtException", onUncaught);
188
203
  process.off("unhandledRejection", onUnhandled);
189
204
  narrate("finished");
190
- // Wait for the incremental streamer to fully close its files, so its last
191
- // flush can't land after writeLog truncates+rewrites them (which would leave
192
- // a NUL gap). The log is already closed, so this resolves promptly.
205
+ // Close the log now (after the last entry) so `streamLog` drains and finishes
206
+ // writing the complete run.jsonl, then wait for it to fully close its files —
207
+ // both so its last flush can't corrupt run.log later, and so run.jsonl is done
208
+ // before `writeLog` streams it into the final collapsed run.log.
209
+ logs.close();
193
210
  await streaming;
194
- const file = await writeLog(logs);
211
+ const file = await writeLog();
195
212
  // Direct to the terminal — the file's already written, so this line isn't in it.
196
213
  console.log(marker("run", `log → ${file}`));
197
214
  const result = { ok: failures.length === 0, failures };
package/dist/core/spin.js CHANGED
@@ -29,7 +29,16 @@ export function spin(a, b) {
29
29
  artifactsFolder: artifactsFolder(),
30
30
  });
31
31
  });
32
- host = run(hosted());
32
+ const hostInstance = hosted();
33
+ host = run(hostInstance);
34
+ // The body is the orchestrator, not a peer action: if it throws, tear the whole
35
+ // spin down. Regular action failures only get recorded (they don't stop the
36
+ // spin), but the orchestrator crashing must end the run — otherwise any daemon
37
+ // it already nodded keeps the process alive forever on a pipeline bug.
38
+ void hostInstance.done.then((outcome) => {
39
+ if (!outcome.ok)
40
+ host.cancel();
41
+ });
33
42
  if (autoDrain)
34
43
  startDrain();
35
44
  return host;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omkit",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
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",