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,111 @@
|
|
|
1
|
+
import { marker } from "../markers.js";
|
|
2
|
+
/**
|
|
3
|
+
* Levels that render as their own top-level milestone line — marker-prefixed and
|
|
4
|
+
* never grouped/indented. Everything else is action/proc output, which the
|
|
5
|
+
* renderer groups under an `[action] <source>` header and indents.
|
|
6
|
+
*/
|
|
7
|
+
const MILESTONE = {
|
|
8
|
+
run: "run",
|
|
9
|
+
event: "event",
|
|
10
|
+
snapshot: "snapshot",
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Builds a stateful renderer that turns raw {@link LogEntry} records into the
|
|
14
|
+
* pretty, grouped text form. Presentation lives here and nowhere else: the
|
|
15
|
+
* collector stores raw entries; `writeLog` and `drain` each create a renderer and
|
|
16
|
+
* feed it the entry stream in order.
|
|
17
|
+
*
|
|
18
|
+
* Grouping is decided against the *single merged stream* (the renderer's own
|
|
19
|
+
* `lastSource`), not per-producer — so interleaved concurrent sources re-header
|
|
20
|
+
* correctly, which the old per-child grouping got wrong. A milestone line breaks
|
|
21
|
+
* the current group, so the next action line re-headers.
|
|
22
|
+
*/
|
|
23
|
+
export const createRenderer = () => {
|
|
24
|
+
let lastSource;
|
|
25
|
+
return (entry) => {
|
|
26
|
+
const milestone = MILESTONE[entry.level];
|
|
27
|
+
if (milestone) {
|
|
28
|
+
// Reset grouping: after an interjection, the next action line re-headers.
|
|
29
|
+
lastSource = undefined;
|
|
30
|
+
return marker(milestone, entry.message);
|
|
31
|
+
}
|
|
32
|
+
if (entry.source !== lastSource) {
|
|
33
|
+
lastSource = entry.source;
|
|
34
|
+
return `${marker("action", entry.source)}\n\t${entry.message}`;
|
|
35
|
+
}
|
|
36
|
+
return `\t${entry.message}`;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
// ── Collapsing large output runs (batch / file rendering) ───────────────────
|
|
40
|
+
//
|
|
41
|
+
// A *run* is a maximal stretch of consecutive non-milestone entries sharing one
|
|
42
|
+
// `source` — the same grouping `createRenderer` does. When a proc emits a burst
|
|
43
|
+
// (a build's file tree, tsc/vite chatter), that run can be dozens of lines and
|
|
44
|
+
// drowns the durable `run.log`. The batch renderer below collapses an
|
|
45
|
+
// over-threshold run to its head plus a pointer; the full run is off-loaded to a
|
|
46
|
+
// text snapshot by the caller (which owns the filesystem — this stays pure).
|
|
47
|
+
/** Lines of a collapsed run shown inline before the pointer. */
|
|
48
|
+
export const COLLAPSE_HEAD = 5;
|
|
49
|
+
/** Only runs *longer than* this collapse, so a trigger always hides a meaningful amount. */
|
|
50
|
+
export const COLLAPSE_THRESHOLD = 15;
|
|
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.
|
|
57
|
+
*/
|
|
58
|
+
export const segmentForFile = (entries, { threshold = COLLAPSE_THRESHOLD } = {}) => {
|
|
59
|
+
const items = [];
|
|
60
|
+
// The run currently being accumulated: a same-source stretch of output lines.
|
|
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 = () => {
|
|
65
|
+
if (!run)
|
|
66
|
+
return;
|
|
67
|
+
if (run.lines.length > threshold) {
|
|
68
|
+
items.push({ kind: "collapsed", run: { source: run.source, lines: run.lines } });
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
run.lines.forEach((message, i) => {
|
|
72
|
+
items.push({
|
|
73
|
+
kind: "line",
|
|
74
|
+
text: i === 0 ? `${marker("action", run.source)}\n\t${message}` : `\t${message}`,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
run = undefined;
|
|
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();
|
|
97
|
+
return items;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Renders a {@link CollapsedRun} as its `head` lines under the `▸ <source>`
|
|
101
|
+
* header, then a single indented `📎` pointer line to the off-loaded snapshot at
|
|
102
|
+
* `rel` reporting how many lines were hidden. Pure — `rel` is supplied by the
|
|
103
|
+
* caller after it writes the file.
|
|
104
|
+
*/
|
|
105
|
+
export const renderCollapsed = (run, { head, rel }) => {
|
|
106
|
+
const shown = run.lines.slice(0, head);
|
|
107
|
+
const lines = shown.map((message, i) => i === 0 ? `${marker("action", run.source)}\n\t${message}` : `\t${message}`);
|
|
108
|
+
const hidden = run.lines.length - shown.length;
|
|
109
|
+
lines.push(`\t${marker("snapshot", `… +${hidden} more lines → ${rel}`)}`);
|
|
110
|
+
return lines.join("\n");
|
|
111
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The special "title" log lines that get a distinguishing prefix. */
|
|
2
|
+
export type MarkerKind = "event" | "action" | "run" | "snapshot";
|
|
3
|
+
/**
|
|
4
|
+
* Prefixes a special log line so it's explicit what it is when scanning the
|
|
5
|
+
* global log — an emitted event (`[ev]`), an action's title (`[action]`), a run
|
|
6
|
+
* lifecycle milestone (`[run]`), or a captured state snapshot (`[snapshot]`) —
|
|
7
|
+
* versus plain output.
|
|
8
|
+
*/
|
|
9
|
+
export declare const marker: (kind: MarkerKind, text: string) => string;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const PREFIX = {
|
|
2
|
+
event: "⚡",
|
|
3
|
+
action: "▸",
|
|
4
|
+
run: "●",
|
|
5
|
+
snapshot: "📎",
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Prefixes a special log line so it's explicit what it is when scanning the
|
|
9
|
+
* global log — an emitted event (`[ev]`), an action's title (`[action]`), a run
|
|
10
|
+
* lifecycle milestone (`[run]`), or a captured state snapshot (`[snapshot]`) —
|
|
11
|
+
* versus plain output.
|
|
12
|
+
*/
|
|
13
|
+
export const marker = (kind, text) => `${PREFIX[kind]} ${text}`;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { LogsCollector } from "./log-collector/LogsCollector.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Drains the collected log to two files in the run's output folder (the common
|
|
4
|
+
* `logs/<name>/<date>/<time>/` convention, local time):
|
|
5
|
+
*
|
|
6
|
+
* - `run.log` — the pretty, human-scannable rendering: just the message lines.
|
|
7
|
+
* - `run.jsonl` — the structured, machine-queryable record: one full {@link LogEntry}
|
|
8
|
+
* per line (sequence, timestamp, level, source, message), so an agent can filter
|
|
9
|
+
* and correlate by origin/level/time instead of parsing prose.
|
|
10
|
+
*
|
|
11
|
+
* Returns the `run.log` path (the one surfaced to the terminal).
|
|
12
|
+
*/
|
|
13
|
+
export declare const writeLog: (logs: LogsCollector) => Promise<string>;
|
|
14
|
+
/**
|
|
15
|
+
* Streams log entries to `run.jsonl` and `run.log` incrementally as they arrive,
|
|
16
|
+
* so output survives a hard kill or lost SIGINT. Each entry is appended via a
|
|
17
|
+
* write stream. The subscription ends on teardown (`logs.endOn`), after which
|
|
18
|
+
* {@link writeLog} overwrites both files with the authoritative final snapshot
|
|
19
|
+
* (including post-abort entries like "finished"). For dirty exits, whatever was
|
|
20
|
+
* flushed to disk by the OS is the best available record.
|
|
21
|
+
*/
|
|
22
|
+
export declare const streamLog: (logs: LogsCollector) => Promise<void>;
|
|
23
|
+
/** A written (or in-flight) snapshot file and where to find it. */
|
|
24
|
+
export interface SnapshotRef {
|
|
25
|
+
/** Absolute path to the snapshot file. */
|
|
26
|
+
readonly file: string;
|
|
27
|
+
/** Path relative to cwd — what a log line references. */
|
|
28
|
+
readonly rel: string;
|
|
29
|
+
/** Resolves once the file is flushed to disk. */
|
|
30
|
+
readonly written: Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Writes `value` as a sequenced JSON snapshot file (`<name>-NN.json`) in the run's
|
|
34
|
+
* output folder and returns its path — **without** logging a pointer line. The
|
|
35
|
+
* path is computed synchronously (so callers can reference it in the same tick)
|
|
36
|
+
* while the write proceeds in the background. Use {@link snapshot} when you also
|
|
37
|
+
* want a `[snapshot]` line in the timeline; the event bus uses this directly to
|
|
38
|
+
* link a non-string payload from its own line.
|
|
39
|
+
*/
|
|
40
|
+
export declare const captureSnapshot: (name: string, value: unknown) => SnapshotRef;
|
|
41
|
+
/**
|
|
42
|
+
* Writes raw text `lines` as a sequenced `.log` snapshot file in the run's output
|
|
43
|
+
* folder and returns its path refs — the text sibling of {@link captureSnapshot}
|
|
44
|
+
* (which writes JSON). {@link writeLog} uses it to off-load a collapsed output
|
|
45
|
+
* run from `run.log`, keeping the burst's full text one click away.
|
|
46
|
+
*/
|
|
47
|
+
export declare const captureText: (name: string, lines: readonly string[]) => SnapshotRef;
|
|
48
|
+
/**
|
|
49
|
+
* Captures any value as a JSON snapshot file (via {@link captureSnapshot}) and
|
|
50
|
+
* records a `[snapshot] <name>: <path>` line in the timeline at the point it's
|
|
51
|
+
* taken. Files are sequenced so repeated snapshots of the same name are kept as a
|
|
52
|
+
* history rather than overwritten. Callable any time, anywhere in the run;
|
|
53
|
+
* returns the file path once written.
|
|
54
|
+
*/
|
|
55
|
+
export declare const snapshot: (name: string, value: unknown) => Promise<string>;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { log } from "./log-collector/global.js";
|
|
5
|
+
import { COLLAPSE_HEAD, createRenderer, renderCollapsed, segmentForFile, } from "./log-collector/render.js";
|
|
6
|
+
const pad = (n, width = 2) => String(n).padStart(width, "0");
|
|
7
|
+
/** The entry script's base name (the run's name), e.g. "tskb-build". */
|
|
8
|
+
const pipelineName = () => {
|
|
9
|
+
const entry = process.argv[1];
|
|
10
|
+
return entry ? path.basename(entry, path.extname(entry)) : "pipeline";
|
|
11
|
+
};
|
|
12
|
+
// One output directory per run (one run per process): logs/<name>/<date>/<time>/.
|
|
13
|
+
// Computed once, lazily — so the log file and every snapshot land together, even
|
|
14
|
+
// when a snapshot is taken mid-run before the log is flushed.
|
|
15
|
+
let dir;
|
|
16
|
+
const runDir = () => {
|
|
17
|
+
if (dir)
|
|
18
|
+
return dir;
|
|
19
|
+
const now = new Date();
|
|
20
|
+
const day = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
21
|
+
const time = `${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
|
|
22
|
+
dir = path.join("logs", pipelineName(), day, time);
|
|
23
|
+
return dir;
|
|
24
|
+
};
|
|
25
|
+
let made;
|
|
26
|
+
const ensureDir = () => (made ??= mkdir(runDir(), { recursive: true }).then(() => { }));
|
|
27
|
+
/** A compact header block for the top of run.log — legend + run identity. */
|
|
28
|
+
const logHeader = () => {
|
|
29
|
+
const now = new Date();
|
|
30
|
+
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
31
|
+
const time = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
|
32
|
+
return [
|
|
33
|
+
`─── ${pipelineName()} · ${date} ${time} ───`,
|
|
34
|
+
`● run ⚡ event ▸ action 📎 snapshot ⇥ output`,
|
|
35
|
+
"",
|
|
36
|
+
].join("\n");
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Drains the collected log to two files in the run's output folder (the common
|
|
40
|
+
* `logs/<name>/<date>/<time>/` convention, local time):
|
|
41
|
+
*
|
|
42
|
+
* - `run.log` — the pretty, human-scannable rendering: just the message lines.
|
|
43
|
+
* - `run.jsonl` — the structured, machine-queryable record: one full {@link LogEntry}
|
|
44
|
+
* per line (sequence, timestamp, level, source, message), so an agent can filter
|
|
45
|
+
* and correlate by origin/level/time instead of parsing prose.
|
|
46
|
+
*
|
|
47
|
+
* Returns the `run.log` path (the one surfaced to the terminal).
|
|
48
|
+
*/
|
|
49
|
+
export const writeLog = async (logs) => {
|
|
50
|
+
await ensureDir();
|
|
51
|
+
const entries = logs.snapshot();
|
|
52
|
+
// Apply grouping/indentation at write time from the raw entries, so the stored
|
|
53
|
+
// messages stay presentation-free. Unlike the live `drain`, the final file
|
|
54
|
+
// collapses large same-source runs: each is off-loaded to a text snapshot and
|
|
55
|
+
// replaced inline by its head plus a pointer, so one proc's burst can't drown
|
|
56
|
+
// the timeline. `run.jsonl` below keeps every line, raw.
|
|
57
|
+
const file = path.join(runDir(), "run.log");
|
|
58
|
+
const header = logHeader();
|
|
59
|
+
const writes = [];
|
|
60
|
+
const parts = segmentForFile(entries).map((item) => {
|
|
61
|
+
if (item.kind === "line")
|
|
62
|
+
return item.text;
|
|
63
|
+
const snap = captureText(`output-${item.run.source}`, item.run.lines);
|
|
64
|
+
writes.push(snap.written);
|
|
65
|
+
return renderCollapsed(item.run, { head: COLLAPSE_HEAD, rel: snap.rel });
|
|
66
|
+
});
|
|
67
|
+
const pretty = parts.join("\n");
|
|
68
|
+
await writeFile(file, pretty ? `${header}${pretty}\n` : "", "utf8");
|
|
69
|
+
// Ensure every off-loaded run is flushed before we report the log is written.
|
|
70
|
+
await Promise.all(writes);
|
|
71
|
+
const jsonlFile = path.join(runDir(), "run.jsonl");
|
|
72
|
+
const jsonl = entries.map((entry) => JSON.stringify(entry)).join("\n");
|
|
73
|
+
await writeFile(jsonlFile, jsonl ? `${jsonl}\n` : "", "utf8");
|
|
74
|
+
return file;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Streams log entries to `run.jsonl` and `run.log` incrementally as they arrive,
|
|
78
|
+
* so output survives a hard kill or lost SIGINT. Each entry is appended via a
|
|
79
|
+
* write stream. The subscription ends on teardown (`logs.endOn`), after which
|
|
80
|
+
* {@link writeLog} overwrites both files with the authoritative final snapshot
|
|
81
|
+
* (including post-abort entries like "finished"). For dirty exits, whatever was
|
|
82
|
+
* flushed to disk by the OS is the best available record.
|
|
83
|
+
*/
|
|
84
|
+
export const streamLog = async (logs) => {
|
|
85
|
+
await ensureDir();
|
|
86
|
+
const jsonlFile = path.join(runDir(), "run.jsonl");
|
|
87
|
+
const logFile = path.join(runDir(), "run.log");
|
|
88
|
+
const jsonl = createWriteStream(jsonlFile);
|
|
89
|
+
const pretty = createWriteStream(logFile);
|
|
90
|
+
const render = createRenderer();
|
|
91
|
+
pretty.write(logHeader() + "\n");
|
|
92
|
+
try {
|
|
93
|
+
for await (const entry of logs.subscribe({ replay: true })) {
|
|
94
|
+
jsonl.write(JSON.stringify(entry) + "\n");
|
|
95
|
+
pretty.write(render(entry) + "\n");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
jsonl.end();
|
|
100
|
+
pretty.end();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
let seq = 0;
|
|
104
|
+
/** Serialize a value for a snapshot file, tolerating circular refs / non-JSON values. */
|
|
105
|
+
const serialize = (value) => {
|
|
106
|
+
try {
|
|
107
|
+
// JSON.stringify returns undefined for functions/undefined — fall back to a string.
|
|
108
|
+
return JSON.stringify(value, null, 2) ?? JSON.stringify(String(value));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Circular or otherwise non-serializable: keep a best-effort representation
|
|
112
|
+
// rather than throwing and losing the value entirely.
|
|
113
|
+
return JSON.stringify(String(value));
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Writes `value` as a sequenced JSON snapshot file (`<name>-NN.json`) in the run's
|
|
118
|
+
* output folder and returns its path — **without** logging a pointer line. The
|
|
119
|
+
* path is computed synchronously (so callers can reference it in the same tick)
|
|
120
|
+
* while the write proceeds in the background. Use {@link snapshot} when you also
|
|
121
|
+
* want a `[snapshot]` line in the timeline; the event bus uses this directly to
|
|
122
|
+
* link a non-string payload from its own line.
|
|
123
|
+
*/
|
|
124
|
+
export const captureSnapshot = (name, value) => {
|
|
125
|
+
const safe = name.replace(/[^\w.-]+/g, "-");
|
|
126
|
+
const file = path.join(runDir(), `${safe}-${pad(++seq)}.json`);
|
|
127
|
+
const written = ensureDir().then(() => writeFile(file, `${serialize(value)}\n`, "utf8"));
|
|
128
|
+
// Normalize to forward slashes so the reference is portable in the log artifact.
|
|
129
|
+
const rel = path.relative(process.cwd(), file).replaceAll("\\", "/");
|
|
130
|
+
return { file, rel, written };
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* Writes raw text `lines` as a sequenced `.log` snapshot file in the run's output
|
|
134
|
+
* folder and returns its path refs — the text sibling of {@link captureSnapshot}
|
|
135
|
+
* (which writes JSON). {@link writeLog} uses it to off-load a collapsed output
|
|
136
|
+
* run from `run.log`, keeping the burst's full text one click away.
|
|
137
|
+
*/
|
|
138
|
+
export const captureText = (name, lines) => {
|
|
139
|
+
const safe = name.replace(/[^\w.-]+/g, "-");
|
|
140
|
+
const file = path.join(runDir(), `${safe}-${pad(++seq)}.log`);
|
|
141
|
+
const body = lines.length ? `${lines.join("\n")}\n` : "";
|
|
142
|
+
const written = ensureDir().then(() => writeFile(file, body, "utf8"));
|
|
143
|
+
// Normalize to forward slashes so the reference is portable in the log artifact.
|
|
144
|
+
const rel = path.relative(process.cwd(), file).replaceAll("\\", "/");
|
|
145
|
+
return { file, rel, written };
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Captures any value as a JSON snapshot file (via {@link captureSnapshot}) and
|
|
149
|
+
* records a `[snapshot] <name>: <path>` line in the timeline at the point it's
|
|
150
|
+
* taken. Files are sequenced so repeated snapshots of the same name are kept as a
|
|
151
|
+
* history rather than overwritten. Callable any time, anywhere in the run;
|
|
152
|
+
* returns the file path once written.
|
|
153
|
+
*/
|
|
154
|
+
export const snapshot = (name, value) => {
|
|
155
|
+
const { file, rel, written } = captureSnapshot(name, value);
|
|
156
|
+
// Record the pointer synchronously, so it sits at the right spot in the timeline.
|
|
157
|
+
log.append({
|
|
158
|
+
source: "snapshot",
|
|
159
|
+
level: "snapshot",
|
|
160
|
+
message: `${name}: ${rel}`,
|
|
161
|
+
});
|
|
162
|
+
return written.then(() => file);
|
|
163
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Options, type ProcessPromise } from "zx";
|
|
2
|
+
import type { Logger } from "./log-collector/LogsCollector.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Spawns a command (tagged-template, exactly like zx's `$`) and streams the
|
|
5
|
+
* child's stdout/stderr into a log (stdout as `info`, stderr as `error`) under
|
|
6
|
+
* the given `name`, killing the child when the bound signal aborts. Returns zx's
|
|
7
|
+
* `ProcessPromise`, so the caller can await it for the result.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* proc("build-docs", { cwd })`tskb ${pattern} --project ${name}`;
|
|
11
|
+
*/
|
|
12
|
+
export type Proc = (name: string, opts?: Partial<Options>) => (pieces: TemplateStringsArray, ...args: unknown[]) => ProcessPromise;
|
|
13
|
+
/**
|
|
14
|
+
* Builds a {@link Proc} bound to a specific log sink and abort signal. The action
|
|
15
|
+
* pipeline injects one per action as `ctx.proc`, wired to that action's logger
|
|
16
|
+
* and the run's signal — so actions just describe the command, with no ambient
|
|
17
|
+
* lookup and nothing threaded through. A custom source (e.g. a CDP connection)
|
|
18
|
+
* follows the same shape: take the action's `logs`/`signal`, push into the log,
|
|
19
|
+
* and hook teardown.
|
|
20
|
+
*/
|
|
21
|
+
export declare const createProc: (logs: Logger, signal: AbortSignal) => Proc;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { $, kill, usePowerShell } from "zx";
|
|
3
|
+
import { captureSnapshot } from "./output.js";
|
|
4
|
+
// zx defaults to bash, which isn't present on a stock Windows box.
|
|
5
|
+
// Use Windows PowerShell there so zx spawns and quotes for the right shell.
|
|
6
|
+
if (os.platform() === "win32")
|
|
7
|
+
usePowerShell();
|
|
8
|
+
/**
|
|
9
|
+
* Builds a {@link Proc} bound to a specific log sink and abort signal. The action
|
|
10
|
+
* pipeline injects one per action as `ctx.proc`, wired to that action's logger
|
|
11
|
+
* and the run's signal — so actions just describe the command, with no ambient
|
|
12
|
+
* lookup and nothing threaded through. A custom source (e.g. a CDP connection)
|
|
13
|
+
* follows the same shape: take the action's `logs`/`signal`, push into the log,
|
|
14
|
+
* and hook teardown.
|
|
15
|
+
*/
|
|
16
|
+
export const createProc = (logs, signal) => (name, opts) => {
|
|
17
|
+
// quiet: zx must not echo the child's output to our terminal — the collector
|
|
18
|
+
// owns all output (otherwise every line prints twice: raw from zx, formatted
|
|
19
|
+
// from the drain). Caller opts can still override.
|
|
20
|
+
const $$ = $({ quiet: true, ...opts });
|
|
21
|
+
return (pieces, ...args) => {
|
|
22
|
+
const child = $$(pieces, ...args);
|
|
23
|
+
// Record the launch on the timeline as an event (the `event` level renders
|
|
24
|
+
// `[ev]`), under the proc's own `source` so it's filterable by origin in
|
|
25
|
+
// run.jsonl while still rendering as a milestone. The command zx resolved
|
|
26
|
+
// rides inline for scanning; the structured args (cmd, cwd, …) are written
|
|
27
|
+
// to a snapshot file and linked, exactly as the event bus does for a
|
|
28
|
+
// non-string payload (see events.ts).
|
|
29
|
+
//
|
|
30
|
+
// `env` is dropped: passing it to zx means handing the whole environment
|
|
31
|
+
// (you spread `...process.env` to add one var), so snapshotting it would
|
|
32
|
+
// write 600+ entries — secrets and tokens included — to logs/ on every
|
|
33
|
+
// launch. Everything else in opts is kept.
|
|
34
|
+
const { env: _env, ...safeOpts } = opts ?? {};
|
|
35
|
+
// Resolve where the child actually runs. zx only records cwd on its child
|
|
36
|
+
// when one was passed; left unset it falls back to process.cwd() at spawn,
|
|
37
|
+
// so mirror that here to always log a concrete directory (the `...safeOpts`
|
|
38
|
+
// spread sits before `cwd` so this resolved value wins even when opts omits
|
|
39
|
+
// it). This is the "where" that makes a launch line reproducible.
|
|
40
|
+
const cwd = opts?.cwd ?? process.cwd();
|
|
41
|
+
const snap = captureSnapshot(`proc-${name}-start`, { cmd: child.cmd, ...safeOpts, cwd });
|
|
42
|
+
logs.append({
|
|
43
|
+
source: name,
|
|
44
|
+
level: "event",
|
|
45
|
+
message: `${name} · start · ${child.cmd} · in ${cwd} · → ${snap.rel}`,
|
|
46
|
+
});
|
|
47
|
+
logs.attach(child.stdout, name, "info");
|
|
48
|
+
logs.attach(child.stderr, name, "error");
|
|
49
|
+
// Kill the child's entire process tree on teardown. `child.kill()` (zx's
|
|
50
|
+
// ProcessPromise method) throws "Too late to kill" when the direct child
|
|
51
|
+
// has already settled — but grandchildren (vite, tsc, npm scripts) may
|
|
52
|
+
// still be alive. Using zx's standalone `kill(pid)` bypasses that guard
|
|
53
|
+
// and does a cross-platform tree kill (taskkill /T /F on Windows,
|
|
54
|
+
// ps.tree + process-group kill on Unix).
|
|
55
|
+
let finished = false;
|
|
56
|
+
const onAbort = () => {
|
|
57
|
+
if (finished)
|
|
58
|
+
return;
|
|
59
|
+
const pid = child.pid;
|
|
60
|
+
if (pid != null)
|
|
61
|
+
void kill(pid).catch(() => { });
|
|
62
|
+
};
|
|
63
|
+
const markFinished = () => {
|
|
64
|
+
finished = true;
|
|
65
|
+
signal.removeEventListener("abort", onAbort);
|
|
66
|
+
};
|
|
67
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
68
|
+
// Registered before the caller's own `.then`, so `finished` is set before a
|
|
69
|
+
// completion handler can fire `cancel()` and abort us.
|
|
70
|
+
void child.then(markFinished, markFinished);
|
|
71
|
+
return child;
|
|
72
|
+
};
|
|
73
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { AnyActionInstance } from "./action.ts";
|
|
2
|
+
import { log as logs } from "./log-collector/global.ts";
|
|
3
|
+
/**
|
|
4
|
+
* A single action's failure, captured for the run's verdict. Cascade failures —
|
|
5
|
+
* actions that reject only because teardown killed them — are deliberately
|
|
6
|
+
* excluded (see `launch`'s catch), so this records genuine faults, not fallout.
|
|
7
|
+
*/
|
|
8
|
+
export interface ActionFailure {
|
|
9
|
+
/** The failing action's name. */
|
|
10
|
+
readonly action: string;
|
|
11
|
+
/** Whatever it threw or rejected with. */
|
|
12
|
+
readonly error: unknown;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The terminal verdict of a run: did everything that mattered succeed, and if
|
|
16
|
+
* not, what failed. {@link Run.done} resolves (never rejects) with this, so a
|
|
17
|
+
* single `await` yields the outcome instead of forcing a try/catch on awaiters.
|
|
18
|
+
*/
|
|
19
|
+
export interface RunResult {
|
|
20
|
+
/** True when no genuine failure was recorded. */
|
|
21
|
+
readonly ok: boolean;
|
|
22
|
+
/** The genuine failures, in the order they surfaced. Empty when `ok`. */
|
|
23
|
+
readonly failures: readonly ActionFailure[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The run's lifecycle. It only ever moves forward:
|
|
27
|
+
*
|
|
28
|
+
* open ──▶ closing ──▶ closed
|
|
29
|
+
*
|
|
30
|
+
* - **open** — launching and running actions; `.run(…)` is accepted.
|
|
31
|
+
* - **closing** — teardown has begun (cancel, first failure, Ctrl-C, or natural
|
|
32
|
+
* completion). The signal is aborted, in-flight actions are unwinding, and new
|
|
33
|
+
* `.run(…)` is refused.
|
|
34
|
+
* - **closed** — everything has settled, the log is flushed, the verdict is final.
|
|
35
|
+
*/
|
|
36
|
+
export type RunState = "open" | "closing" | "closed";
|
|
37
|
+
/** Tunes a run's failure policy. */
|
|
38
|
+
export interface RunOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Abort the whole run the moment the first genuine failure surfaces
|
|
41
|
+
* (default `true`). Set `false` to let every action finish and collect all
|
|
42
|
+
* failures into the verdict — useful when you want the complete picture of
|
|
43
|
+
* what broke rather than just the first cause.
|
|
44
|
+
*/
|
|
45
|
+
readonly failFast?: boolean;
|
|
46
|
+
}
|
|
47
|
+
/** A live run: a set of actions sharing one abort controller and the global log. */
|
|
48
|
+
export interface Run {
|
|
49
|
+
/** The shared global log (output + events for everything in this run). */
|
|
50
|
+
readonly logs: typeof logs;
|
|
51
|
+
/** The current lifecycle state — see {@link RunState}. */
|
|
52
|
+
readonly state: RunState;
|
|
53
|
+
/** Settles once the run reaches `closed`, carrying the terminal {@link RunResult}. */
|
|
54
|
+
readonly done: Promise<RunResult>;
|
|
55
|
+
/** The run's abort signal — for consumers that want to observe teardown. */
|
|
56
|
+
readonly signal: AbortSignal;
|
|
57
|
+
/**
|
|
58
|
+
* Launch more actions into this same run — same controller, same log. Chainable.
|
|
59
|
+
*
|
|
60
|
+
* Throws if the run is no longer `open`: launching into a closing/closed run
|
|
61
|
+
* would bind the actions to an already-aborted signal, so they'd start and
|
|
62
|
+
* immediately unwind, silently doing nothing. Launch before teardown, or from
|
|
63
|
+
* a lifecycle event of an action that is still running (those fire while the
|
|
64
|
+
* run is still `open`).
|
|
65
|
+
*/
|
|
66
|
+
run(...actions: AnyActionInstance[]): Run;
|
|
67
|
+
/** Begin teardown: abort the signal (killing procs, unblocking waits, stopping daemons). */
|
|
68
|
+
cancel(): void;
|
|
69
|
+
/**
|
|
70
|
+
* Start streaming the global log live to a writer (console by default) in the
|
|
71
|
+
* background; it stops on its own when the run settles. Returns the run, so it
|
|
72
|
+
* chains. Await `.done` for the verdict.
|
|
73
|
+
*/
|
|
74
|
+
drain(write?: (message: string) => void): Run;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Starts the run: launches the given actions in parallel under one abort
|
|
78
|
+
* controller, against the process-wide global log. Returns a {@link Run} — add
|
|
79
|
+
* more actions on demand with `.run(…)`, observe `.done` for the verdict, stop
|
|
80
|
+
* with `.cancel()`. An optional leading {@link RunOptions} tunes failure policy.
|
|
81
|
+
*
|
|
82
|
+
* **Callable once per process.** A run *is* the world being modelled, and there's
|
|
83
|
+
* one global log per world; a second `run(…)` would silently share that log, so
|
|
84
|
+
* it throws instead. Attach further actions — including event-driven ones — to
|
|
85
|
+
* the returned {@link Run} via its `.run(…)` method, not by calling this again.
|
|
86
|
+
*
|
|
87
|
+
* Each action runs against its own child logger (which headers and indents its
|
|
88
|
+
* lines into the global log) and the run's shared abort signal — both injected
|
|
89
|
+
* into its context, where the framework also binds a `proc` to them so child
|
|
90
|
+
* processes stream into the right log and die on teardown. On Ctrl-C, `.cancel()`,
|
|
91
|
+
* the first failure (under fail-fast), or natural completion, the run transitions
|
|
92
|
+
* through {@link RunState} to `closed`.
|
|
93
|
+
*/
|
|
94
|
+
export declare function run(...actions: AnyActionInstance[]): Run;
|
|
95
|
+
export declare function run(options: RunOptions, ...actions: AnyActionInstance[]): Run;
|