omkit 0.0.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/LICENSE +21 -0
- package/README.md +144 -0
- package/dist/actions/chrome-page.d.ts +44 -0
- package/dist/actions/chrome-page.js +94 -0
- package/dist/actions/command.d.ts +26 -0
- package/dist/actions/command.js +32 -0
- package/dist/actions/healthcheck.d.ts +55 -0
- package/dist/actions/healthcheck.js +94 -0
- package/dist/actions/index.d.ts +22 -0
- package/dist/actions/index.js +14 -0
- package/dist/actions/prompt.d.ts +54 -0
- package/dist/actions/prompt.js +99 -0
- package/dist/actions/tail-log.d.ts +30 -0
- package/dist/actions/tail-log.js +124 -0
- package/dist/actions/until-log.d.ts +24 -0
- package/dist/actions/until-log.js +49 -0
- package/dist/actions/watch-dir.d.ts +35 -0
- package/dist/actions/watch-dir.js +159 -0
- package/dist/actions/watch.d.ts +33 -0
- package/dist/actions/watch.js +80 -0
- package/dist/core/action.d.ts +105 -0
- package/dist/core/action.js +133 -0
- package/dist/core/events.d.ts +23 -0
- package/dist/core/events.js +61 -0
- package/dist/core/helpers/AsyncQueue.d.ts +13 -0
- package/dist/core/helpers/AsyncQueue.js +50 -0
- package/dist/core/log-collector/LogsCollector.d.ts +63 -0
- package/dist/core/log-collector/LogsCollector.js +110 -0
- package/dist/core/log-collector/global.d.ts +7 -0
- package/dist/core/log-collector/global.js +7 -0
- package/dist/core/log-collector/render.d.ts +57 -0
- package/dist/core/log-collector/render.js +111 -0
- package/dist/core/markers.d.ts +9 -0
- package/dist/core/markers.js +13 -0
- package/dist/core/output.d.ts +55 -0
- package/dist/core/output.js +163 -0
- package/dist/core/process.d.ts +21 -0
- package/dist/core/process.js +73 -0
- package/dist/core/run.d.ts +95 -0
- package/dist/core/run.js +212 -0
- package/dist/core/spin.d.ts +58 -0
- package/dist/core/spin.js +29 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +25 -0
- package/package.json +59 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { Logger } from "./log-collector/LogsCollector.ts";
|
|
2
|
+
import { type Emitter, type EventHandler } from "./events.ts";
|
|
3
|
+
import { type Proc } from "./process.ts";
|
|
4
|
+
/** An action that declares no events. */
|
|
5
|
+
export type NoEvents = Record<never, never>;
|
|
6
|
+
/** A value an action's `exec` may return: a result, or a promise of one. */
|
|
7
|
+
export type Awaitable<T> = T | Promise<T>;
|
|
8
|
+
/**
|
|
9
|
+
* Lifecycle events the framework emits for every action — on top of whatever it
|
|
10
|
+
* declares via `.emits<…>()`. They make key moments first-class signals (no
|
|
11
|
+
* log-scraping) and give a uniform sequencing hook.
|
|
12
|
+
*/
|
|
13
|
+
export interface SystemEvents<Result> {
|
|
14
|
+
/** The action published its imperative handle via `ctx.attach` (fires once). */
|
|
15
|
+
attached: void;
|
|
16
|
+
/** The run resolved; payload is its result. */
|
|
17
|
+
done: Result;
|
|
18
|
+
/** The run rejected; payload is the thrown error. */
|
|
19
|
+
error: unknown;
|
|
20
|
+
}
|
|
21
|
+
/** The full event map a constructed instance exposes: the declared events plus the system ones. */
|
|
22
|
+
export type InstanceEvents<Events extends object, Result> = Events & SystemEvents<Result>;
|
|
23
|
+
/** System services the pipeline injects into every action when it runs. */
|
|
24
|
+
export interface SystemGlobal {
|
|
25
|
+
/** A logger bound to this action — feeds it directly or via `proc`. */
|
|
26
|
+
readonly logs: Logger;
|
|
27
|
+
/** The run's abort signal — aborted on teardown/cancel; honor it to stop early. */
|
|
28
|
+
readonly signal: AbortSignal;
|
|
29
|
+
}
|
|
30
|
+
/** What an action's implementation receives: the system bag, plus `emit`, `attach`, and `proc`. */
|
|
31
|
+
export interface ActionContext<Events extends object, Handle = void> extends SystemGlobal {
|
|
32
|
+
/** Emit one of this action's declared events (also pushed to the global log). */
|
|
33
|
+
readonly emit: Emitter<Events>["emit"];
|
|
34
|
+
/** Publish this action's imperative handle, resolving its instance's `.ref`. */
|
|
35
|
+
readonly attach: (handle: Handle) => void;
|
|
36
|
+
/**
|
|
37
|
+
* Spawn child processes bound to this action: their output streams into this
|
|
38
|
+
* action's log and they're killed on teardown. The built-in source; a custom
|
|
39
|
+
* one (e.g. CDP) follows the same pattern over `logs`/`signal`.
|
|
40
|
+
*/
|
|
41
|
+
readonly proc: Proc;
|
|
42
|
+
}
|
|
43
|
+
/** A constructed-but-not-yet-run action: what calling an `Action` produces. */
|
|
44
|
+
export interface ActionInstance<Result = unknown, Events extends object = NoEvents, Handle = void> {
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly args: readonly unknown[];
|
|
47
|
+
/** Runs the action with the captured args and the injected system services. */
|
|
48
|
+
readonly start: (system: SystemGlobal) => Promise<Result>;
|
|
49
|
+
/**
|
|
50
|
+
* Resolves with the handle the action attached via `ctx.attach` (set once). If
|
|
51
|
+
* the action settles *without* attaching, this never hangs: it resolves
|
|
52
|
+
* (`undefined` for the default `void` handle) on success, or rejects with the
|
|
53
|
+
* action's error on failure.
|
|
54
|
+
*/
|
|
55
|
+
readonly ref: Promise<Handle>;
|
|
56
|
+
/** Subscribe to a declared or system event (`done`/`error`) with a handler. */
|
|
57
|
+
on<K extends keyof InstanceEvents<Events, Result>>(key: K, handler: EventHandler<InstanceEvents<Events, Result>[K]>): void;
|
|
58
|
+
/**
|
|
59
|
+
* A promise for the next occurrence of an event: resolves with its payload —
|
|
60
|
+
* or rejects if the action settles first (`error`, or `done` without the
|
|
61
|
+
* event), so an awaiter never hangs. Use {@link on} for ongoing/handler
|
|
62
|
+
* subscriptions; `once` is the awaitable, one-value form.
|
|
63
|
+
*/
|
|
64
|
+
once<K extends keyof InstanceEvents<Events, Result>>(key: K): Promise<InstanceEvents<Events, Result>[K]>;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Any constructed instance, whatever its result, events, or handle. The launch
|
|
68
|
+
* surface ({@link ActionInstance}-takers like `run` and `Run.run`) accepts this
|
|
69
|
+
* so handle-bearing actions — those declaring `.ref<H>()` — can be launched: the
|
|
70
|
+
* bare `ActionInstance` pins the handle to `void`, so an instance whose `ref` is
|
|
71
|
+
* a `Promise<Page>` (not `Promise<void>`) would otherwise be rejected. `unknown`
|
|
72
|
+
* for result/handle and `object` for events admit every concrete instance while
|
|
73
|
+
* still excluding non-instances.
|
|
74
|
+
*/
|
|
75
|
+
export type AnyActionInstance = ActionInstance<unknown, object, unknown>;
|
|
76
|
+
/** A callable produced by `action`: invoking it builds an {@link ActionInstance}. */
|
|
77
|
+
export interface Action<Args extends unknown[], Result, Events extends object = NoEvents, Handle = void> {
|
|
78
|
+
(...args: Args): ActionInstance<Result, Events, Handle>;
|
|
79
|
+
readonly actionName: string;
|
|
80
|
+
}
|
|
81
|
+
/** Intermediate step from `action(name)`: declare events/handle, then provide the impl. */
|
|
82
|
+
export interface ActionBuilder<Events extends object, Handle = void> {
|
|
83
|
+
/** Declare the typed event map this action emits. */
|
|
84
|
+
emits<E extends object>(): ActionBuilder<E, Handle>;
|
|
85
|
+
/** Declare the imperative handle this action exposes (resolved via `instance.ref`). */
|
|
86
|
+
ref<H>(): ActionBuilder<Events, H>;
|
|
87
|
+
/** Provide the implementation. `ctx` carries `logs`, `signal`, `emit`, and `attach`. */
|
|
88
|
+
run<Args extends unknown[], Result>(exec: (ctx: ActionContext<Events, Handle>, ...args: Args) => Awaitable<Result>): Action<Args, Result, Events, Handle>;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Defines an action. Always returns a builder; provide the implementation via
|
|
92
|
+
* `.run(…)`, optionally after declaring events and/or a handle:
|
|
93
|
+
*
|
|
94
|
+
* action("Build").run(async (ctx, opts) => { … }) // no declared events / handle
|
|
95
|
+
* action("Watch") // typed events + handle
|
|
96
|
+
* .emits<{ create: string }>()
|
|
97
|
+
* .ref<{ rescan(): void }>()
|
|
98
|
+
* .run((ctx, opts) => { ctx.emit(…); ctx.attach(…); … })
|
|
99
|
+
*
|
|
100
|
+
* `.run(…)` yields a callable that, when invoked, constructs an
|
|
101
|
+
* {@link ActionInstance} (deferred — the run executes it). Instances expose
|
|
102
|
+
* chainable `.on` / `.once` (declared events plus `done`/`error`); the attached
|
|
103
|
+
* handle is awaited via `instance.ref`.
|
|
104
|
+
*/
|
|
105
|
+
export declare function action(name: string): ActionBuilder<NoEvents, void>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { events } from "./events.js";
|
|
2
|
+
import { createProc } from "./process.js";
|
|
3
|
+
/** A promise paired with its settlers, for the set-once handle. */
|
|
4
|
+
const deferred = () => {
|
|
5
|
+
let resolve;
|
|
6
|
+
let reject;
|
|
7
|
+
const promise = new Promise((res, rej) => {
|
|
8
|
+
resolve = res;
|
|
9
|
+
reject = rej;
|
|
10
|
+
});
|
|
11
|
+
return { promise, resolve, reject };
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* A promise for the next emit of `key`. It races the event against the
|
|
15
|
+
* instance's terminal events: if the action errors first it rejects with that
|
|
16
|
+
* error, and if it completes (`done`) without ever emitting `key` it rejects too
|
|
17
|
+
* — so an awaiter can never hang. A retained snapshot resolves it immediately.
|
|
18
|
+
*/
|
|
19
|
+
const awaitEvent = (emitter, name, key) => new Promise((resolve, reject) => {
|
|
20
|
+
const offs = [];
|
|
21
|
+
let settled = false;
|
|
22
|
+
// First of [the event | error | done] to fire wins; the rest are unsubscribed.
|
|
23
|
+
const settle = (act) => {
|
|
24
|
+
if (settled)
|
|
25
|
+
return;
|
|
26
|
+
settled = true;
|
|
27
|
+
for (const off of offs)
|
|
28
|
+
off();
|
|
29
|
+
act();
|
|
30
|
+
};
|
|
31
|
+
// listenOnce is keyed by E; the system keys (error/done) are always present
|
|
32
|
+
// on an instance's event map, so reach them through a loosened view.
|
|
33
|
+
const listen = emitter.listenOnce;
|
|
34
|
+
offs.push(listen(key, (payload) => settle(() => resolve(payload))));
|
|
35
|
+
if (key !== "error") {
|
|
36
|
+
offs.push(listen("error", (err) => settle(() => reject(err))));
|
|
37
|
+
}
|
|
38
|
+
if (key !== "done") {
|
|
39
|
+
offs.push(listen("done", () => settle(() => reject(new Error(`${name}: settled before "${String(key)}"`)))));
|
|
40
|
+
}
|
|
41
|
+
// A retained snapshot fires a listen synchronously during registration; if
|
|
42
|
+
// that already settled us, drop any listeners registered afterwards.
|
|
43
|
+
if (settled)
|
|
44
|
+
for (const off of offs)
|
|
45
|
+
off();
|
|
46
|
+
});
|
|
47
|
+
/** Assembles an {@link Action} from a name and its implementation. */
|
|
48
|
+
const build = (name, exec) => {
|
|
49
|
+
const create = (...args) => {
|
|
50
|
+
// The emitter covers declared + system events; `ctx.emit` is narrowed to the
|
|
51
|
+
// declared ones, while the framework emits done/error through the same bus.
|
|
52
|
+
const emitter = events(name);
|
|
53
|
+
const emit = emitter.emit;
|
|
54
|
+
// Loosely typed for the framework's own emits (sidesteps EmitArgs for void results).
|
|
55
|
+
const systemEmit = emitter.emit;
|
|
56
|
+
// The handle lives on the instance as a set-once promise: `attach` resolves
|
|
57
|
+
// it, `instance.ref` awaits it. A second attach is a no-op (promise settled).
|
|
58
|
+
// If the action settles without ever attaching, `start` below settles it too
|
|
59
|
+
// (resolve on success, reject on error) so `ref` can't hang.
|
|
60
|
+
const handle = deferred();
|
|
61
|
+
// Attaching is a one-time lifecycle moment: resolve `ref` and emit `attached`
|
|
62
|
+
// (the *fact* the action became controllable — not the handle, which is just
|
|
63
|
+
// functions). Guarded so a second attach neither re-emits nor re-resolves.
|
|
64
|
+
let attached = false;
|
|
65
|
+
const attach = (value) => {
|
|
66
|
+
if (attached)
|
|
67
|
+
return;
|
|
68
|
+
attached = true;
|
|
69
|
+
handle.resolve(value);
|
|
70
|
+
systemEmit("attached");
|
|
71
|
+
};
|
|
72
|
+
// Most actions have a `void` handle and no one awaits `ref`; mark the promise
|
|
73
|
+
// handled so a settle-time rejection on such an instance doesn't surface as an
|
|
74
|
+
// unhandled rejection. Real awaiters of `ref` still receive the rejection.
|
|
75
|
+
void handle.promise.catch(() => { });
|
|
76
|
+
const instance = {
|
|
77
|
+
name,
|
|
78
|
+
args,
|
|
79
|
+
ref: handle.promise,
|
|
80
|
+
start: (system) =>
|
|
81
|
+
// `Promise.resolve().then` normalizes a sync result and routes a sync
|
|
82
|
+
// throw to the `error` path, so exec may be sync or async.
|
|
83
|
+
Promise.resolve()
|
|
84
|
+
.then(() => exec({ ...system, emit, attach, proc: createProc(system.logs, system.signal) }, ...args))
|
|
85
|
+
.then((result) => {
|
|
86
|
+
systemEmit("done", result);
|
|
87
|
+
// Settle `ref` if the action finished without attaching, so awaiters
|
|
88
|
+
// resolve (with `undefined` for the default void handle) rather than
|
|
89
|
+
// hang. Idempotent — a no-op when a handle was already attached.
|
|
90
|
+
handle.resolve(undefined);
|
|
91
|
+
return result;
|
|
92
|
+
}, (error) => {
|
|
93
|
+
systemEmit("error", error);
|
|
94
|
+
// The action failed, so its handle will never arrive — reject `ref`
|
|
95
|
+
// with the same error instead of leaving awaiters hung. No-op if a
|
|
96
|
+
// handle was already attached before the failure.
|
|
97
|
+
handle.reject(error);
|
|
98
|
+
throw error;
|
|
99
|
+
}),
|
|
100
|
+
on(key, handler) {
|
|
101
|
+
emitter.listen(key, handler);
|
|
102
|
+
},
|
|
103
|
+
once(key) {
|
|
104
|
+
return awaitEvent(emitter, name, key);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
return instance;
|
|
108
|
+
};
|
|
109
|
+
return Object.assign(create, { actionName: name });
|
|
110
|
+
};
|
|
111
|
+
const builder = (name) => ({
|
|
112
|
+
emits: () => builder(name),
|
|
113
|
+
ref: () => builder(name),
|
|
114
|
+
run: (exec) => build(name, exec),
|
|
115
|
+
});
|
|
116
|
+
/**
|
|
117
|
+
* Defines an action. Always returns a builder; provide the implementation via
|
|
118
|
+
* `.run(…)`, optionally after declaring events and/or a handle:
|
|
119
|
+
*
|
|
120
|
+
* action("Build").run(async (ctx, opts) => { … }) // no declared events / handle
|
|
121
|
+
* action("Watch") // typed events + handle
|
|
122
|
+
* .emits<{ create: string }>()
|
|
123
|
+
* .ref<{ rescan(): void }>()
|
|
124
|
+
* .run((ctx, opts) => { ctx.emit(…); ctx.attach(…); … })
|
|
125
|
+
*
|
|
126
|
+
* `.run(…)` yields a callable that, when invoked, constructs an
|
|
127
|
+
* {@link ActionInstance} (deferred — the run executes it). Instances expose
|
|
128
|
+
* chainable `.on` / `.once` (declared events plus `done`/`error`); the attached
|
|
129
|
+
* handle is awaited via `instance.ref`.
|
|
130
|
+
*/
|
|
131
|
+
export function action(name) {
|
|
132
|
+
return builder(name);
|
|
133
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Passed to every handler so it can tell a replayed snapshot from a live emit. */
|
|
2
|
+
export interface EventMeta {
|
|
3
|
+
/** True when called with the retained last value on subscribe; false for a live emit. */
|
|
4
|
+
readonly isSnapshot: boolean;
|
|
5
|
+
}
|
|
6
|
+
export type EventHandler<P> = (payload: P, meta: EventMeta) => void;
|
|
7
|
+
/** No payload arg for `void` events, a required one otherwise. */
|
|
8
|
+
type EmitArgs<P> = [P] extends [void] ? [] : [payload: P];
|
|
9
|
+
/**
|
|
10
|
+
* A typed pub/sub keyed by an event map (`{ key: payload }`). It snapshots the
|
|
11
|
+
* last payload per key, so a late `listen`/`listenOnce` is replayed that value
|
|
12
|
+
* immediately (with `isSnapshot: true`) before receiving live emits.
|
|
13
|
+
*/
|
|
14
|
+
export interface Emitter<Events extends object> {
|
|
15
|
+
/** Emit `key`'s payload to current listeners and retain it as the snapshot. */
|
|
16
|
+
emit<K extends keyof Events>(key: K, ...args: EmitArgs<Events[K]>): void;
|
|
17
|
+
/** Subscribe to `key`. Fires immediately with the snapshot if one exists. Returns an unsubscribe. */
|
|
18
|
+
listen<K extends keyof Events>(key: K, handler: EventHandler<Events[K]>): () => void;
|
|
19
|
+
/** Like {@link Emitter.listen} but auto-unsubscribes after the first delivery (snapshot counts). */
|
|
20
|
+
listenOnce<K extends keyof Events>(key: K, handler: EventHandler<Events[K]>): () => void;
|
|
21
|
+
}
|
|
22
|
+
export declare const events: <Events extends object>(namespace?: string) => Emitter<Events>;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { log } from "./log-collector/global.js";
|
|
2
|
+
import { captureSnapshot } from "./output.js";
|
|
3
|
+
export const events = (namespace) => {
|
|
4
|
+
const handlers = new Map();
|
|
5
|
+
const snapshots = new Map();
|
|
6
|
+
const emit = (key, ...args) => {
|
|
7
|
+
const payload = args[0];
|
|
8
|
+
snapshots.set(key, payload);
|
|
9
|
+
// Every emit also lands in the global log, so events share the timeline with
|
|
10
|
+
// process output and end up in the drained log file. Fields are `·`-delimited
|
|
11
|
+
// — namespace (the emitting action) · key · payload. String payloads show
|
|
12
|
+
// inline; richer payloads are no longer dropped — they're written to a
|
|
13
|
+
// snapshot file and linked (`→ <path>`), so the durable record keeps them.
|
|
14
|
+
const fields = [namespace, String(key)];
|
|
15
|
+
if (typeof payload === "string") {
|
|
16
|
+
fields.push(payload);
|
|
17
|
+
}
|
|
18
|
+
else if (payload !== undefined) {
|
|
19
|
+
const name = `event-${[namespace, String(key)].filter(Boolean).join("-")}`;
|
|
20
|
+
fields.push(`→ ${captureSnapshot(name, payload).rel}`);
|
|
21
|
+
}
|
|
22
|
+
log.append({
|
|
23
|
+
source: "event",
|
|
24
|
+
level: "event",
|
|
25
|
+
message: fields.filter(Boolean).join(" · "),
|
|
26
|
+
});
|
|
27
|
+
const set = handlers.get(key);
|
|
28
|
+
if (!set)
|
|
29
|
+
return;
|
|
30
|
+
// Copy so a handler unsubscribing (or listenOnce) mid-emit doesn't disturb iteration.
|
|
31
|
+
for (const handler of [...set])
|
|
32
|
+
handler(payload, { isSnapshot: false });
|
|
33
|
+
};
|
|
34
|
+
const listen = (key, handler) => {
|
|
35
|
+
let set = handlers.get(key);
|
|
36
|
+
if (!set) {
|
|
37
|
+
set = new Set();
|
|
38
|
+
handlers.set(key, set);
|
|
39
|
+
}
|
|
40
|
+
set.add(handler);
|
|
41
|
+
// Snapshot on subscribe, if one has ever been emitted for this key.
|
|
42
|
+
if (snapshots.has(key))
|
|
43
|
+
handler(snapshots.get(key), { isSnapshot: true });
|
|
44
|
+
return () => {
|
|
45
|
+
set.delete(handler);
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
const listenOnce = (key, handler) => {
|
|
49
|
+
// A retained snapshot satisfies "once" immediately — deliver and we're done.
|
|
50
|
+
if (snapshots.has(key)) {
|
|
51
|
+
handler(snapshots.get(key), { isSnapshot: true });
|
|
52
|
+
return () => { };
|
|
53
|
+
}
|
|
54
|
+
const off = listen(key, (payload, meta) => {
|
|
55
|
+
off();
|
|
56
|
+
handler(payload, meta);
|
|
57
|
+
});
|
|
58
|
+
return off;
|
|
59
|
+
};
|
|
60
|
+
return { emit, listen, listenOnce };
|
|
61
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare class AsyncQueue<T> implements AsyncIterable<T> {
|
|
2
|
+
private readonly items;
|
|
3
|
+
private readonly resolvers;
|
|
4
|
+
private closed;
|
|
5
|
+
push(item: T): void;
|
|
6
|
+
/**
|
|
7
|
+
* Ends the queue: any parked reader resolves `{ done: true }` immediately, and
|
|
8
|
+
* once already-buffered items are drained, future reads do too. Idempotent.
|
|
9
|
+
*/
|
|
10
|
+
close(): void;
|
|
11
|
+
private next;
|
|
12
|
+
[Symbol.asyncIterator](): AsyncIterator<T>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export class AsyncQueue {
|
|
2
|
+
items = [];
|
|
3
|
+
resolvers = [];
|
|
4
|
+
closed = false;
|
|
5
|
+
push(item) {
|
|
6
|
+
if (this.closed)
|
|
7
|
+
return;
|
|
8
|
+
const resolver = this.resolvers.shift();
|
|
9
|
+
if (resolver) {
|
|
10
|
+
resolver({
|
|
11
|
+
done: false,
|
|
12
|
+
value: item,
|
|
13
|
+
});
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
this.items.push(item);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Ends the queue: any parked reader resolves `{ done: true }` immediately, and
|
|
20
|
+
* once already-buffered items are drained, future reads do too. Idempotent.
|
|
21
|
+
*/
|
|
22
|
+
close() {
|
|
23
|
+
if (this.closed)
|
|
24
|
+
return;
|
|
25
|
+
this.closed = true;
|
|
26
|
+
// Unblock everyone parked — nothing more is coming.
|
|
27
|
+
for (const resolve of this.resolvers.splice(0))
|
|
28
|
+
resolve({ done: true, value: undefined });
|
|
29
|
+
}
|
|
30
|
+
next() {
|
|
31
|
+
const item = this.items.shift();
|
|
32
|
+
if (item !== undefined) {
|
|
33
|
+
return Promise.resolve({
|
|
34
|
+
done: false,
|
|
35
|
+
value: item,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
// Drained and closed → the iteration is over.
|
|
39
|
+
if (this.closed)
|
|
40
|
+
return Promise.resolve({ done: true, value: undefined });
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
this.resolvers.push(resolve);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
[Symbol.asyncIterator]() {
|
|
46
|
+
return {
|
|
47
|
+
next: () => this.next(),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export interface LogEntry {
|
|
2
|
+
sequence: number;
|
|
3
|
+
timestamp: number;
|
|
4
|
+
/**
|
|
5
|
+
* Who emitted this line — an action/proc name, or `"event"`/`"run"`/`"snapshot"`.
|
|
6
|
+
* Retained so the structured log can be filtered and correlated by origin.
|
|
7
|
+
*/
|
|
8
|
+
source: string;
|
|
9
|
+
level: string;
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
/** Input to a logger. `source` drives the header line and is retained on the entry. */
|
|
13
|
+
export interface LogInput {
|
|
14
|
+
source: string;
|
|
15
|
+
level: string;
|
|
16
|
+
message: string;
|
|
17
|
+
}
|
|
18
|
+
export interface SubscribeOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Replay the buffered history before live entries, so a late subscriber sees
|
|
21
|
+
* everything logged so far. Default: false (live entries only).
|
|
22
|
+
*/
|
|
23
|
+
replay?: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* What actions and the `proc` primitive log into: a raw append-only store. It
|
|
27
|
+
* keeps no presentation — grouping headers and indentation are applied at read
|
|
28
|
+
* time by the renderer (see `render.ts`), so every entry stays raw and queryable.
|
|
29
|
+
*/
|
|
30
|
+
export interface Logger {
|
|
31
|
+
/** Streams a raw text/byte source into the log, split into lines. */
|
|
32
|
+
attach(stream: AsyncIterable<unknown>, source: string, level: string): void;
|
|
33
|
+
/** Appends a single entry. */
|
|
34
|
+
append(entry: LogInput): void;
|
|
35
|
+
/** Subscribes to entries, optionally replaying history first. */
|
|
36
|
+
subscribe(options?: SubscribeOptions): AsyncIterable<LogEntry>;
|
|
37
|
+
}
|
|
38
|
+
export declare class LogsCollector implements Logger {
|
|
39
|
+
private sequence;
|
|
40
|
+
private readonly history;
|
|
41
|
+
private readonly subscribers;
|
|
42
|
+
private ended;
|
|
43
|
+
/**
|
|
44
|
+
* Streams a raw text/byte source (e.g. a child process's stdout) into the
|
|
45
|
+
* collector: splits it into lines and appends each under the given
|
|
46
|
+
* `source`/`level`. Partial lines are buffered across chunks and a non-empty
|
|
47
|
+
* trailing fragment is flushed when the stream ends.
|
|
48
|
+
*/
|
|
49
|
+
attach(stream: AsyncIterable<unknown>, source: string, level: string): void;
|
|
50
|
+
/** Raw store: records the line verbatim, source included; presentation is the renderer's job. */
|
|
51
|
+
append(entry: LogInput): void;
|
|
52
|
+
/**
|
|
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.
|
|
57
|
+
*/
|
|
58
|
+
endOn(signal: AbortSignal): void;
|
|
59
|
+
/** EOF for readers: end every live subscription and refuse to register new ones. */
|
|
60
|
+
private closeAll;
|
|
61
|
+
subscribe({ replay }?: SubscribeOptions): AsyncIterable<LogEntry>;
|
|
62
|
+
snapshot(): readonly LogEntry[];
|
|
63
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { AsyncQueue } from "../helpers/AsyncQueue.js";
|
|
2
|
+
export class LogsCollector {
|
|
3
|
+
sequence = 0;
|
|
4
|
+
history = [];
|
|
5
|
+
subscribers = new Set();
|
|
6
|
+
// Set once the bound signal aborts: the log has reached EOF for readers.
|
|
7
|
+
ended = false;
|
|
8
|
+
/**
|
|
9
|
+
* Streams a raw text/byte source (e.g. a child process's stdout) into the
|
|
10
|
+
* collector: splits it into lines and appends each under the given
|
|
11
|
+
* `source`/`level`. Partial lines are buffered across chunks and a non-empty
|
|
12
|
+
* trailing fragment is flushed when the stream ends.
|
|
13
|
+
*/
|
|
14
|
+
attach(stream, source, level) {
|
|
15
|
+
void splitLines(stream, (message) => this.append({ source, level, message }));
|
|
16
|
+
}
|
|
17
|
+
/** Raw store: records the line verbatim, source included; presentation is the renderer's job. */
|
|
18
|
+
append(entry) {
|
|
19
|
+
const log = {
|
|
20
|
+
source: entry.source,
|
|
21
|
+
level: entry.level,
|
|
22
|
+
message: entry.message,
|
|
23
|
+
sequence: ++this.sequence,
|
|
24
|
+
timestamp: Date.now(),
|
|
25
|
+
};
|
|
26
|
+
this.history.push(log);
|
|
27
|
+
for (const subscriber of this.subscribers) {
|
|
28
|
+
// push the log into the queue for every subscriber to receive
|
|
29
|
+
subscriber.push(log);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
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.
|
|
37
|
+
*/
|
|
38
|
+
endOn(signal) {
|
|
39
|
+
if (signal.aborted)
|
|
40
|
+
this.closeAll();
|
|
41
|
+
else
|
|
42
|
+
signal.addEventListener("abort", () => this.closeAll(), { once: true });
|
|
43
|
+
}
|
|
44
|
+
/** EOF for readers: end every live subscription and refuse to register new ones. */
|
|
45
|
+
closeAll() {
|
|
46
|
+
this.ended = true;
|
|
47
|
+
for (const queue of this.subscribers)
|
|
48
|
+
queue.close();
|
|
49
|
+
this.subscribers.clear();
|
|
50
|
+
}
|
|
51
|
+
subscribe({ replay = false } = {}) {
|
|
52
|
+
// create a log queue for the new subscriber
|
|
53
|
+
const queue = new AsyncQueue();
|
|
54
|
+
// Optionally replay buffered history before live entries. Synchronous, so no
|
|
55
|
+
// append can interleave between the replay and registration — a late
|
|
56
|
+
// subscriber sees every entry, with no gap and no duplicate.
|
|
57
|
+
if (replay)
|
|
58
|
+
for (const entry of this.history)
|
|
59
|
+
queue.push(entry);
|
|
60
|
+
// If the run has already ended, this subscription is EOF after the replay —
|
|
61
|
+
// don't register it for entries that will never come. Otherwise, fan it in.
|
|
62
|
+
if (this.ended)
|
|
63
|
+
queue.close();
|
|
64
|
+
else
|
|
65
|
+
this.subscribers.add(queue);
|
|
66
|
+
const { subscribers } = this;
|
|
67
|
+
// logs queue for the current subscriber
|
|
68
|
+
const inner = queue[Symbol.asyncIterator]();
|
|
69
|
+
return {
|
|
70
|
+
[Symbol.asyncIterator]() {
|
|
71
|
+
return {
|
|
72
|
+
// iterates the subscriber's log queue
|
|
73
|
+
next: () => inner.next(),
|
|
74
|
+
// Unsubscribe on early exit (break/return), so a consumer that stops
|
|
75
|
+
// reading (e.g. waitForLog on a match) doesn't leave a dead queue that
|
|
76
|
+
// append keeps pushing into forever.
|
|
77
|
+
return: () => {
|
|
78
|
+
subscribers.delete(queue);
|
|
79
|
+
queue.close();
|
|
80
|
+
return Promise.resolve({ done: true, value: undefined });
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
snapshot() {
|
|
87
|
+
return this.history;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Splits a byte/string stream into lines, calling `onLine` for each. Partial
|
|
92
|
+
* lines are buffered across chunks and a non-empty trailing fragment is flushed
|
|
93
|
+
* when the stream ends.
|
|
94
|
+
*/
|
|
95
|
+
async function splitLines(stream, onLine) {
|
|
96
|
+
let buffer = "";
|
|
97
|
+
for await (const chunk of stream) {
|
|
98
|
+
buffer += String(chunk);
|
|
99
|
+
let newline;
|
|
100
|
+
while ((newline = buffer.indexOf("\n")) !== -1) {
|
|
101
|
+
const line = buffer.slice(0, newline).replace(/\r$/, "");
|
|
102
|
+
buffer = buffer.slice(newline + 1);
|
|
103
|
+
if (line)
|
|
104
|
+
onLine(line);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const tail = buffer.replace(/\r$/, "");
|
|
108
|
+
if (tail)
|
|
109
|
+
onLine(tail);
|
|
110
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { LogsCollector } from "./LogsCollector.ts";
|
|
2
|
+
/**
|
|
3
|
+
* The process-wide log. One per process: pipelines log into it and drain it, and
|
|
4
|
+
* the event bus pushes an entry here on every `emit`. Created separately from any
|
|
5
|
+
* pipeline so code that runs before/around a pipeline (like a bus) can reach it.
|
|
6
|
+
*/
|
|
7
|
+
export declare const log: LogsCollector;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { LogsCollector } from "./LogsCollector.js";
|
|
2
|
+
/**
|
|
3
|
+
* The process-wide log. One per process: pipelines log into it and drain it, and
|
|
4
|
+
* the event bus pushes an entry here on every `emit`. Created separately from any
|
|
5
|
+
* pipeline so code that runs before/around a pipeline (like a bus) can reach it.
|
|
6
|
+
*/
|
|
7
|
+
export const log = new LogsCollector();
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { LogEntry } from "./LogsCollector.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Builds a stateful renderer that turns raw {@link LogEntry} records into the
|
|
4
|
+
* pretty, grouped text form. Presentation lives here and nowhere else: the
|
|
5
|
+
* collector stores raw entries; `writeLog` and `drain` each create a renderer and
|
|
6
|
+
* feed it the entry stream in order.
|
|
7
|
+
*
|
|
8
|
+
* Grouping is decided against the *single merged stream* (the renderer's own
|
|
9
|
+
* `lastSource`), not per-producer — so interleaved concurrent sources re-header
|
|
10
|
+
* correctly, which the old per-child grouping got wrong. A milestone line breaks
|
|
11
|
+
* the current group, so the next action line re-headers.
|
|
12
|
+
*/
|
|
13
|
+
export declare const createRenderer: () => ((entry: LogEntry) => string);
|
|
14
|
+
/** Lines of a collapsed run shown inline before the pointer. */
|
|
15
|
+
export declare const COLLAPSE_HEAD = 5;
|
|
16
|
+
/** Only runs *longer than* this collapse, so a trigger always hides a meaningful amount. */
|
|
17
|
+
export declare const COLLAPSE_THRESHOLD = 15;
|
|
18
|
+
/** A run set aside for collapsing: its source and every output line, in order. */
|
|
19
|
+
export interface CollapsedRun {
|
|
20
|
+
readonly source: string;
|
|
21
|
+
readonly lines: readonly string[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* One unit of batch-rendered output: either a ready-to-write text `line`
|
|
25
|
+
* (identical to {@link createRenderer}'s output for that entry) or a
|
|
26
|
+
* `collapsed` run the caller must off-load and render via {@link renderCollapsed}.
|
|
27
|
+
*/
|
|
28
|
+
export type DisplayItem = {
|
|
29
|
+
kind: "line";
|
|
30
|
+
text: string;
|
|
31
|
+
} | {
|
|
32
|
+
kind: "collapsed";
|
|
33
|
+
run: CollapsedRun;
|
|
34
|
+
};
|
|
35
|
+
/** Tunes {@link segmentForFile}. Defaults to {@link COLLAPSE_HEAD}/{@link COLLAPSE_THRESHOLD}. */
|
|
36
|
+
export interface SegmentOptions {
|
|
37
|
+
head?: number;
|
|
38
|
+
threshold?: number;
|
|
39
|
+
}
|
|
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.
|
|
46
|
+
*/
|
|
47
|
+
export declare const segmentForFile: (entries: readonly LogEntry[], { threshold }?: SegmentOptions) => DisplayItem[];
|
|
48
|
+
/**
|
|
49
|
+
* Renders a {@link CollapsedRun} as its `head` lines under the `▸ <source>`
|
|
50
|
+
* header, then a single indented `📎` pointer line to the off-loaded snapshot at
|
|
51
|
+
* `rel` reporting how many lines were hidden. Pure — `rel` is supplied by the
|
|
52
|
+
* caller after it writes the file.
|
|
53
|
+
*/
|
|
54
|
+
export declare const renderCollapsed: (run: CollapsedRun, { head, rel }: {
|
|
55
|
+
head: number;
|
|
56
|
+
rel: string;
|
|
57
|
+
}) => string;
|