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.
- package/dist/core/action.d.ts +12 -5
- package/dist/core/action.js +31 -18
- package/dist/core/log-collector/LogsCollector.d.ts +7 -5
- package/dist/core/log-collector/LogsCollector.js +17 -9
- package/dist/core/log-collector/render.d.ts +17 -6
- package/dist/core/log-collector/render.js +50 -29
- package/dist/core/output.d.ts +19 -15
- package/dist/core/output.js +66 -43
- package/dist/core/run.d.ts +1 -1
- package/dist/core/run.js +24 -7
- package/dist/core/spin.js +10 -1
- package/package.json +1 -1
package/dist/core/action.d.ts
CHANGED
|
@@ -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
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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>;
|
package/dist/core/action.js
CHANGED
|
@@ -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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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,
|
|
38
|
+
const awaitEvent = (emitter, key) => new Promise((resolve) => {
|
|
27
39
|
const offs = [];
|
|
28
40
|
let settled = false;
|
|
29
|
-
// First of [the event |
|
|
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
|
|
39
|
-
//
|
|
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
|
-
|
|
43
|
-
|
|
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(() =>
|
|
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`
|
|
115
|
-
//
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
|
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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
|
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
|
|
63
|
-
// exactly as createRenderer would (header
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
/**
|
package/dist/core/output.d.ts
CHANGED
|
@@ -9,24 +9,28 @@ import type { LogsCollector } from "./log-collector/LogsCollector.ts";
|
|
|
9
9
|
*/
|
|
10
10
|
export declare const artifactsFolder: () => string;
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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: (
|
|
23
|
+
export declare const writeLog: () => Promise<string>;
|
|
23
24
|
/**
|
|
24
|
-
* Streams log
|
|
25
|
-
* so output survives a hard kill or
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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. */
|
package/dist/core/output.js
CHANGED
|
@@ -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,
|
|
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
|
-
*
|
|
57
|
-
*
|
|
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
|
-
*
|
|
60
|
-
*
|
|
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 (
|
|
68
|
+
export const writeLog = async () => {
|
|
67
69
|
await ensureDir();
|
|
68
|
-
const
|
|
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
|
|
72
|
+
const out = createWriteStream(file);
|
|
73
|
+
out.write(logHeader());
|
|
74
|
+
const seg = createFileSegmenter();
|
|
76
75
|
const writes = [];
|
|
77
|
-
const
|
|
78
|
-
if (item.kind === "line")
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
95
|
-
* so output survives a hard kill or
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
|
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
|
-
//
|
|
118
|
-
// this stream landed *after*
|
|
119
|
-
// a multi-MB run of NUL bytes
|
|
120
|
-
// calling `writeLog`, so this writer is guaranteed done
|
|
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
|
};
|
package/dist/core/run.d.ts
CHANGED
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
|
|
65
|
-
//
|
|
66
|
-
|
|
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
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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",
|