omkit 0.0.9 → 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} +31 -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
@@ -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
+ }
@@ -63,3 +63,17 @@ export declare class LogsCollector implements Logger {
63
63
  subscribe({ replay }?: SubscribeOptions): AsyncIterable<LogEntry>;
64
64
  snapshot(): readonly LogEntry[];
65
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,4 +1,4 @@
1
- import { AsyncQueue } from "../helpers/AsyncQueue.js";
1
+ import { AsyncQueue } from "../../utils/AsyncQueue.js";
2
2
  export class LogsCollector {
3
3
  sequence = 0;
4
4
  // Recent entries retained in memory, only to replay to late subscribers. The
@@ -95,6 +95,29 @@ export class LogsCollector {
95
95
  return this.history;
96
96
  }
97
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
+ }
98
121
  /**
99
122
  * Splits a byte/string stream into lines, calling `onLine` for each. Partial
100
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
+ }
@@ -0,0 +1,49 @@
1
+ import type { Logger } from "../log/LogsCollector.ts";
2
+ import type { RunFolder } from "../folder/RunFolder.ts";
3
+ /** A written (or in-flight) snapshot file and where to find it. */
4
+ export interface SnapshotRef {
5
+ /** Absolute path to the snapshot file. */
6
+ readonly file: string;
7
+ /** Path relative to cwd — what a log line references. */
8
+ readonly rel: string;
9
+ /** Resolves once the file is flushed to disk. */
10
+ readonly written: Promise<void>;
11
+ }
12
+ /**
13
+ * Writes sequenced snapshot files into the run folder and (for {@link snapshot})
14
+ * drops a `[snapshot]` pointer line into the timeline.
15
+ *
16
+ * Sequence numbers make repeated snapshots of the same name a history rather than an
17
+ * overwrite. Paths are computed synchronously so a caller can reference the file in
18
+ * the same tick while the write proceeds in the background. Depends on a
19
+ * {@link RunFolder} (where the files land) and a {@link Logger} (only `snapshot`
20
+ * writes a timeline line).
21
+ */
22
+ export declare class SnapshotStore {
23
+ private seq;
24
+ private readonly folder;
25
+ private readonly log;
26
+ constructor(folder: RunFolder, log: Logger);
27
+ /**
28
+ * Writes `value` as a sequenced JSON snapshot file (`<name>-NN.json`) and returns its
29
+ * path — **without** logging a pointer line. The path is computed synchronously while
30
+ * the write proceeds in the background. Use {@link snapshot} when you also want a
31
+ * `[snapshot]` line in the timeline; the event bus uses this directly to link a
32
+ * non-string payload from its own line.
33
+ */
34
+ captureJson(name: string, value: unknown): SnapshotRef;
35
+ /**
36
+ * Writes raw text `lines` as a sequenced `.log` snapshot file — the text sibling of
37
+ * {@link captureJson}. {@link RunLog.write} uses it to off-load a collapsed output run
38
+ * from `run.log`, keeping the burst's full text one click away.
39
+ */
40
+ captureText(name: string, lines: readonly string[]): SnapshotRef;
41
+ /**
42
+ * Captures a value as a JSON snapshot (via {@link captureJson}) and records a
43
+ * `[snapshot] <name>: <path>` line in the timeline at the point it's taken. Callable
44
+ * any time, anywhere in the run; resolves with the file path once written.
45
+ */
46
+ snapshot(name: string, value: unknown): Promise<string>;
47
+ /** Shared writer: sequence + sanitize the name, write in the background, return its refs. */
48
+ private capture;
49
+ }