omkit 0.0.9 → 0.0.11

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 +255 -0
  21. package/dist/{core/action.d.ts → orchestration/action/types.d.ts} +38 -27
  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 +14 -0
  40. package/dist/{core/log-collector → output/log}/LogsCollector.js +24 -1
  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 -201
  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 -68
  62. package/dist/core/log-collector/render.js +0 -132
  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 -68
  66. package/dist/core/output.js +0 -213
  67. package/dist/core/run.d.ts +0 -88
  68. package/dist/core/run.js +0 -257
  69. package/dist/core/spin.d.ts +0 -72
  70. package/dist/core/spin.js +0 -45
  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,68 +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
- * A stateful, **streaming** segmenter: `push` each {@link LogEntry} in order and
42
- * it returns the {@link DisplayItem}s that just became final (0, 1, or a whole
43
- * flushed run), then `end()` flushes the last pending run. Only the current
44
- * same-source run is held in memory, so segmenting a huge log stays bounded —
45
- * {@link writeLog} feeds it straight from `run.jsonl` on disk. Output is
46
- * byte-identical to {@link segmentForFile}. Pure — no filesystem.
47
- */
48
- export declare const createFileSegmenter: ({ threshold }?: SegmentOptions) => {
49
- push(entry: LogEntry): DisplayItem[];
50
- end(): DisplayItem[];
51
- };
52
- /**
53
- * Segments a full, ordered entry list into {@link DisplayItem}s (the array form of
54
- * {@link createFileSegmenter}). Milestones and runs no longer than `threshold`
55
- * become `line` items byte-identical to {@link createRenderer}; a run longer than
56
- * `threshold` becomes a single `collapsed` item carrying every line of the run.
57
- */
58
- export declare const segmentForFile: (entries: readonly LogEntry[], options?: SegmentOptions) => DisplayItem[];
59
- /**
60
- * Renders a {@link CollapsedRun} as its `head` lines under the `▸ <source>`
61
- * header, then a single indented `📎` pointer line to the off-loaded snapshot at
62
- * `rel` reporting how many lines were hidden. Pure — `rel` is supplied by the
63
- * caller after it writes the file.
64
- */
65
- export declare const renderCollapsed: (run: CollapsedRun, { head, rel }: {
66
- head: number;
67
- rel: string;
68
- }) => string;
@@ -1,132 +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
- * A stateful, **streaming** segmenter: `push` each {@link LogEntry} in order and
53
- * it returns the {@link DisplayItem}s that just became final (0, 1, or a whole
54
- * flushed run), then `end()` flushes the last pending run. Only the current
55
- * same-source run is held in memory, so segmenting a huge log stays bounded —
56
- * {@link writeLog} feeds it straight from `run.jsonl` on disk. Output is
57
- * byte-identical to {@link segmentForFile}. Pure — no filesystem.
58
- */
59
- export const createFileSegmenter = ({ threshold = COLLAPSE_THRESHOLD } = {}) => {
60
- // The run currently being accumulated: a same-source stretch of output lines.
61
- let run;
62
- // Flush the pending run into `out`: collapse it if it ran long, else emit
63
- // grouped lines exactly as createRenderer would (header first, indent the rest).
64
- const flush = (out) => {
65
- if (!run)
66
- return;
67
- if (run.lines.length > threshold) {
68
- out.push({ kind: "collapsed", run: { source: run.source, lines: run.lines } });
69
- }
70
- else {
71
- run.lines.forEach((message, i) => {
72
- out.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
- return {
81
- push(entry) {
82
- const out = [];
83
- const milestone = MILESTONE[entry.level];
84
- if (milestone) {
85
- // A milestone breaks the current run, then renders as its own line.
86
- flush(out);
87
- out.push({ kind: "line", text: marker(milestone, entry.message) });
88
- return out;
89
- }
90
- if (run && run.source === entry.source) {
91
- run.lines.push(entry.message);
92
- }
93
- else {
94
- flush(out);
95
- run = { source: entry.source, lines: [entry.message] };
96
- }
97
- return out;
98
- },
99
- end() {
100
- const out = [];
101
- flush(out);
102
- return out;
103
- },
104
- };
105
- };
106
- /**
107
- * Segments a full, ordered entry list into {@link DisplayItem}s (the array form of
108
- * {@link createFileSegmenter}). Milestones and runs no longer than `threshold`
109
- * become `line` items byte-identical to {@link createRenderer}; a run longer than
110
- * `threshold` becomes a single `collapsed` item carrying every line of the run.
111
- */
112
- export const segmentForFile = (entries, options = {}) => {
113
- const seg = createFileSegmenter(options);
114
- const items = [];
115
- for (const entry of entries)
116
- items.push(...seg.push(entry));
117
- items.push(...seg.end());
118
- return items;
119
- };
120
- /**
121
- * Renders a {@link CollapsedRun} as its `head` lines under the `▸ <source>`
122
- * header, then a single indented `📎` pointer line to the off-loaded snapshot at
123
- * `rel` reporting how many lines were hidden. Pure — `rel` is supplied by the
124
- * caller after it writes the file.
125
- */
126
- export const renderCollapsed = (run, { head, rel }) => {
127
- const shown = run.lines.slice(0, head);
128
- const lines = shown.map((message, i) => i === 0 ? `${marker("action", run.source)}\n\t${message}` : `\t${message}`);
129
- const hidden = run.lines.length - shown.length;
130
- lines.push(`\t${marker("snapshot", `… +${hidden} more lines → ${rel}`)}`);
131
- return lines.join("\n");
132
- };
@@ -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,68 +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
- * Renders the final, collapsed `run.log` from the authoritative `run.jsonl` (the
13
- * complete record {@link streamLog} wrote), **streaming** it line by line so the
14
- * whole run is never held in memory. Grouping/indentation is applied here so the
15
- * stored `run.jsonl` stays presentation-free; large same-source runs are collapsed
16
- * — off-loaded to a text snapshot and replaced inline by their head plus a pointer
17
- * — so one proc's burst can't drown the timeline. `run.jsonl` is left as-is (it's
18
- * the full raw record). Returns the `run.log` path (surfaced to the terminal).
19
- *
20
- * Call only after {@link streamLog} has closed (the run's log is ended), so
21
- * `run.jsonl` is complete and no longer being written.
22
- */
23
- export declare const writeLog: () => Promise<string>;
24
- /**
25
- * Streams every log entry to `run.jsonl` (the complete, authoritative record) and
26
- * a live `run.log` incrementally as they arrive, so output survives a hard kill or
27
- * lost SIGINT. The log is closed once at finalize, after which {@link writeLog}
28
- * re-renders `run.log` from `run.jsonl` in collapsed form.
29
- *
30
- * Subscribes **synchronously, before any `await`** — so a fast synchronous
31
- * producer that appends (and trims the in-memory history) before the file setup
32
- * finishes still has its entries buffered on the subscription queue rather than
33
- * lost. `replay: true` covers anything already logged when this is called.
34
- */
35
- export declare const streamLog: (logs: LogsCollector) => Promise<void>;
36
- /** A written (or in-flight) snapshot file and where to find it. */
37
- export interface SnapshotRef {
38
- /** Absolute path to the snapshot file. */
39
- readonly file: string;
40
- /** Path relative to cwd — what a log line references. */
41
- readonly rel: string;
42
- /** Resolves once the file is flushed to disk. */
43
- readonly written: Promise<void>;
44
- }
45
- /**
46
- * Writes `value` as a sequenced JSON snapshot file (`<name>-NN.json`) in the run's
47
- * output folder and returns its path — **without** logging a pointer line. The
48
- * path is computed synchronously (so callers can reference it in the same tick)
49
- * while the write proceeds in the background. Use {@link snapshot} when you also
50
- * want a `[snapshot]` line in the timeline; the event bus uses this directly to
51
- * link a non-string payload from its own line.
52
- */
53
- export declare const captureSnapshot: (name: string, value: unknown) => SnapshotRef;
54
- /**
55
- * Writes raw text `lines` as a sequenced `.log` snapshot file in the run's output
56
- * folder and returns its path refs — the text sibling of {@link captureSnapshot}
57
- * (which writes JSON). {@link writeLog} uses it to off-load a collapsed output
58
- * run from `run.log`, keeping the burst's full text one click away.
59
- */
60
- export declare const captureText: (name: string, lines: readonly string[]) => SnapshotRef;
61
- /**
62
- * Captures any value as a JSON snapshot file (via {@link captureSnapshot}) and
63
- * records a `[snapshot] <name>: <path>` line in the timeline at the point it's
64
- * taken. Files are sequenced so repeated snapshots of the same name are kept as a
65
- * history rather than overwritten. Callable any time, anywhere in the run;
66
- * returns the file path once written.
67
- */
68
- export declare const snapshot: (name: string, value: unknown) => Promise<string>;
@@ -1,213 +0,0 @@
1
- import { createReadStream, createWriteStream, mkdirSync } from "node:fs";
2
- import { mkdir, writeFile } from "node:fs/promises";
3
- import { createInterface } from "node:readline";
4
- import path from "node:path";
5
- import { log } from "./log-collector/global.js";
6
- import { COLLAPSE_HEAD, createFileSegmenter, createRenderer, renderCollapsed, } from "./log-collector/render.js";
7
- const pad = (n, width = 2) => String(n).padStart(width, "0");
8
- /** The entry script's base name (the run's name), e.g. "tskb-build". */
9
- const pipelineName = () => {
10
- const entry = process.argv[1];
11
- return entry ? path.basename(entry, path.extname(entry)) : "pipeline";
12
- };
13
- // One output directory per run (one run per process): logs/<name>/<date>/<time>/.
14
- // Computed once, lazily — so the log file and every snapshot land together, even
15
- // when a snapshot is taken mid-run before the log is flushed.
16
- let dir;
17
- const runDir = () => {
18
- if (dir)
19
- return dir;
20
- const now = new Date();
21
- const day = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
22
- const time = `${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
23
- dir = path.join("logs", pipelineName(), day, time);
24
- return dir;
25
- };
26
- let made;
27
- const ensureDir = () => (made ??= mkdir(runDir(), { recursive: true }).then(() => { }));
28
- let ensuredSync = false;
29
- /**
30
- * Absolute path to this run's output folder — `logs/<name>/<date>/<time>/`, where
31
- * `run.log`, `run.jsonl`, and snapshots live. Exposed so actions can drop their
32
- * own **artifacts** into the same per-run folder (one folder per run; one run per
33
- * process). The folder is created on first access — synchronously, so a caller
34
- * can write into it in the same tick — and the path is absolute so it's safe to
35
- * hand to a child process with a different cwd.
36
- */
37
- export const artifactsFolder = () => {
38
- const abs = path.resolve(runDir());
39
- if (!ensuredSync) {
40
- mkdirSync(abs, { recursive: true });
41
- ensuredSync = true;
42
- }
43
- return abs;
44
- };
45
- /** A compact header block for the top of run.log — legend + run identity. */
46
- const logHeader = () => {
47
- const now = new Date();
48
- const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
49
- const time = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
50
- return [
51
- `─── ${pipelineName()} · ${date} ${time} ───`,
52
- `● run ⚡ event ▸ action 📎 snapshot ⇥ output`,
53
- "",
54
- ].join("\n");
55
- };
56
- /**
57
- * Renders the final, collapsed `run.log` from the authoritative `run.jsonl` (the
58
- * complete record {@link streamLog} wrote), **streaming** it line by line so the
59
- * whole run is never held in memory. Grouping/indentation is applied here so the
60
- * stored `run.jsonl` stays presentation-free; large same-source runs are collapsed
61
- * — off-loaded to a text snapshot and replaced inline by their head plus a pointer
62
- * — so one proc's burst can't drown the timeline. `run.jsonl` is left as-is (it's
63
- * the full raw record). Returns the `run.log` path (surfaced to the terminal).
64
- *
65
- * Call only after {@link streamLog} has closed (the run's log is ended), so
66
- * `run.jsonl` is complete and no longer being written.
67
- */
68
- export const writeLog = async () => {
69
- await ensureDir();
70
- const jsonlFile = path.join(runDir(), "run.jsonl");
71
- const file = path.join(runDir(), "run.log");
72
- const out = createWriteStream(file);
73
- out.write(logHeader());
74
- const seg = createFileSegmenter();
75
- const writes = [];
76
- const emit = (item) => {
77
- if (item.kind === "line") {
78
- out.write(item.text + "\n");
79
- }
80
- else {
81
- const snap = captureText(`output-${item.run.source}`, item.run.lines);
82
- writes.push(snap.written);
83
- out.write(renderCollapsed(item.run, { head: COLLAPSE_HEAD, rel: snap.rel }) + "\n");
84
- }
85
- };
86
- try {
87
- const rl = createInterface({ input: createReadStream(jsonlFile), crlfDelay: Infinity });
88
- for await (const line of rl) {
89
- if (!line)
90
- continue;
91
- let entry;
92
- try {
93
- entry = JSON.parse(line);
94
- }
95
- catch {
96
- continue; // a torn final line (dirty exit) — skip it
97
- }
98
- for (const item of seg.push(entry))
99
- emit(item);
100
- }
101
- }
102
- catch {
103
- // run.jsonl missing/unreadable (e.g. a very early crash) — write what we have.
104
- }
105
- for (const item of seg.end())
106
- emit(item);
107
- await streamClosed(out); // flush + close run.log before reporting it written
108
- await Promise.all(writes); // ensure every off-loaded run is on disk
109
- return file;
110
- };
111
- /**
112
- * Streams every log entry to `run.jsonl` (the complete, authoritative record) and
113
- * a live `run.log` incrementally as they arrive, so output survives a hard kill or
114
- * lost SIGINT. The log is closed once at finalize, after which {@link writeLog}
115
- * re-renders `run.log` from `run.jsonl` in collapsed form.
116
- *
117
- * Subscribes **synchronously, before any `await`** — so a fast synchronous
118
- * producer that appends (and trims the in-memory history) before the file setup
119
- * finishes still has its entries buffered on the subscription queue rather than
120
- * lost. `replay: true` covers anything already logged when this is called.
121
- */
122
- export const streamLog = async (logs) => {
123
- const entries = logs.subscribe({ replay: true });
124
- await ensureDir();
125
- const jsonlFile = path.join(runDir(), "run.jsonl");
126
- const logFile = path.join(runDir(), "run.log");
127
- const jsonl = createWriteStream(jsonlFile);
128
- const pretty = createWriteStream(logFile);
129
- const render = createRenderer();
130
- pretty.write(logHeader() + "\n");
131
- try {
132
- for await (const entry of entries) {
133
- jsonl.write(JSON.stringify(entry) + "\n");
134
- pretty.write(render(entry) + "\n");
135
- }
136
- }
137
- finally {
138
- // End AND wait for both streams to fully close before resolving. {@link writeLog}
139
- // re-renders run.log at finalize (from this run.jsonl); if a buffered chunk from
140
- // this stream landed *after* writeLog truncated run.log, the OS would zero-fill
141
- // the gap — a multi-MB run of NUL bytes. `run` awaits this promise before
142
- // calling `writeLog`, so this writer is guaranteed done first (and run.jsonl is
143
- // complete for writeLog to read).
144
- await Promise.all([streamClosed(jsonl), streamClosed(pretty)]);
145
- }
146
- };
147
- /** Resolve once a write stream has flushed and closed its file descriptor. */
148
- const streamClosed = (stream) => new Promise((resolve) => {
149
- stream.on("close", () => resolve());
150
- stream.on("error", () => resolve()); // a stream error still frees the file; don't hang
151
- stream.end();
152
- });
153
- let seq = 0;
154
- /** Serialize a value for a snapshot file, tolerating circular refs / non-JSON values. */
155
- const serialize = (value) => {
156
- try {
157
- // JSON.stringify returns undefined for functions/undefined — fall back to a string.
158
- return JSON.stringify(value, null, 2) ?? JSON.stringify(String(value));
159
- }
160
- catch {
161
- // Circular or otherwise non-serializable: keep a best-effort representation
162
- // rather than throwing and losing the value entirely.
163
- return JSON.stringify(String(value));
164
- }
165
- };
166
- /**
167
- * Writes `value` as a sequenced JSON snapshot file (`<name>-NN.json`) in the run's
168
- * output folder and returns its path — **without** logging a pointer line. The
169
- * path is computed synchronously (so callers can reference it in the same tick)
170
- * while the write proceeds in the background. Use {@link snapshot} when you also
171
- * want a `[snapshot]` line in the timeline; the event bus uses this directly to
172
- * link a non-string payload from its own line.
173
- */
174
- export const captureSnapshot = (name, value) => {
175
- const safe = name.replace(/[^\w.-]+/g, "-");
176
- const file = path.join(runDir(), `${safe}-${pad(++seq)}.json`);
177
- const written = ensureDir().then(() => writeFile(file, `${serialize(value)}\n`, "utf8"));
178
- // Normalize to forward slashes so the reference is portable in the log artifact.
179
- const rel = path.relative(process.cwd(), file).replaceAll("\\", "/");
180
- return { file, rel, written };
181
- };
182
- /**
183
- * Writes raw text `lines` as a sequenced `.log` snapshot file in the run's output
184
- * folder and returns its path refs — the text sibling of {@link captureSnapshot}
185
- * (which writes JSON). {@link writeLog} uses it to off-load a collapsed output
186
- * run from `run.log`, keeping the burst's full text one click away.
187
- */
188
- export const captureText = (name, lines) => {
189
- const safe = name.replace(/[^\w.-]+/g, "-");
190
- const file = path.join(runDir(), `${safe}-${pad(++seq)}.log`);
191
- const body = lines.length ? `${lines.join("\n")}\n` : "";
192
- const written = ensureDir().then(() => writeFile(file, body, "utf8"));
193
- // Normalize to forward slashes so the reference is portable in the log artifact.
194
- const rel = path.relative(process.cwd(), file).replaceAll("\\", "/");
195
- return { file, rel, written };
196
- };
197
- /**
198
- * Captures any value as a JSON snapshot file (via {@link captureSnapshot}) and
199
- * records a `[snapshot] <name>: <path>` line in the timeline at the point it's
200
- * taken. Files are sequenced so repeated snapshots of the same name are kept as a
201
- * history rather than overwritten. Callable any time, anywhere in the run;
202
- * returns the file path once written.
203
- */
204
- export const snapshot = (name, value) => {
205
- const { file, rel, written } = captureSnapshot(name, value);
206
- // Record the pointer synchronously, so it sits at the right spot in the timeline.
207
- log.append({
208
- source: "snapshot",
209
- level: "snapshot",
210
- message: `${name}: ${rel}`,
211
- });
212
- return written.then(() => file);
213
- };