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
@@ -0,0 +1,27 @@
1
+ import { RunFolder } from "./folder/RunFolder.js";
2
+ import { SnapshotStore } from "./snapshot/SnapshotStore.js";
3
+ import { RunLog } from "./log/RunLog.js";
4
+ /**
5
+ * The run's output subsystem — the composition root that owns the per-run folder and
6
+ * the writers layered on it, and hands them to the rest of the process.
7
+ *
8
+ * Each run owns one of these: its {@link SpinHost} constructs an `Output` over the
9
+ * run's log and threads it to actions as `ctx.output`. Everything that was
10
+ * module-global in the old `output.ts` (the folder path, the mkdir latch, the
11
+ * snapshot sequence) now lives inside these three members, reachable as
12
+ * `output.folder`, `output.snapshots`, `output.runLog` — so callers reach for a named
13
+ * layer instead of a free function, and the shared state has one owner per run.
14
+ */
15
+ export class Output {
16
+ /** The per-run directory and artifact drop. */
17
+ folder;
18
+ /** Sequenced JSON/text snapshot files + `[snapshot]` timeline lines. */
19
+ snapshots;
20
+ /** The `run.jsonl` / `run.log` writer. */
21
+ runLog;
22
+ constructor(logger) {
23
+ this.folder = new RunFolder();
24
+ this.snapshots = new SnapshotStore(this.folder, logger);
25
+ this.runLog = new RunLog(this.folder, this.snapshots);
26
+ }
27
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Owns this process's single output directory — `logs/<name>/<date>/<time>/`, where
3
+ * `run.log`, `run.jsonl`, and snapshots all live together.
4
+ *
5
+ * One folder per run, one run per process: the path is computed **once, lazily**, so
6
+ * a snapshot taken mid-run lands in the same folder as the log that's flushed later.
7
+ * The {@link SnapshotStore} and {@link RunLog} take a shared instance rather than
8
+ * reaching for module globals, so "where does this run's output go" has one owner.
9
+ */
10
+ export declare class RunFolder {
11
+ private dir;
12
+ private made;
13
+ private ensuredSync;
14
+ /** The entry script's base name (the run's name), e.g. "tskb-build". */
15
+ name(): string;
16
+ /** The run's output directory, `logs/<name>/<date>/<time>/` — computed once, lazily. */
17
+ path(): string;
18
+ /** A path inside the run folder — `join(path(), name)`. */
19
+ resolve(name: string): string;
20
+ /** Create the folder, memoized — every caller awaits the same underlying mkdir. */
21
+ ensure(): Promise<void>;
22
+ /**
23
+ * Absolute path to the run folder, created **synchronously** so a caller can write
24
+ * into it in the same tick. Absolute so it's safe to hand to a child process with a
25
+ * different cwd. This is the artifact drop for actions that want to write beside
26
+ * `run.log` (one folder per run; one run per process).
27
+ */
28
+ artifacts(): string;
29
+ }
@@ -0,0 +1,56 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { mkdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { ymd, hms } from "../../utils/format.js";
5
+ /**
6
+ * Owns this process's single output directory — `logs/<name>/<date>/<time>/`, where
7
+ * `run.log`, `run.jsonl`, and snapshots all live together.
8
+ *
9
+ * One folder per run, one run per process: the path is computed **once, lazily**, so
10
+ * a snapshot taken mid-run lands in the same folder as the log that's flushed later.
11
+ * The {@link SnapshotStore} and {@link RunLog} take a shared instance rather than
12
+ * reaching for module globals, so "where does this run's output go" has one owner.
13
+ */
14
+ export class RunFolder {
15
+ // Computed once on first access and cached — every consumer sees the same folder.
16
+ dir;
17
+ // Memoized async mkdir; the promise is shared so concurrent callers await one op.
18
+ made;
19
+ // Whether the folder has been created synchronously (for `artifacts()`).
20
+ ensuredSync = false;
21
+ /** The entry script's base name (the run's name), e.g. "tskb-build". */
22
+ name() {
23
+ const entry = process.argv[1];
24
+ return entry ? path.basename(entry, path.extname(entry)) : "pipeline";
25
+ }
26
+ /** The run's output directory, `logs/<name>/<date>/<time>/` — computed once, lazily. */
27
+ path() {
28
+ if (this.dir)
29
+ return this.dir;
30
+ const now = new Date();
31
+ this.dir = path.join("logs", this.name(), ymd(now), hms(now, "-"));
32
+ return this.dir;
33
+ }
34
+ /** A path inside the run folder — `join(path(), name)`. */
35
+ resolve(name) {
36
+ return path.join(this.path(), name);
37
+ }
38
+ /** Create the folder, memoized — every caller awaits the same underlying mkdir. */
39
+ ensure() {
40
+ return (this.made ??= mkdir(this.path(), { recursive: true }).then(() => { }));
41
+ }
42
+ /**
43
+ * Absolute path to the run folder, created **synchronously** so a caller can write
44
+ * into it in the same tick. Absolute so it's safe to hand to a child process with a
45
+ * different cwd. This is the artifact drop for actions that want to write beside
46
+ * `run.log` (one folder per run; one run per process).
47
+ */
48
+ artifacts() {
49
+ const abs = path.resolve(this.path());
50
+ if (!this.ensuredSync) {
51
+ mkdirSync(abs, { recursive: true });
52
+ this.ensuredSync = true;
53
+ }
54
+ return abs;
55
+ }
56
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The output layer's barrel — the composition root ({@link Output}) and its member
3
+ * classes. There is **no module singleton** here: each run's {@link SpinHost} owns
4
+ * its own `Output`, and threads it to actions as `ctx.output` (and to the spin body
5
+ * as `ctx.snapshot` / `ctx.artifactsFolder`), so nothing binds to process-global
6
+ * state and runs stay isolated.
7
+ */
8
+ export { Output } from "./Output.ts";
9
+ export { RunFolder } from "./folder/RunFolder.ts";
10
+ export { type SnapshotRef, SnapshotStore } from "./snapshot/SnapshotStore.ts";
11
+ export { RunLog } from "./log/RunLog.ts";
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The output layer's barrel — the composition root ({@link Output}) and its member
3
+ * classes. There is **no module singleton** here: each run's {@link SpinHost} owns
4
+ * its own `Output`, and threads it to actions as `ctx.output` (and to the spin body
5
+ * as `ctx.snapshot` / `ctx.artifactsFolder`), so nothing binds to process-global
6
+ * state and runs stay isolated.
7
+ */
8
+ export { Output } from "./Output.js";
9
+ export { RunFolder } from "./folder/RunFolder.js";
10
+ export { SnapshotStore } from "./snapshot/SnapshotStore.js";
11
+ export { RunLog } from "./log/RunLog.js";
@@ -0,0 +1,73 @@
1
+ import type { LogEntry } from "./LogsCollector.ts";
2
+ /** The special "title" log lines that get a distinguishing prefix. */
3
+ export type MarkerKind = "event" | "action" | "run" | "snapshot";
4
+ /** A run set aside for collapsing: its source and every output line, in order. */
5
+ export interface CollapsedRun {
6
+ readonly source: string;
7
+ readonly lines: readonly string[];
8
+ }
9
+ /**
10
+ * One unit of batch-rendered output: either a ready-to-write text `line`
11
+ * (identical to {@link LogRenderer.render}'s output for that entry) or a
12
+ * `collapsed` run the caller must off-load and render via {@link LogRenderer.renderCollapsed}.
13
+ */
14
+ export type DisplayItem = {
15
+ kind: "line";
16
+ text: string;
17
+ } | {
18
+ kind: "collapsed";
19
+ run: CollapsedRun;
20
+ };
21
+ /**
22
+ * Turns raw {@link LogEntry} records into the pretty, grouped text form.
23
+ * Presentation lives here and nowhere else: the collector stores raw entries; the
24
+ * live drain and `run.log` writer each construct a renderer and feed it entries in
25
+ * order. Two modes over the same grouping rules — pick per instance, don't mix:
26
+ *
27
+ * - **streaming** — {@link render} returns one entry's line as it arrives (live
28
+ * drain, incremental `run.log`).
29
+ * - **batch** — {@link push}/{@link end} accumulate a same-source run and collapse
30
+ * it when it grows past the threshold, so one proc's burst can't drown the
31
+ * durable `run.log` (the caller off-loads the full run to a snapshot).
32
+ *
33
+ * Grouping is decided against the *single merged stream* (`lastSource`), not
34
+ * per-producer — so interleaved concurrent sources re-header correctly. A
35
+ * milestone line breaks the current group, so the next action line re-headers.
36
+ */
37
+ export declare class LogRenderer {
38
+ /** Lines of a collapsed run shown inline before the pointer. */
39
+ static readonly COLLAPSE_HEAD = 5;
40
+ /** Prefix a milestone/header line with its marker (`● run`, `▸ action`, …). */
41
+ static marker(kind: MarkerKind, text: string): string;
42
+ /**
43
+ * Renders a {@link CollapsedRun} as its `head` lines under the `▸ <source>`
44
+ * header, then a single indented `📎` pointer line to the off-loaded snapshot at
45
+ * `rel` reporting how many lines were hidden. Pure — `rel` is supplied by the
46
+ * caller after it writes the file.
47
+ */
48
+ static renderCollapsed(run: CollapsedRun, { rel, head }: {
49
+ rel: string;
50
+ head?: number;
51
+ }): string;
52
+ private lastSource;
53
+ private run;
54
+ private readonly threshold;
55
+ constructor(threshold?: number);
56
+ /**
57
+ * Streaming: render one entry to its pretty line. A milestone renders as its own
58
+ * marker line and breaks the group; other entries are grouped under an
59
+ * `▸ <source>` header (first line of a group) and indented (the rest).
60
+ */
61
+ render(entry: LogEntry): string;
62
+ /**
63
+ * Batch: push an entry in order and get back the {@link DisplayItem}s that just
64
+ * became final (0, 1, or a whole flushed run). Only the current same-source run
65
+ * is held in memory, so segmenting a huge log stays bounded — the `run.log`
66
+ * writer feeds this straight from `run.jsonl` on disk. Output is byte-identical
67
+ * to {@link render} for non-collapsed runs. Pure — no filesystem.
68
+ */
69
+ push(entry: LogEntry): DisplayItem[];
70
+ /** Batch: flush the last pending run (call once after the final {@link push}). */
71
+ end(): DisplayItem[];
72
+ private flush;
73
+ }
@@ -0,0 +1,131 @@
1
+ const PREFIX = {
2
+ event: "⚡",
3
+ action: "▸",
4
+ run: "●",
5
+ snapshot: "📎",
6
+ };
7
+ /**
8
+ * Levels that render as their own top-level milestone line — marker-prefixed and
9
+ * never grouped/indented. Everything else is action/proc output, which the
10
+ * renderer groups under an `▸ <source>` header and indents.
11
+ */
12
+ const MILESTONE = {
13
+ run: "run",
14
+ event: "event",
15
+ snapshot: "snapshot",
16
+ };
17
+ /** Only runs *longer than* this collapse, so a trigger always hides a meaningful amount. */
18
+ const COLLAPSE_THRESHOLD = 15;
19
+ /**
20
+ * Turns raw {@link LogEntry} records into the pretty, grouped text form.
21
+ * Presentation lives here and nowhere else: the collector stores raw entries; the
22
+ * live drain and `run.log` writer each construct a renderer and feed it entries in
23
+ * order. Two modes over the same grouping rules — pick per instance, don't mix:
24
+ *
25
+ * - **streaming** — {@link render} returns one entry's line as it arrives (live
26
+ * drain, incremental `run.log`).
27
+ * - **batch** — {@link push}/{@link end} accumulate a same-source run and collapse
28
+ * it when it grows past the threshold, so one proc's burst can't drown the
29
+ * durable `run.log` (the caller off-loads the full run to a snapshot).
30
+ *
31
+ * Grouping is decided against the *single merged stream* (`lastSource`), not
32
+ * per-producer — so interleaved concurrent sources re-header correctly. A
33
+ * milestone line breaks the current group, so the next action line re-headers.
34
+ */
35
+ export class LogRenderer {
36
+ /** Lines of a collapsed run shown inline before the pointer. */
37
+ static COLLAPSE_HEAD = 5;
38
+ /** Prefix a milestone/header line with its marker (`● run`, `▸ action`, …). */
39
+ static marker(kind, text) {
40
+ return `${PREFIX[kind]} ${text}`;
41
+ }
42
+ /**
43
+ * Renders a {@link CollapsedRun} as its `head` lines under the `▸ <source>`
44
+ * header, then a single indented `📎` pointer line to the off-loaded snapshot at
45
+ * `rel` reporting how many lines were hidden. Pure — `rel` is supplied by the
46
+ * caller after it writes the file.
47
+ */
48
+ static renderCollapsed(run, { rel, head = LogRenderer.COLLAPSE_HEAD }) {
49
+ const shown = run.lines.slice(0, head);
50
+ const lines = shown.map((message, i) => i === 0 ? `${LogRenderer.marker("action", run.source)}\n\t${message}` : `\t${message}`);
51
+ const hidden = run.lines.length - shown.length;
52
+ lines.push(`\t${LogRenderer.marker("snapshot", `… +${hidden} more lines → ${rel}`)}`);
53
+ return lines.join("\n");
54
+ }
55
+ // Streaming grouping state: the source of the group currently open.
56
+ lastSource;
57
+ // Batch state: the same-source run currently being accumulated.
58
+ run;
59
+ threshold;
60
+ constructor(threshold = COLLAPSE_THRESHOLD) {
61
+ this.threshold = threshold;
62
+ }
63
+ /**
64
+ * Streaming: render one entry to its pretty line. A milestone renders as its own
65
+ * marker line and breaks the group; other entries are grouped under an
66
+ * `▸ <source>` header (first line of a group) and indented (the rest).
67
+ */
68
+ render(entry) {
69
+ const milestone = MILESTONE[entry.level];
70
+ if (milestone) {
71
+ // Reset grouping: after an interjection, the next action line re-headers.
72
+ this.lastSource = undefined;
73
+ return LogRenderer.marker(milestone, entry.message);
74
+ }
75
+ if (entry.source !== this.lastSource) {
76
+ this.lastSource = entry.source;
77
+ return `${LogRenderer.marker("action", entry.source)}\n\t${entry.message}`;
78
+ }
79
+ return `\t${entry.message}`;
80
+ }
81
+ /**
82
+ * Batch: push an entry in order and get back the {@link DisplayItem}s that just
83
+ * became final (0, 1, or a whole flushed run). Only the current same-source run
84
+ * is held in memory, so segmenting a huge log stays bounded — the `run.log`
85
+ * writer feeds this straight from `run.jsonl` on disk. Output is byte-identical
86
+ * to {@link render} for non-collapsed runs. Pure — no filesystem.
87
+ */
88
+ push(entry) {
89
+ const out = [];
90
+ const milestone = MILESTONE[entry.level];
91
+ if (milestone) {
92
+ // A milestone breaks the current run, then renders as its own line.
93
+ this.flush(out);
94
+ out.push({ kind: "line", text: LogRenderer.marker(milestone, entry.message) });
95
+ return out;
96
+ }
97
+ if (this.run && this.run.source === entry.source) {
98
+ this.run.lines.push(entry.message);
99
+ }
100
+ else {
101
+ this.flush(out);
102
+ this.run = { source: entry.source, lines: [entry.message] };
103
+ }
104
+ return out;
105
+ }
106
+ /** Batch: flush the last pending run (call once after the final {@link push}). */
107
+ end() {
108
+ const out = [];
109
+ this.flush(out);
110
+ return out;
111
+ }
112
+ // Flush the pending run into `out`: collapse it if it ran long, else emit grouped
113
+ // lines exactly as `render` would (header first, indent the rest).
114
+ flush(out) {
115
+ const run = this.run;
116
+ if (!run)
117
+ return;
118
+ if (run.lines.length > this.threshold) {
119
+ out.push({ kind: "collapsed", run: { source: run.source, lines: run.lines } });
120
+ }
121
+ else {
122
+ run.lines.forEach((message, i) => {
123
+ out.push({
124
+ kind: "line",
125
+ text: i === 0 ? `${LogRenderer.marker("action", run.source)}\n\t${message}` : `\t${message}`,
126
+ });
127
+ });
128
+ }
129
+ this.run = undefined;
130
+ }
131
+ }
@@ -38,6 +38,8 @@ export interface Logger {
38
38
  export declare class LogsCollector implements Logger {
39
39
  private sequence;
40
40
  private readonly history;
41
+ private readonly maxHistory;
42
+ private readonly trimHistoryTo;
41
43
  private readonly subscribers;
42
44
  private ended;
43
45
  /**
@@ -50,14 +52,28 @@ export declare class LogsCollector implements Logger {
50
52
  /** Raw store: records the line verbatim, source included; presentation is the renderer's job. */
51
53
  append(entry: LogInput): void;
52
54
  /**
53
- * Bind the run's abort signal: when it aborts (teardown), every subscription
54
- * ends — each parked or future `for await` returns — so subscribers don't each
55
- * have to watch the signal. The collector *is* the world's log and the signal
56
- * is the world ending; the log closes with it.
55
+ * End the log: every subscription returns and no new one is accepted. Called
56
+ * once at run finalize — *after* the last entry (e.g. `finished`) is appended —
57
+ * so `streamLog` captures the whole run before its stream closes. Kept separate
58
+ * from teardown so entries logged *during* teardown still stream/drain live.
57
59
  */
58
- endOn(signal: AbortSignal): void;
60
+ close(): void;
59
61
  /** EOF for readers: end every live subscription and refuse to register new ones. */
60
62
  private closeAll;
61
63
  subscribe({ replay }?: SubscribeOptions): AsyncIterable<LogEntry>;
62
64
  snapshot(): readonly LogEntry[];
63
65
  }
66
+ /**
67
+ * A {@link Logger} decorator that prefixes every entry's `source` with a fixed path
68
+ * (` › `-joined), forwarding to a base logger. Handed to a **child** action (and its
69
+ * `proc`) so all of its output is attributed to its ancestry — `parent › child ›
70
+ * <source>` — while the underlying store stays the run's one log.
71
+ */
72
+ export declare class ScopedLogger implements Logger {
73
+ private readonly base;
74
+ private readonly prefix;
75
+ constructor(base: Logger, path: string);
76
+ attach(stream: AsyncIterable<unknown>, source: string, level: string): void;
77
+ append(entry: LogInput): void;
78
+ subscribe(options?: SubscribeOptions): AsyncIterable<LogEntry>;
79
+ }
@@ -1,7 +1,14 @@
1
- import { AsyncQueue } from "../helpers/AsyncQueue.js";
1
+ import { AsyncQueue } from "../../utils/AsyncQueue.js";
2
2
  export class LogsCollector {
3
3
  sequence = 0;
4
+ // Recent entries retained in memory, only to replay to late subscribers. The
5
+ // *complete* record is streamed to run.jsonl on disk, so this is capped — an
6
+ // indefinite run (dev servers, tailing big logs) must not grow without bound.
4
7
  history = [];
8
+ // Cap and the size we trim back to when it's exceeded (trimming in batches keeps
9
+ // append amortized O(1) instead of O(n) per line once full).
10
+ maxHistory = 100_000;
11
+ trimHistoryTo = 90_000;
5
12
  subscribers = new Set();
6
13
  // Set once the bound signal aborts: the log has reached EOF for readers.
7
14
  ended = false;
@@ -24,22 +31,23 @@ export class LogsCollector {
24
31
  timestamp: Date.now(),
25
32
  };
26
33
  this.history.push(log);
34
+ // Cap the in-memory history — the durable copy is already on disk (run.jsonl).
35
+ if (this.history.length > this.maxHistory) {
36
+ this.history.splice(0, this.history.length - this.trimHistoryTo);
37
+ }
27
38
  for (const subscriber of this.subscribers) {
28
39
  // push the log into the queue for every subscriber to receive
29
40
  subscriber.push(log);
30
41
  }
31
42
  }
32
43
  /**
33
- * Bind the run's abort signal: when it aborts (teardown), every subscription
34
- * ends — each parked or future `for await` returns — so subscribers don't each
35
- * have to watch the signal. The collector *is* the world's log and the signal
36
- * is the world ending; the log closes with it.
44
+ * End the log: every subscription returns and no new one is accepted. Called
45
+ * once at run finalize — *after* the last entry (e.g. `finished`) is appended —
46
+ * so `streamLog` captures the whole run before its stream closes. Kept separate
47
+ * from teardown so entries logged *during* teardown still stream/drain live.
37
48
  */
38
- endOn(signal) {
39
- if (signal.aborted)
40
- this.closeAll();
41
- else
42
- signal.addEventListener("abort", () => this.closeAll(), { once: true });
49
+ close() {
50
+ this.closeAll();
43
51
  }
44
52
  /** EOF for readers: end every live subscription and refuse to register new ones. */
45
53
  closeAll() {
@@ -87,6 +95,29 @@ export class LogsCollector {
87
95
  return this.history;
88
96
  }
89
97
  }
98
+ /**
99
+ * A {@link Logger} decorator that prefixes every entry's `source` with a fixed path
100
+ * (` › `-joined), forwarding to a base logger. Handed to a **child** action (and its
101
+ * `proc`) so all of its output is attributed to its ancestry — `parent › child ›
102
+ * <source>` — while the underlying store stays the run's one log.
103
+ */
104
+ export class ScopedLogger {
105
+ base;
106
+ prefix;
107
+ constructor(base, path) {
108
+ this.base = base;
109
+ this.prefix = `${path} › `;
110
+ }
111
+ attach(stream, source, level) {
112
+ this.base.attach(stream, this.prefix + source, level);
113
+ }
114
+ append(entry) {
115
+ this.base.append({ ...entry, source: this.prefix + entry.source });
116
+ }
117
+ subscribe(options) {
118
+ return this.base.subscribe(options);
119
+ }
120
+ }
90
121
  /**
91
122
  * Splits a byte/string stream into lines, calling `onLine` for each. Partial
92
123
  * lines are buffered across chunks and a non-empty trailing fragment is flushed
@@ -0,0 +1,44 @@
1
+ import type { LogsCollector } from "./LogsCollector.ts";
2
+ import type { RunFolder } from "../folder/RunFolder.ts";
3
+ import type { SnapshotStore } from "../snapshot/SnapshotStore.ts";
4
+ /**
5
+ * Writes the run's two log files: `run.jsonl` (the complete, authoritative record,
6
+ * streamed live so it survives a hard kill) and `run.log` (the human timeline).
7
+ *
8
+ * Two phases: {@link stream} runs for the whole run, appending each entry to both
9
+ * files as it arrives; {@link write} runs once at finalize, re-rendering `run.log`
10
+ * from the finished `run.jsonl` in collapsed form. Depends on a {@link RunFolder}
11
+ * (where the files land) and a {@link SnapshotStore} (to off-load collapsed runs).
12
+ */
13
+ export declare class RunLog {
14
+ private readonly folder;
15
+ private readonly snapshots;
16
+ constructor(folder: RunFolder, snapshots: SnapshotStore);
17
+ /**
18
+ * Streams every log entry to `run.jsonl` (the complete, authoritative record) and a
19
+ * live `run.log` incrementally as they arrive, so output survives a hard kill or lost
20
+ * SIGINT. The log is closed once at finalize, after which {@link write} re-renders
21
+ * `run.log` from `run.jsonl` in collapsed form.
22
+ *
23
+ * Subscribes **synchronously, before any `await`** — so a fast synchronous producer
24
+ * that appends (and trims the in-memory history) before the file setup finishes still
25
+ * has its entries buffered on the subscription queue rather than lost. `replay: true`
26
+ * covers anything already logged when this is called.
27
+ */
28
+ stream(logs: LogsCollector): Promise<void>;
29
+ /**
30
+ * Renders the final, collapsed `run.log` from the authoritative `run.jsonl` (the
31
+ * complete record {@link stream} wrote), **streaming** it line by line so the whole
32
+ * run is never held in memory. Grouping/indentation is applied here so the stored
33
+ * `run.jsonl` stays presentation-free; large same-source runs are collapsed —
34
+ * off-loaded to a text snapshot and replaced inline by their head plus a pointer — so
35
+ * one proc's burst can't drown the timeline. `run.jsonl` is left as-is. Returns the
36
+ * `run.log` path (surfaced to the terminal).
37
+ *
38
+ * Call only after {@link stream} has closed (the run's log is ended), so `run.jsonl`
39
+ * is complete and no longer being written.
40
+ */
41
+ write(): Promise<string>;
42
+ /** A compact header block for the top of run.log — legend + run identity. */
43
+ private header;
44
+ }
@@ -0,0 +1,119 @@
1
+ import { createReadStream, createWriteStream } from "node:fs";
2
+ import { createInterface } from "node:readline";
3
+ import { LogRenderer } from "./LogRenderer.js";
4
+ import { closeStream } from "../../system/fs/streams.js";
5
+ import { ymd, hms } from "../../utils/format.js";
6
+ /**
7
+ * Writes the run's two log files: `run.jsonl` (the complete, authoritative record,
8
+ * streamed live so it survives a hard kill) and `run.log` (the human timeline).
9
+ *
10
+ * Two phases: {@link stream} runs for the whole run, appending each entry to both
11
+ * files as it arrives; {@link write} runs once at finalize, re-rendering `run.log`
12
+ * from the finished `run.jsonl` in collapsed form. Depends on a {@link RunFolder}
13
+ * (where the files land) and a {@link SnapshotStore} (to off-load collapsed runs).
14
+ */
15
+ export class RunLog {
16
+ folder;
17
+ snapshots;
18
+ constructor(folder, snapshots) {
19
+ this.folder = folder;
20
+ this.snapshots = snapshots;
21
+ }
22
+ /**
23
+ * Streams every log entry to `run.jsonl` (the complete, authoritative record) and a
24
+ * live `run.log` incrementally as they arrive, so output survives a hard kill or lost
25
+ * SIGINT. The log is closed once at finalize, after which {@link write} re-renders
26
+ * `run.log` from `run.jsonl` in collapsed form.
27
+ *
28
+ * Subscribes **synchronously, before any `await`** — so a fast synchronous producer
29
+ * that appends (and trims the in-memory history) before the file setup finishes still
30
+ * has its entries buffered on the subscription queue rather than lost. `replay: true`
31
+ * covers anything already logged when this is called.
32
+ */
33
+ async stream(logs) {
34
+ const entries = logs.subscribe({ replay: true });
35
+ await this.folder.ensure();
36
+ const jsonl = createWriteStream(this.folder.resolve("run.jsonl"));
37
+ const pretty = createWriteStream(this.folder.resolve("run.log"));
38
+ const renderer = new LogRenderer();
39
+ pretty.write(this.header() + "\n");
40
+ try {
41
+ for await (const entry of entries) {
42
+ jsonl.write(JSON.stringify(entry) + "\n");
43
+ pretty.write(renderer.render(entry) + "\n");
44
+ }
45
+ }
46
+ finally {
47
+ // End AND wait for both streams to fully close before resolving. {@link write}
48
+ // re-renders run.log at finalize (from this run.jsonl); if a buffered chunk from
49
+ // this stream landed *after* write() truncated run.log, the OS would zero-fill the
50
+ // gap — a multi-MB run of NUL bytes. `run` awaits this promise before calling
51
+ // write(), so this writer is guaranteed done first (and run.jsonl is complete).
52
+ await Promise.all([closeStream(jsonl), closeStream(pretty)]);
53
+ }
54
+ }
55
+ /**
56
+ * Renders the final, collapsed `run.log` from the authoritative `run.jsonl` (the
57
+ * complete record {@link stream} wrote), **streaming** it line by line so the whole
58
+ * run is never held in memory. Grouping/indentation is applied here so the stored
59
+ * `run.jsonl` stays presentation-free; large same-source runs are collapsed —
60
+ * off-loaded to a text snapshot and replaced inline by their head plus a pointer — so
61
+ * one proc's burst can't drown the timeline. `run.jsonl` is left as-is. Returns the
62
+ * `run.log` path (surfaced to the terminal).
63
+ *
64
+ * Call only after {@link stream} has closed (the run's log is ended), so `run.jsonl`
65
+ * is complete and no longer being written.
66
+ */
67
+ async write() {
68
+ await this.folder.ensure();
69
+ const jsonlFile = this.folder.resolve("run.jsonl");
70
+ const file = this.folder.resolve("run.log");
71
+ const out = createWriteStream(file);
72
+ out.write(this.header());
73
+ const renderer = new LogRenderer();
74
+ const writes = [];
75
+ const emit = (item) => {
76
+ if (item.kind === "line") {
77
+ out.write(item.text + "\n");
78
+ }
79
+ else {
80
+ const snap = this.snapshots.captureText(`output-${item.run.source}`, item.run.lines);
81
+ writes.push(snap.written);
82
+ out.write(LogRenderer.renderCollapsed(item.run, { rel: snap.rel }) + "\n");
83
+ }
84
+ };
85
+ try {
86
+ const rl = createInterface({ input: createReadStream(jsonlFile), crlfDelay: Infinity });
87
+ for await (const line of rl) {
88
+ if (!line)
89
+ continue;
90
+ let entry;
91
+ try {
92
+ entry = JSON.parse(line);
93
+ }
94
+ catch {
95
+ continue; // a torn final line (dirty exit) — skip it
96
+ }
97
+ for (const item of renderer.push(entry))
98
+ emit(item);
99
+ }
100
+ }
101
+ catch {
102
+ // run.jsonl missing/unreadable (e.g. a very early crash) — write what we have.
103
+ }
104
+ for (const item of renderer.end())
105
+ emit(item);
106
+ await closeStream(out); // flush + close run.log before reporting it written
107
+ await Promise.all(writes); // ensure every off-loaded run is on disk
108
+ return file;
109
+ }
110
+ /** A compact header block for the top of run.log — legend + run identity. */
111
+ header() {
112
+ const now = new Date();
113
+ return [
114
+ `─── ${this.folder.name()} · ${ymd(now)} ${hms(now)} ───`,
115
+ `● run ⚡ event ▸ action 📎 snapshot ⇥ output`,
116
+ "",
117
+ ].join("\n");
118
+ }
119
+ }