omkit 0.0.8 → 0.0.10

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.
Files changed (72) hide show
  1. package/dist/actions/chrome-page.d.ts +1 -1
  2. package/dist/actions/chrome-page.js +1 -1
  3. package/dist/actions/command.d.ts +1 -1
  4. package/dist/actions/command.js +5 -6
  5. package/dist/actions/healthcheck.d.ts +1 -1
  6. package/dist/actions/healthcheck.js +1 -1
  7. package/dist/actions/prompt.d.ts +1 -1
  8. package/dist/actions/prompt.js +1 -1
  9. package/dist/actions/tail-log.d.ts +1 -1
  10. package/dist/actions/tail-log.js +1 -1
  11. package/dist/actions/until-log.d.ts +2 -2
  12. package/dist/actions/until-log.js +1 -1
  13. package/dist/actions/watch-dir.d.ts +1 -1
  14. package/dist/actions/watch-dir.js +1 -1
  15. package/dist/actions/watch.d.ts +1 -1
  16. package/dist/actions/watch.js +1 -1
  17. package/dist/index.d.ts +13 -17
  18. package/dist/index.js +15 -17
  19. package/dist/orchestration/action/action.d.ts +23 -0
  20. package/dist/orchestration/action/action.js +254 -0
  21. package/dist/{core/action.d.ts → orchestration/action/types.d.ts} +37 -26
  22. package/dist/orchestration/action/types.js +1 -0
  23. package/dist/{core → orchestration/events}/events.d.ts +9 -1
  24. package/dist/{core → orchestration/events}/events.js +14 -22
  25. package/dist/orchestration/spin/SpinHost.d.ts +105 -0
  26. package/dist/orchestration/spin/SpinHost.js +303 -0
  27. package/dist/orchestration/spin/spin.d.ts +24 -0
  28. package/dist/orchestration/spin/spin.js +56 -0
  29. package/dist/orchestration/spin/types.d.ts +56 -0
  30. package/dist/orchestration/spin/types.js +1 -0
  31. package/dist/output/Output.d.ts +24 -0
  32. package/dist/output/Output.js +27 -0
  33. package/dist/output/folder/RunFolder.d.ts +29 -0
  34. package/dist/output/folder/RunFolder.js +56 -0
  35. package/dist/output/index.d.ts +11 -0
  36. package/dist/output/index.js +11 -0
  37. package/dist/output/log/LogRenderer.d.ts +73 -0
  38. package/dist/output/log/LogRenderer.js +131 -0
  39. package/dist/{core/log-collector → output/log}/LogsCollector.d.ts +21 -5
  40. package/dist/{core/log-collector → output/log}/LogsCollector.js +41 -10
  41. package/dist/output/log/RunLog.d.ts +44 -0
  42. package/dist/output/log/RunLog.js +119 -0
  43. package/dist/output/snapshot/SnapshotStore.d.ts +49 -0
  44. package/dist/output/snapshot/SnapshotStore.js +61 -0
  45. package/dist/system/fs/FolderCache.d.ts +47 -0
  46. package/dist/system/fs/FolderCache.js +117 -0
  47. package/dist/system/fs/streams.d.ts +11 -0
  48. package/dist/system/fs/streams.js +14 -0
  49. package/dist/{core → system/process}/process.d.ts +9 -8
  50. package/dist/{core → system/process}/process.js +8 -9
  51. package/dist/utils/format.d.ts +9 -0
  52. package/dist/utils/format.js +9 -0
  53. package/dist/utils/serialize.d.ts +2 -0
  54. package/dist/utils/serialize.js +12 -0
  55. package/package.json +1 -1
  56. package/dist/core/action.js +0 -188
  57. package/dist/core/folder-cache.d.ts +0 -39
  58. package/dist/core/folder-cache.js +0 -109
  59. package/dist/core/log-collector/global.d.ts +0 -7
  60. package/dist/core/log-collector/global.js +0 -7
  61. package/dist/core/log-collector/render.d.ts +0 -57
  62. package/dist/core/log-collector/render.js +0 -111
  63. package/dist/core/markers.d.ts +0 -9
  64. package/dist/core/markers.js +0 -13
  65. package/dist/core/output.d.ts +0 -64
  66. package/dist/core/output.js +0 -190
  67. package/dist/core/run.d.ts +0 -88
  68. package/dist/core/run.js +0 -240
  69. package/dist/core/spin.d.ts +0 -72
  70. package/dist/core/spin.js +0 -36
  71. /package/dist/{core/helpers → utils}/AsyncQueue.d.ts +0 -0
  72. /package/dist/{core/helpers → utils}/AsyncQueue.js +0 -0
@@ -1,109 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
- import { isAbsolute, join, resolve } from "node:path";
4
- /**
5
- * An input-fingerprint cache: hash the mtime+size of one or more **input**
6
- * targets (the files/folders an action reads — *not* what it produces), so an
7
- * expensive action can be skipped when its inputs are unchanged since its last
8
- * successful run. Backs the `.withCache(...)` method on action instances.
9
- *
10
- * Entries live under `node_modules/.cache/omkit/` (conventional, gitignored) —
11
- * one JSON file per set of inputs, named by a hash of their absolute paths.
12
- */
13
- /** Where cache entries are written — `node_modules/.cache/omkit/` under the cwd. */
14
- export const cacheDir = join(process.cwd(), "node_modules", ".cache", "omkit");
15
- /**
16
- * Require every path to be **absolute** (a relative path is a developer error —
17
- * throws, naming it) and return them canonicalized with `resolve`, so different
18
- * spellings of the same target (`.`/`..`/trailing slash) collapse to one stable
19
- * fingerprint prefix and cache key. `resolve` on an already-absolute path only
20
- * normalizes — it never falls back to the cwd. Each target may be a file or a
21
- * folder (see {@link fingerprint}).
22
- */
23
- export const resolvePaths = (paths) => paths.map((p) => {
24
- if (!isAbsolute(p)) {
25
- throw new Error(`withCache: path must be absolute, got "${p}"`);
26
- }
27
- return resolve(p);
28
- });
29
- /**
30
- * Fingerprints one or more **input** targets by mtime+size. Each target may be
31
- * a **file** (fingerprinted directly) or a **folder** (walked recursively, its
32
- * files fingerprinted). Every path must be absolute (throws otherwise). A
33
- * missing target contributes nothing — so an input that doesn't exist yet is
34
- * naturally a miss, not an error. The entry list is sorted for determinism
35
- * before hashing.
36
- */
37
- export const fingerprint = async (paths) => {
38
- const targets = resolvePaths(paths);
39
- const entries = [];
40
- const record = (target, rel, mtimeMs, size) => {
41
- entries.push(`${target}\0${rel}\0${mtimeMs}\0${size}`);
42
- };
43
- for (const target of targets) {
44
- let root;
45
- try {
46
- root = await stat(target);
47
- }
48
- catch {
49
- continue; // missing/unreadable — contributes no entries
50
- }
51
- // A file target fingerprints itself (empty rel — there's no walk).
52
- if (root.isFile()) {
53
- record(target, "", root.mtimeMs, root.size);
54
- continue;
55
- }
56
- // A folder target: walk it and fingerprint every file inside.
57
- let rels;
58
- try {
59
- rels = await readdir(target, { recursive: true });
60
- }
61
- catch {
62
- continue;
63
- }
64
- for (const rel of rels) {
65
- const abs = join(target, rel);
66
- try {
67
- const stats = await stat(abs);
68
- if (stats.isFile())
69
- record(target, rel, stats.mtimeMs, stats.size);
70
- }
71
- catch {
72
- // entry vanished between readdir and stat — skip
73
- }
74
- }
75
- }
76
- entries.sort();
77
- return createHash("sha1").update(entries.join("\n")).digest("hex");
78
- };
79
- /** The on-disk filename for a set of targets — a hash of their absolute paths. */
80
- const entryPath = (paths) => {
81
- const key = createHash("sha1").update(resolvePaths(paths).sort().join("\0")).digest("hex");
82
- return join(cacheDir, `${key}.json`);
83
- };
84
- /**
85
- * Reads the fingerprint stored for these targets, or `undefined` when there's
86
- * no cache (absent, unreadable, or malformed — all treated as a miss).
87
- */
88
- export const read = async (paths) => {
89
- try {
90
- const raw = await readFile(entryPath(paths), "utf8");
91
- const entry = JSON.parse(raw);
92
- return typeof entry.fingerprint === "string" ? entry.fingerprint : undefined;
93
- }
94
- catch {
95
- return undefined;
96
- }
97
- };
98
- /**
99
- * Records the fingerprint for these targets after a successful run. Writes
100
- * atomically (temp file + rename) so a concurrent read never sees a half-file.
101
- */
102
- export const write = async (paths, fp) => {
103
- await mkdir(cacheDir, { recursive: true });
104
- const target = entryPath(paths);
105
- const entry = { folders: paths, fingerprint: fp, ts: Date.now() };
106
- const tmp = `${target}.${process.pid}.tmp`;
107
- await writeFile(tmp, JSON.stringify(entry, null, 2));
108
- await rename(tmp, target);
109
- };
@@ -1,7 +0,0 @@
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;
@@ -1,7 +0,0 @@
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();
@@ -1,57 +0,0 @@
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;
@@ -1,111 +0,0 @@
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
- };
@@ -1,9 +0,0 @@
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;
@@ -1,13 +0,0 @@
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}`;
@@ -1,64 +0,0 @@
1
- import type { LogsCollector } from "./log-collector/LogsCollector.ts";
2
- /**
3
- * Absolute path to this run's output folder — `logs/<name>/<date>/<time>/`, where
4
- * `run.log`, `run.jsonl`, and snapshots live. Exposed so actions can drop their
5
- * own **artifacts** into the same per-run folder (one folder per run; one run per
6
- * process). The folder is created on first access — synchronously, so a caller
7
- * can write into it in the same tick — and the path is absolute so it's safe to
8
- * hand to a child process with a different cwd.
9
- */
10
- export declare const artifactsFolder: () => string;
11
- /**
12
- * Drains the collected log to two files in the run's output folder (the common
13
- * `logs/<name>/<date>/<time>/` convention, local time):
14
- *
15
- * - `run.log` — the pretty, human-scannable rendering: just the message lines.
16
- * - `run.jsonl` — the structured, machine-queryable record: one full {@link LogEntry}
17
- * per line (sequence, timestamp, level, source, message), so an agent can filter
18
- * and correlate by origin/level/time instead of parsing prose.
19
- *
20
- * Returns the `run.log` path (the one surfaced to the terminal).
21
- */
22
- export declare const writeLog: (logs: LogsCollector) => Promise<string>;
23
- /**
24
- * Streams log entries to `run.jsonl` and `run.log` incrementally as they arrive,
25
- * so output survives a hard kill or lost SIGINT. Each entry is appended via a
26
- * write stream. The subscription ends on teardown (`logs.endOn`), after which
27
- * {@link writeLog} overwrites both files with the authoritative final snapshot
28
- * (including post-abort entries like "finished"). For dirty exits, whatever was
29
- * flushed to disk by the OS is the best available record.
30
- */
31
- export declare const streamLog: (logs: LogsCollector) => Promise<void>;
32
- /** A written (or in-flight) snapshot file and where to find it. */
33
- export interface SnapshotRef {
34
- /** Absolute path to the snapshot file. */
35
- readonly file: string;
36
- /** Path relative to cwd — what a log line references. */
37
- readonly rel: string;
38
- /** Resolves once the file is flushed to disk. */
39
- readonly written: Promise<void>;
40
- }
41
- /**
42
- * Writes `value` as a sequenced JSON snapshot file (`<name>-NN.json`) in the run's
43
- * output folder and returns its path — **without** logging a pointer line. The
44
- * path is computed synchronously (so callers can reference it in the same tick)
45
- * while the write proceeds in the background. Use {@link snapshot} when you also
46
- * want a `[snapshot]` line in the timeline; the event bus uses this directly to
47
- * link a non-string payload from its own line.
48
- */
49
- export declare const captureSnapshot: (name: string, value: unknown) => SnapshotRef;
50
- /**
51
- * Writes raw text `lines` as a sequenced `.log` snapshot file in the run's output
52
- * folder and returns its path refs — the text sibling of {@link captureSnapshot}
53
- * (which writes JSON). {@link writeLog} uses it to off-load a collapsed output
54
- * run from `run.log`, keeping the burst's full text one click away.
55
- */
56
- export declare const captureText: (name: string, lines: readonly string[]) => SnapshotRef;
57
- /**
58
- * Captures any value as a JSON snapshot file (via {@link captureSnapshot}) and
59
- * records a `[snapshot] <name>: <path>` line in the timeline at the point it's
60
- * taken. Files are sequenced so repeated snapshots of the same name are kept as a
61
- * history rather than overwritten. Callable any time, anywhere in the run;
62
- * returns the file path once written.
63
- */
64
- export declare const snapshot: (name: string, value: unknown) => Promise<string>;
@@ -1,190 +0,0 @@
1
- import { createWriteStream, mkdirSync } 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
- let ensuredSync = false;
28
- /**
29
- * Absolute path to this run's output folder — `logs/<name>/<date>/<time>/`, where
30
- * `run.log`, `run.jsonl`, and snapshots live. Exposed so actions can drop their
31
- * own **artifacts** into the same per-run folder (one folder per run; one run per
32
- * process). The folder is created on first access — synchronously, so a caller
33
- * can write into it in the same tick — and the path is absolute so it's safe to
34
- * hand to a child process with a different cwd.
35
- */
36
- export const artifactsFolder = () => {
37
- const abs = path.resolve(runDir());
38
- if (!ensuredSync) {
39
- mkdirSync(abs, { recursive: true });
40
- ensuredSync = true;
41
- }
42
- return abs;
43
- };
44
- /** A compact header block for the top of run.log — legend + run identity. */
45
- const logHeader = () => {
46
- const now = new Date();
47
- const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
48
- const time = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
49
- return [
50
- `─── ${pipelineName()} · ${date} ${time} ───`,
51
- `● run ⚡ event ▸ action 📎 snapshot ⇥ output`,
52
- "",
53
- ].join("\n");
54
- };
55
- /**
56
- * Drains the collected log to two files in the run's output folder (the common
57
- * `logs/<name>/<date>/<time>/` convention, local time):
58
- *
59
- * - `run.log` — the pretty, human-scannable rendering: just the message lines.
60
- * - `run.jsonl` — the structured, machine-queryable record: one full {@link LogEntry}
61
- * per line (sequence, timestamp, level, source, message), so an agent can filter
62
- * and correlate by origin/level/time instead of parsing prose.
63
- *
64
- * Returns the `run.log` path (the one surfaced to the terminal).
65
- */
66
- export const writeLog = async (logs) => {
67
- await ensureDir();
68
- const entries = logs.snapshot();
69
- // Apply grouping/indentation at write time from the raw entries, so the stored
70
- // messages stay presentation-free. Unlike the live `drain`, the final file
71
- // collapses large same-source runs: each is off-loaded to a text snapshot and
72
- // replaced inline by its head plus a pointer, so one proc's burst can't drown
73
- // the timeline. `run.jsonl` below keeps every line, raw.
74
- const file = path.join(runDir(), "run.log");
75
- const header = logHeader();
76
- const writes = [];
77
- const parts = segmentForFile(entries).map((item) => {
78
- if (item.kind === "line")
79
- return item.text;
80
- const snap = captureText(`output-${item.run.source}`, item.run.lines);
81
- writes.push(snap.written);
82
- return renderCollapsed(item.run, { head: COLLAPSE_HEAD, rel: snap.rel });
83
- });
84
- const pretty = parts.join("\n");
85
- await writeFile(file, pretty ? `${header}${pretty}\n` : "", "utf8");
86
- // Ensure every off-loaded run is flushed before we report the log is written.
87
- await Promise.all(writes);
88
- const jsonlFile = path.join(runDir(), "run.jsonl");
89
- const jsonl = entries.map((entry) => JSON.stringify(entry)).join("\n");
90
- await writeFile(jsonlFile, jsonl ? `${jsonl}\n` : "", "utf8");
91
- return file;
92
- };
93
- /**
94
- * Streams log entries to `run.jsonl` and `run.log` incrementally as they arrive,
95
- * so output survives a hard kill or lost SIGINT. Each entry is appended via a
96
- * write stream. The subscription ends on teardown (`logs.endOn`), after which
97
- * {@link writeLog} overwrites both files with the authoritative final snapshot
98
- * (including post-abort entries like "finished"). For dirty exits, whatever was
99
- * flushed to disk by the OS is the best available record.
100
- */
101
- export const streamLog = async (logs) => {
102
- await ensureDir();
103
- const jsonlFile = path.join(runDir(), "run.jsonl");
104
- const logFile = path.join(runDir(), "run.log");
105
- const jsonl = createWriteStream(jsonlFile);
106
- const pretty = createWriteStream(logFile);
107
- const render = createRenderer();
108
- pretty.write(logHeader() + "\n");
109
- try {
110
- for await (const entry of logs.subscribe({ replay: true })) {
111
- jsonl.write(JSON.stringify(entry) + "\n");
112
- pretty.write(render(entry) + "\n");
113
- }
114
- }
115
- finally {
116
- // End AND wait for both streams to fully close before resolving. {@link writeLog}
117
- // truncates and rewrites these same files at finalize; if a buffered chunk from
118
- // this stream landed *after* that truncation, the OS would zero-fill the gap —
119
- // a multi-MB run of NUL bytes in run.log. `run` awaits this promise before
120
- // calling `writeLog`, so this writer is guaranteed done before the rewrite.
121
- await Promise.all([streamClosed(jsonl), streamClosed(pretty)]);
122
- }
123
- };
124
- /** Resolve once a write stream has flushed and closed its file descriptor. */
125
- const streamClosed = (stream) => new Promise((resolve) => {
126
- stream.on("close", () => resolve());
127
- stream.on("error", () => resolve()); // a stream error still frees the file; don't hang
128
- stream.end();
129
- });
130
- let seq = 0;
131
- /** Serialize a value for a snapshot file, tolerating circular refs / non-JSON values. */
132
- const serialize = (value) => {
133
- try {
134
- // JSON.stringify returns undefined for functions/undefined — fall back to a string.
135
- return JSON.stringify(value, null, 2) ?? JSON.stringify(String(value));
136
- }
137
- catch {
138
- // Circular or otherwise non-serializable: keep a best-effort representation
139
- // rather than throwing and losing the value entirely.
140
- return JSON.stringify(String(value));
141
- }
142
- };
143
- /**
144
- * Writes `value` as a sequenced JSON snapshot file (`<name>-NN.json`) in the run's
145
- * output folder and returns its path — **without** logging a pointer line. The
146
- * path is computed synchronously (so callers can reference it in the same tick)
147
- * while the write proceeds in the background. Use {@link snapshot} when you also
148
- * want a `[snapshot]` line in the timeline; the event bus uses this directly to
149
- * link a non-string payload from its own line.
150
- */
151
- export const captureSnapshot = (name, value) => {
152
- const safe = name.replace(/[^\w.-]+/g, "-");
153
- const file = path.join(runDir(), `${safe}-${pad(++seq)}.json`);
154
- const written = ensureDir().then(() => writeFile(file, `${serialize(value)}\n`, "utf8"));
155
- // Normalize to forward slashes so the reference is portable in the log artifact.
156
- const rel = path.relative(process.cwd(), file).replaceAll("\\", "/");
157
- return { file, rel, written };
158
- };
159
- /**
160
- * Writes raw text `lines` as a sequenced `.log` snapshot file in the run's output
161
- * folder and returns its path refs — the text sibling of {@link captureSnapshot}
162
- * (which writes JSON). {@link writeLog} uses it to off-load a collapsed output
163
- * run from `run.log`, keeping the burst's full text one click away.
164
- */
165
- export const captureText = (name, lines) => {
166
- const safe = name.replace(/[^\w.-]+/g, "-");
167
- const file = path.join(runDir(), `${safe}-${pad(++seq)}.log`);
168
- const body = lines.length ? `${lines.join("\n")}\n` : "";
169
- const written = ensureDir().then(() => writeFile(file, body, "utf8"));
170
- // Normalize to forward slashes so the reference is portable in the log artifact.
171
- const rel = path.relative(process.cwd(), file).replaceAll("\\", "/");
172
- return { file, rel, written };
173
- };
174
- /**
175
- * Captures any value as a JSON snapshot file (via {@link captureSnapshot}) and
176
- * records a `[snapshot] <name>: <path>` line in the timeline at the point it's
177
- * taken. Files are sequenced so repeated snapshots of the same name are kept as a
178
- * history rather than overwritten. Callable any time, anywhere in the run;
179
- * returns the file path once written.
180
- */
181
- export const snapshot = (name, value) => {
182
- const { file, rel, written } = captureSnapshot(name, value);
183
- // Record the pointer synchronously, so it sits at the right spot in the timeline.
184
- log.append({
185
- source: "snapshot",
186
- level: "snapshot",
187
- message: `${name}: ${rel}`,
188
- });
189
- return written.then(() => file);
190
- };
@@ -1,88 +0,0 @@
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 spin's verdict. Cascade failures —
5
- * actions that settled `{ ok: false }` only because teardown aborted them — are
6
- * deliberately excluded (see `launch`'s outcome handling), so this records
7
- * genuine faults, not fallout.
8
- */
9
- export interface ActionFailure {
10
- /** The failing action's name. */
11
- readonly action: string;
12
- /** Whatever it failed with (the Outcome's `error`). */
13
- readonly error: unknown;
14
- }
15
- /**
16
- * The terminal verdict of a spin: did everything that mattered succeed, and if
17
- * not, what failed. {@link Spin.done} resolves (never rejects) with this, so a
18
- * single `await` yields the outcome instead of forcing a try/catch on awaiters.
19
- * A failing action is *recorded* here (so `ok` is false and the exit code is 1)
20
- * but does **not** tear the spin down — see {@link Spin}.
21
- */
22
- export interface SpinResult {
23
- /** True when no genuine failure was recorded. */
24
- readonly ok: boolean;
25
- /** The genuine failures, in the order they surfaced. Empty when `ok`. */
26
- readonly failures: readonly ActionFailure[];
27
- }
28
- /**
29
- * The spin's lifecycle. It only ever moves forward:
30
- *
31
- * open ──▶ closing ──▶ closed
32
- *
33
- * - **open** — launching and running actions; `.run(…)` is accepted.
34
- * - **closing** — teardown has begun (cancel, Ctrl-C, natural completion, or an
35
- * uncaught fatal). The signal is aborted, in-flight actions are unwinding, and
36
- * new `.run(…)` is refused. Note: a failing *action* does not begin teardown.
37
- * - **closed** — everything has settled, the log is flushed, the verdict is final.
38
- */
39
- export type SpinState = "open" | "closing" | "closed";
40
- /** A live spin: a set of actions sharing one abort controller and the global log. */
41
- export interface Spin {
42
- /** The shared global log (output + events for everything in this spin). */
43
- readonly logs: typeof logs;
44
- /** The current lifecycle state — see {@link SpinState}. */
45
- readonly state: SpinState;
46
- /** Settles once the spin reaches `closed`, carrying the terminal {@link SpinResult}. */
47
- readonly done: Promise<SpinResult>;
48
- /** The spin's abort signal — for consumers that want to observe teardown. */
49
- readonly signal: AbortSignal;
50
- /**
51
- * Launch more actions into this same spin — same controller, same log. Chainable.
52
- *
53
- * Throws if the spin is no longer `open`: launching into a closing/closed spin
54
- * would bind the actions to an already-aborted signal, so they'd start and
55
- * immediately unwind, silently doing nothing. Launch before teardown, or from
56
- * a lifecycle event of an action that is still running (those fire while the
57
- * spin is still `open`).
58
- */
59
- run(...actions: AnyActionInstance[]): Spin;
60
- /** Begin teardown: abort the signal (killing procs, unblocking waits, stopping daemons). */
61
- cancel(): void;
62
- /**
63
- * Start streaming the global log live to a writer (console by default) in the
64
- * background; it stops on its own when the spin settles. Returns the spin, so it
65
- * chains. Await `.done` for the verdict.
66
- */
67
- drain(write?: (message: string) => void): Spin;
68
- }
69
- /**
70
- * Starts the spin: launches the given actions in parallel under one abort
71
- * controller, against the process-wide global log. Returns a {@link Spin} — add
72
- * more actions on demand with `.run(…)`, observe `.done` for the verdict, stop
73
- * with `.cancel()`.
74
- *
75
- * **Callable once per process.** A spin *is* the world being modelled, and there's
76
- * one global log per world; a second call would silently share that log, so it
77
- * throws instead. Attach further actions — including event-driven ones — to the
78
- * returned {@link Spin} via its `.run(…)` method, not by calling this again.
79
- *
80
- * Each action runs against its own child logger (which headers and indents its
81
- * lines into the global log) and the spin's shared abort signal — both injected
82
- * into its context, where the framework also binds a `proc` to them so child
83
- * processes stream into the right log and die on teardown. A **failing action
84
- * never tears the spin down** — its `{ ok: false }` outcome is recorded in the
85
- * verdict (and sets exit code 1) and the spin keeps running. Teardown to `closed`
86
- * happens only on Ctrl-C, `.cancel()`, natural completion, or an uncaught fatal.
87
- */
88
- export declare function run(...actions: AnyActionInstance[]): Spin;