@volter/world-runtime 2.0.0

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 (164) hide show
  1. package/LICENSE +202 -0
  2. package/dist/known-external-services.json +1108 -0
  3. package/dist/src/ancestry.d.ts +2 -0
  4. package/dist/src/ancestry.js +42 -0
  5. package/dist/src/app-url.d.ts +47 -0
  6. package/dist/src/app-url.js +239 -0
  7. package/dist/src/attach.d.ts +48 -0
  8. package/dist/src/attach.js +87 -0
  9. package/dist/src/branch.d.ts +20 -0
  10. package/dist/src/branch.js +65 -0
  11. package/dist/src/browser-proxy-cli.d.ts +2 -0
  12. package/dist/src/browser-proxy-cli.js +41 -0
  13. package/dist/src/ca-trust.d.ts +5 -0
  14. package/dist/src/ca-trust.js +64 -0
  15. package/dist/src/catalog.d.ts +31 -0
  16. package/dist/src/catalog.js +148 -0
  17. package/dist/src/changeset.d.ts +142 -0
  18. package/dist/src/changeset.js +570 -0
  19. package/dist/src/cli.d.ts +2 -0
  20. package/dist/src/cli.js +1262 -0
  21. package/dist/src/command-lifetime.d.ts +15 -0
  22. package/dist/src/command-lifetime.js +98 -0
  23. package/dist/src/configs.d.ts +18 -0
  24. package/dist/src/configs.js +119 -0
  25. package/dist/src/console-apart.d.ts +38 -0
  26. package/dist/src/console-apart.js +107 -0
  27. package/dist/src/consumers.d.ts +46 -0
  28. package/dist/src/consumers.js +200 -0
  29. package/dist/src/covers.d.ts +183 -0
  30. package/dist/src/covers.js +800 -0
  31. package/dist/src/fixture-env.d.ts +42 -0
  32. package/dist/src/fixture-env.js +221 -0
  33. package/dist/src/host-cli.d.ts +2 -0
  34. package/dist/src/host-cli.js +92 -0
  35. package/dist/src/host-fault-fixture.d.ts +32 -0
  36. package/dist/src/host-fault-fixture.js +100 -0
  37. package/dist/src/host-worker.d.ts +1 -0
  38. package/dist/src/host-worker.js +23 -0
  39. package/dist/src/host.d.ts +38 -0
  40. package/dist/src/host.js +135 -0
  41. package/dist/src/index.d.ts +48 -0
  42. package/dist/src/index.js +35 -0
  43. package/dist/src/infra-cli.d.ts +2 -0
  44. package/dist/src/infra-cli.js +136 -0
  45. package/dist/src/init.d.ts +227 -0
  46. package/dist/src/init.js +1117 -0
  47. package/dist/src/inject-map.d.ts +34 -0
  48. package/dist/src/inject-map.js +56 -0
  49. package/dist/src/lifecycle-record.d.ts +47 -0
  50. package/dist/src/lifecycle-record.js +196 -0
  51. package/dist/src/origin.d.ts +31 -0
  52. package/dist/src/origin.js +139 -0
  53. package/dist/src/pack-facts.d.ts +75 -0
  54. package/dist/src/pack-facts.js +98 -0
  55. package/dist/src/pglite-backing.d.ts +21 -0
  56. package/dist/src/pglite-backing.js +158 -0
  57. package/dist/src/pglite-host.mjs +147 -0
  58. package/dist/src/placeholder.d.ts +20 -0
  59. package/dist/src/placeholder.js +100 -0
  60. package/dist/src/prerequisites.d.ts +21 -0
  61. package/dist/src/prerequisites.js +49 -0
  62. package/dist/src/process-groups.d.ts +4 -0
  63. package/dist/src/process-groups.js +49 -0
  64. package/dist/src/project-inspect.d.ts +109 -0
  65. package/dist/src/project-inspect.js +827 -0
  66. package/dist/src/proxy-daemon.d.ts +2 -0
  67. package/dist/src/proxy-daemon.js +18 -0
  68. package/dist/src/redirect-proxy.d.ts +105 -0
  69. package/dist/src/redirect-proxy.js +665 -0
  70. package/dist/src/reflect.d.ts +74 -0
  71. package/dist/src/reflect.js +392 -0
  72. package/dist/src/resources.d.ts +26 -0
  73. package/dist/src/resources.js +22 -0
  74. package/dist/src/root.d.ts +114 -0
  75. package/dist/src/root.js +312 -0
  76. package/dist/src/run-task-worker.d.ts +1 -0
  77. package/dist/src/run-task-worker.js +38 -0
  78. package/dist/src/run-task.d.ts +18 -0
  79. package/dist/src/run-task.js +48 -0
  80. package/dist/src/runtime-test-support.d.ts +59 -0
  81. package/dist/src/runtime-test-support.js +205 -0
  82. package/dist/src/runtime.d.ts +256 -0
  83. package/dist/src/runtime.js +3502 -0
  84. package/dist/src/schema.d.ts +449 -0
  85. package/dist/src/schema.js +605 -0
  86. package/dist/src/serve.d.ts +30 -0
  87. package/dist/src/serve.js +82 -0
  88. package/dist/src/served-world.d.ts +194 -0
  89. package/dist/src/served-world.js +986 -0
  90. package/dist/src/service-exit.d.ts +46 -0
  91. package/dist/src/service-exit.js +195 -0
  92. package/dist/src/service-recorder.d.ts +1 -0
  93. package/dist/src/service-recorder.js +121 -0
  94. package/dist/src/sibling.d.ts +1 -0
  95. package/dist/src/sibling.js +9 -0
  96. package/dist/src/signals.d.ts +1 -0
  97. package/dist/src/signals.js +11 -0
  98. package/dist/src/storage-capacity.d.ts +8 -0
  99. package/dist/src/storage-capacity.js +61 -0
  100. package/dist/src/tail.d.ts +30 -0
  101. package/dist/src/tail.js +160 -0
  102. package/dist/src/tcp-port.d.ts +2 -0
  103. package/dist/src/tcp-port.js +36 -0
  104. package/dist/src/up-task-worker.d.ts +1 -0
  105. package/dist/src/up-task-worker.js +61 -0
  106. package/dist/src/up-task.d.ts +17 -0
  107. package/dist/src/up-task.js +49 -0
  108. package/dist/src/websocket-relay.d.ts +3 -0
  109. package/dist/src/websocket-relay.js +40 -0
  110. package/known-external-services.json +1108 -0
  111. package/package.json +83 -0
  112. package/src/ancestry.ts +36 -0
  113. package/src/app-url.ts +253 -0
  114. package/src/attach.ts +117 -0
  115. package/src/branch.ts +63 -0
  116. package/src/browser-proxy-cli.ts +44 -0
  117. package/src/ca-trust.ts +57 -0
  118. package/src/catalog.ts +156 -0
  119. package/src/changeset.ts +627 -0
  120. package/src/cli.ts +1111 -0
  121. package/src/command-lifetime.ts +79 -0
  122. package/src/configs.ts +110 -0
  123. package/src/console-apart.ts +90 -0
  124. package/src/consumers.ts +185 -0
  125. package/src/covers.ts +934 -0
  126. package/src/fixture-env.ts +230 -0
  127. package/src/host-cli.ts +90 -0
  128. package/src/host-worker.ts +23 -0
  129. package/src/host.ts +169 -0
  130. package/src/index.ts +171 -0
  131. package/src/infra-cli.ts +133 -0
  132. package/src/init.ts +1316 -0
  133. package/src/inject-map.ts +72 -0
  134. package/src/lifecycle-record.ts +168 -0
  135. package/src/origin.ts +134 -0
  136. package/src/pack-facts.ts +128 -0
  137. package/src/pglite-backing.ts +141 -0
  138. package/src/pglite-host.mjs +147 -0
  139. package/src/placeholder.ts +89 -0
  140. package/src/prerequisites.ts +66 -0
  141. package/src/process-groups.ts +33 -0
  142. package/src/project-inspect.ts +770 -0
  143. package/src/proxy-daemon.ts +21 -0
  144. package/src/redirect-proxy.ts +684 -0
  145. package/src/reflect.ts +440 -0
  146. package/src/resources.ts +22 -0
  147. package/src/root.ts +290 -0
  148. package/src/run-task-worker.ts +27 -0
  149. package/src/run-task.ts +44 -0
  150. package/src/runtime-test-support.ts +208 -0
  151. package/src/runtime.ts +3357 -0
  152. package/src/schema.ts +922 -0
  153. package/src/serve.ts +102 -0
  154. package/src/served-world.ts +812 -0
  155. package/src/service-exit.ts +175 -0
  156. package/src/service-recorder.ts +89 -0
  157. package/src/sibling.ts +10 -0
  158. package/src/signals.ts +10 -0
  159. package/src/storage-capacity.ts +60 -0
  160. package/src/tail.ts +205 -0
  161. package/src/tcp-port.ts +35 -0
  162. package/src/up-task-worker.ts +40 -0
  163. package/src/up-task.ts +45 -0
  164. package/src/websocket-relay.ts +32 -0
@@ -0,0 +1,175 @@
1
+ // How a process service ended, read back from its own log: the runtime's parent writes a `world.service.started`
2
+ // line when it spawns the service and a `world.service.exited` line when the service ends (runtime.ts,
3
+ // ./service-recorder.ts). `status` and `doctor` report the last run's end with the service's last lines of output, so
4
+ // a service that died after `up` returned says how and what it last said instead of only "not live". The World's own
5
+ // stop of a service (`down`, a boot's rollback) is recorded in the same log (`world.service.stop`, and
6
+ // `world.service.killed` for a SIGKILL after the grace), so its end reads as the World's act and not an outsider's.
7
+ import { closeSync, existsSync, fstatSync, openSync, readSync } from 'node:fs';
8
+
9
+ export type ServiceExitRecord = {
10
+ event: 'world.service.exited';
11
+ status: number;
12
+ code?: number | null;
13
+ signal?: string | null;
14
+ error?: string;
15
+ pid?: number;
16
+ uptimeMs?: number;
17
+ stopRequested?: string;
18
+ at: string;
19
+ };
20
+ export type WorldStopRecord = { event: 'world.service.stop' | 'world.service.killed'; by: string; signal: string; at: string };
21
+ export type ServiceEnd = {
22
+ startedAt?: string;
23
+ exited?: ServiceExitRecord;
24
+ /** The World's stop of this run, and its SIGKILL after the grace, when it sent them. */
25
+ stopped?: WorldStopRecord;
26
+ killed?: WorldStopRecord;
27
+ /** The service's own last lines of output in its last run, oldest first. */
28
+ lastOutput: string[];
29
+ };
30
+
31
+ const TAIL_BYTES = 256 * 1024;
32
+ // How far back the start of a run is looked for when its output has pushed the start record out of the tail.
33
+ const DEEP_BYTES = 64 * 1024 * 1024;
34
+ const CHUNK_BYTES = 1024 * 1024;
35
+ const OUTPUT_LINES = 20;
36
+ const LINE_CHARS = 400;
37
+ // ANSI colour and cursor sequences, which a terminal-minded service writes into a file log too.
38
+ const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
39
+
40
+ function fstatSize(path: string): number {
41
+ const fd = openSync(path, 'r');
42
+ try { return fstatSync(fd).size; } finally { closeSync(fd); }
43
+ }
44
+
45
+ function tailWithOffset(log: string): { text: string; start: number } {
46
+ const fd = openSync(log, 'r');
47
+ try {
48
+ const size = fstatSync(fd).size, start = Math.max(0, size - TAIL_BYTES), buffer = Buffer.alloc(size - start);
49
+ readSync(fd, buffer, 0, buffer.length, start);
50
+ const text = buffer.toString('utf8');
51
+ return { text: start > 0 ? text.slice(text.indexOf('\n') + 1) : text, start };
52
+ } finally {
53
+ closeSync(fd);
54
+ }
55
+ }
56
+
57
+ /** The last `world.service.started` record in the bytes of `log` before `end`, read backwards a chunk at a time (a
58
+ * chatty service can write far more than the tail between its start and its end). */
59
+ function startBefore(log: string, end: number): { at?: string } | undefined {
60
+ const fd = openSync(log, 'r');
61
+ try {
62
+ const floor = Math.max(0, end - DEEP_BYTES);
63
+ for (let hi = end; hi > floor; hi -= CHUNK_BYTES) {
64
+ const lo = Math.max(floor, hi - CHUNK_BYTES - 512), buffer = Buffer.alloc(hi - lo);
65
+ readSync(fd, buffer, 0, buffer.length, lo);
66
+ const text = buffer.toString('utf8'), at = text.lastIndexOf(`${MARK}started"`);
67
+ if (at < 0) continue;
68
+ const line = text.slice(at, text.indexOf('\n', at) < 0 ? undefined : text.indexOf('\n', at));
69
+ try { return JSON.parse(line) as { at?: string }; } catch { return {}; }
70
+ }
71
+ return undefined;
72
+ } finally {
73
+ closeSync(fd);
74
+ }
75
+ }
76
+
77
+ // A record the parent wrote. The service shares the file, so a record can follow output the service left without a
78
+ // newline on the same line: it is found wherever it starts, and what precedes it is the service's output.
79
+ const MARK = '{"event":"world.service.';
80
+ function split(line: string): { output: string; record?: { event?: unknown; at?: unknown } } {
81
+ const at = line.indexOf(MARK);
82
+ if (at < 0) return { output: line };
83
+ try { return { output: line.slice(0, at), record: JSON.parse(line.slice(at)) as { event?: unknown; at?: unknown } }; } catch { return { output: line }; }
84
+ }
85
+
86
+ /** A log's text as the service wrote it, without the recorder's records: what a `stdoutMatch` readiness pattern is
87
+ * matched against. */
88
+ export function serviceOutput(text: string): string {
89
+ return text.includes(MARK) ? text.split('\n').map((line) => split(line).output).join('\n') : text;
90
+ }
91
+
92
+ /** The last run's end in a service log: its start, its exit record (absent while it runs, or when its parent was killed
93
+ * with it), and the output it wrote last. Undefined for a log the parent never wrote to (a co-located twin's host log),
94
+ * which has no runs to tell apart. A run is bounded by its start record or, for a spawn that failed before starting
95
+ * or a log from before start records, by the previous run's exit. */
96
+ export function readServiceEnd(log: string): ServiceEnd | undefined {
97
+ if (!existsSync(log)) return undefined;
98
+ const { text, start: tailStart } = tailWithOffset(log);
99
+ const lines = text.split('\n').map(split);
100
+ let from = -1, startedAt: string | undefined, exited: ServiceExitRecord | undefined, until = lines.length;
101
+ let stopped: WorldStopRecord | undefined, killed: WorldStopRecord | undefined;
102
+ const marks: Array<{ i: number; record: WorldStopRecord }> = [];
103
+ for (let i = lines.length - 1; i >= 0 && from < 0; i--) {
104
+ const r = lines[i].record;
105
+ if (r?.event === 'world.service.started') { from = i + 1; startedAt = typeof r.at === 'string' ? r.at : undefined; }
106
+ else if (r?.event === 'world.service.exited') {
107
+ if (exited === undefined) { exited = r as ServiceExitRecord; until = i; } else from = i + 1;
108
+ } else if (r?.event === 'world.service.stop' || r?.event === 'world.service.killed') marks.push({ i, record: r as WorldStopRecord });
109
+ }
110
+ // A stop written after the run's exit (a later `down` finding it already dead) was not this end's cause.
111
+ for (const { i, record } of marks) {
112
+ if (i >= until) continue;
113
+ if (record.event === 'world.service.stop') stopped ??= record; else killed ??= record;
114
+ }
115
+ // The run began before the tail: its start is found further back, and all of the tail is this run's.
116
+ if (from < 0 && tailStart > 0) { const deep = startBefore(log, tailStart); if (deep) startedAt = deep.at ?? ''; }
117
+ // The recorder rotates a full log to `<log>.1`: a run older than the rotation began there.
118
+ if (from < 0 && startedAt === undefined && existsSync(`${log}.1`)) {
119
+ const size = fstatSize(`${log}.1`), deep = startBefore(`${log}.1`, size);
120
+ if (deep) startedAt = deep.at ?? '';
121
+ }
122
+ if (startedAt === undefined && exited === undefined) return undefined;
123
+ // The output a record line carried before it belongs to the run before the record.
124
+ const own = [...lines.slice(Math.max(0, from), until).map((line) => line.output), ...(until < lines.length ? [lines[until].output] : [])];
125
+ const lastOutput = own
126
+ .map((line) => line.replace(ANSI, '').replace(/\r/g, '').trimEnd())
127
+ .filter((line) => line.trim() !== '')
128
+ .slice(-OUTPUT_LINES)
129
+ .map((line) => (line.length > LINE_CHARS ? `${line.slice(0, LINE_CHARS)}…` : line));
130
+ return { ...(startedAt !== undefined ? { startedAt } : {}), ...(exited !== undefined ? { exited } : {}), ...(stopped ? { stopped } : {}), ...(killed ? { killed } : {}), lastOutput };
131
+ }
132
+
133
+ const duration = (ms: number): string => {
134
+ const s = Math.round(ms / 1000);
135
+ return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;
136
+ };
137
+
138
+ /** One line for a service that is not live: how it ended, who stopped it, when, and after how long. */
139
+ export function describeServiceEnd(end: ServiceEnd): string {
140
+ const exit = end.exited;
141
+ // A log from before exit records split code from signal: a status past 128 is 128 + the signal's number.
142
+ if (exit && exit.code === undefined && exit.signal === undefined && exit.status > 128) exit.signal = `signal ${exit.status - 128}`;
143
+ if (!exit) {
144
+ if (end.killed) return `was killed by the World (${end.killed.by}: SIGKILL after it did not stop within the grace), at ${end.killed.at}`;
145
+ if (end.stopped) return `recorded no exit after the World's stop (${end.stopped.by}, at ${end.stopped.at}): its World parent died with it`;
146
+ return end.startedAt !== undefined
147
+ ? 'recorded no exit: its World parent died with it (a SIGKILL to the group from outside the World, the OOM killer, or a machine restart)'
148
+ : 'recorded no start in its log';
149
+ }
150
+ const ended = exit.signal ? `ended by ${exit.signal}` : `exit code ${exit.code ?? exit.status}`;
151
+ const how = exit.error ? `failed to spawn (${exit.error})`
152
+ : exit.stopRequested && end.stopped ? `was stopped by the World (${end.stopped.by}; ${ended})`
153
+ : exit.stopRequested ? `was stopped by a ${exit.stopRequested} from outside the World (no World stop was recorded; ${ended})`
154
+ : exit.signal ? `was killed by ${exit.signal}` : `exited with code ${exit.code ?? exit.status}`;
155
+ const ran = exit.uptimeMs !== undefined ? ` after running ${duration(exit.uptimeMs)}` : '';
156
+ return `${how}${ran}, at ${exit.at}`;
157
+ }
158
+
159
+ // ---- the World's events file ----------------------------------------------------------------------------------------
160
+ export type WorldEvent = { event: string; service?: string; at: string; [key: string]: unknown };
161
+ /** The World's events (logs/events.jsonl), oldest first; unreadable lines are skipped. */
162
+ export function readWorldEvents(path: string): WorldEvent[] {
163
+ if (!existsSync(path)) return [];
164
+ const { text } = tailWithOffset(path);
165
+ return text.split('\n').flatMap((line) => { if (!line.trim()) return []; try { return [JSON.parse(line) as WorldEvent]; } catch { return []; } });
166
+ }
167
+ /** One event as a line a person reads. */
168
+ export function describeWorldEvent(event: WorldEvent): string {
169
+ const who = event.service ?? '(world)';
170
+ if (event.event === 'world.service.started') return `${event.at} ${who} started (pid ${event.pid})`;
171
+ if (event.event === 'world.service.exited') return `${event.at} ${who} ${describeServiceEnd({ exited: event as unknown as ServiceExitRecord, lastOutput: [] }).replace(/, at [^,]*$/, '')}`;
172
+ if (event.event === 'world.service.stop') return `${event.at} ${who} stopped by the World (${event.by}, ${event.signal})`;
173
+ if (event.event === 'world.service.killed') return `${event.at} ${who} killed by the World (${event.by}, SIGKILL after the grace)`;
174
+ return `${event.at} ${who} ${event.event}`;
175
+ }
@@ -0,0 +1,89 @@
1
+ // A World service's parent for its whole life (runtime.ts, spawnRecorded). Node, not a POSIX shell, because job control
2
+ // is not something every place a World runs provides. The service writes its output straight to its log (this
3
+ // parent's stdout and stderr are that file, opened for append, and the service inherits them): a pipe through here
4
+ // would lose the last output of a Node service that exits right after a large write, reorder stdout against stderr,
5
+ // and tie the service's life to this parent's. This parent:
6
+ // - writes a `world.service.started` record when the service starts and a `world.service.exited` record (exit code or
7
+ // signal, pid, run time, whether it was asked to stop) when it ends, into the log and into the World's events file
8
+ // (VOLTER_WORLD_EVENTS), the one place every service end in the World is listed;
9
+ // - caps the log, checked every second: past VOLTER_WORLD_SERVICE_LOG_MAX bytes it copies the log's last cap bytes to
10
+ // `<log>.1` and truncates it in place (the service's append-mode descriptor then writes from the new end), so a
11
+ // chatty or runaway service cannot fill the disk; lines written between the copy and the truncate are lost, the usual
12
+ // cost of rotating a file a program holds, and a writer that reopened the log without O_APPEND (Linux's /dev/stdout)
13
+ // keeps its old offset and leaves a gap of NULs;
14
+ // - stamps the time into the log about every ten seconds while output flows (`world.service.time`), since the
15
+ // service's own lines carry none: lines between two marks were written between those times;
16
+ // - forwards TERM/INT/HUP to the service and exits with its status (128+n for a signal).
17
+ // The World's NODE_OPTIONS (the injector) is for the service, not this parent: it arrives renamed and is restored.
18
+ import { spawn } from 'node:child_process';
19
+ import { constants } from 'node:os';
20
+ import { appendFileSync, closeSync, fstatSync, openSync, readSync, statSync, truncateSync, writeFileSync } from 'node:fs';
21
+
22
+ const [command = '', ...args] = process.argv.slice(2);
23
+ const env = { ...process.env };
24
+ if (env.VOLTER_WORLD_SERVICE_NODE_OPTIONS !== undefined) { env.NODE_OPTIONS = env.VOLTER_WORLD_SERVICE_NODE_OPTIONS; delete env.VOLTER_WORLD_SERVICE_NODE_OPTIONS; }
25
+ const service = env.VOLTER_WORLD_SERVICE_ID;
26
+ const logPath = env.VOLTER_WORLD_SERVICE_LOG;
27
+ const eventsPath = env.VOLTER_WORLD_EVENTS;
28
+ const cap = Number(env.VOLTER_WORLD_SERVICE_LOG_MAX) > 0 ? Number(env.VOLTER_WORLD_SERVICE_LOG_MAX) : 64 * 1024 * 1024;
29
+ for (const key of ['VOLTER_WORLD_EVENTS', 'VOLTER_WORLD_SERVICE_LOG_MAX']) delete env[key];
30
+
31
+ /** The log's size and whether its last byte ends a line (a record never lands inside a service's line). */
32
+ const logEnd = (): { size: number; atLineStart: boolean } => {
33
+ if (!logPath) return { size: 0, atLineStart: true };
34
+ try {
35
+ const fd = openSync(logPath, 'r');
36
+ try {
37
+ const { size } = fstatSync(fd), last = Buffer.alloc(1);
38
+ return { size, atLineStart: !size || (readSync(fd, last, 0, 1, size - 1) === 1 && last[0] === 10) };
39
+ } finally { closeSync(fd); }
40
+ } catch { return { size: 0, atLineStart: true }; }
41
+ };
42
+ const record = (entry: Record<string, unknown>, then?: () => void): void => {
43
+ const line = `${JSON.stringify({ ...entry, service, at: new Date().toISOString() })}\n`;
44
+ if (eventsPath && entry.event !== 'world.service.time') { try { appendFileSync(eventsPath, line, { mode: 0o600 }); } catch { /* the log still has it */ } }
45
+ process.stdout.write(`${logEnd().atLineStart ? '' : '\n'}${line}`, () => then?.());
46
+ };
47
+
48
+ const started = Date.now();
49
+ const child = spawn(command, args, { stdio: 'inherit', env });
50
+ if (child.pid !== undefined) record({ event: 'world.service.started', pid: child.pid, parentPid: process.pid });
51
+
52
+ /** The log's last `bytes` (from a line start) as the rotation `<log>.1`: however far a runaway writer overshot the cap,
53
+ * the rotation and the live log together stay within twice it. */
54
+ const rotate = (path: string, size: number): void => {
55
+ const fd = openSync(path, 'r');
56
+ try {
57
+ const start = Math.max(0, size - cap), buffer = Buffer.alloc(size - start);
58
+ readSync(fd, buffer, 0, buffer.length, start);
59
+ const from = start > 0 ? buffer.indexOf(10) + 1 : 0;
60
+ writeFileSync(`${path}.1`, buffer.subarray(from), { mode: 0o600 });
61
+ } finally { closeSync(fd); }
62
+ truncateSync(path, 0);
63
+ };
64
+ // The cap every second; a time mark every ten while output flows.
65
+ let marked = logEnd().size, ticks = 0;
66
+ const watch = setInterval(() => {
67
+ const end = logEnd();
68
+ if (logPath && end.size >= cap) {
69
+ try { rotate(logPath, end.size); marked = 0; } catch { /* the log keeps growing rather than lose it */ }
70
+ }
71
+ if (++ticks % 10 === 0 && end.size > marked && end.atLineStart) { record({ event: 'world.service.time' }); marked = statSize(); }
72
+ }, 1_000);
73
+ const statSize = (): number => { try { return logPath ? statSync(logPath).size : 0; } catch { return 0; } };
74
+
75
+ let forwarded: string | null = null;
76
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) process.on(signal, () => { forwarded = forwarded || signal; try { child.kill('SIGTERM'); } catch { /* already gone */ } });
77
+ let ended = false;
78
+ const done = (status: number, end: Record<string, unknown>): void => {
79
+ if (ended) return;
80
+ ended = true;
81
+ clearInterval(watch);
82
+ record({ event: 'world.service.exited', status, ...end, pid: child.pid, uptimeMs: Date.now() - started, ...(forwarded ? { stopRequested: forwarded } : {}) }, () => process.exit(status));
83
+ };
84
+ child.once('error', (error: Error) => {
85
+ const message = String(error?.message || error);
86
+ process.stderr.write(`${message}\n`);
87
+ done(127, { code: null, signal: null, error: message });
88
+ });
89
+ child.once('exit', (code, signal) => done(signal ? 128 + (constants.signals[signal as NodeJS.Signals] || 0) : (code ?? 1), { code, signal }));
package/src/sibling.ts ADDED
@@ -0,0 +1,10 @@
1
+ // A sibling script of the calling module, in the form this package is RUNNING AS: `.ts` from the
2
+ // checkout, `.js` from the built package (scripts/publish/build.mjs emits dist/src/*.js beside
3
+ // nothing else). A spawn by a hard `.ts` path was the built runtime's first failure (2026-09-07:
4
+ // "Module not found …/dist/src/host-cli.ts" under the tutorial registry).
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ export function siblingScript(fromUrl: string, stem: string): string {
9
+ return join(dirname(fileURLToPath(fromUrl)), `${stem}${fromUrl.endsWith('.js') ? '.js' : '.ts'}`);
10
+ }
package/src/signals.ts ADDED
@@ -0,0 +1,10 @@
1
+ // Removing a signal listener without removing the others. Under Bun (measured on 1.3.10),
2
+ // `process.off('SIGINT', fn)` uninstalls the process's OS handler even while other listeners remain
3
+ // (`listenerCount` still answers 1): the next SIGINT then takes the default action and kills the
4
+ // process, so a caller's own handler (a harness's teardown) never runs. Re-registering the remaining
5
+ // listeners reinstalls the handler. Node keeps it either way.
6
+ export function offSignal(signal: NodeJS.Signals, listener: (...args: never[]) => void): void {
7
+ const rest = process.listeners(signal).filter((l) => l !== listener);
8
+ process.removeAllListeners(signal);
9
+ for (const l of rest) process.on(signal, l as (...args: unknown[]) => void);
10
+ }
@@ -0,0 +1,60 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { statSync } from 'node:fs';
3
+
4
+ export type StorageProbeFailure = { root: string; stage: string; reason: string };
5
+
6
+ /** Storage capacity is shared by APFS volumes in a container. Device numbers alone do not identify shared backing capacity. Unknown topology
7
+ * stays unknown; this is inspection metadata, never a speculative admission budget. */
8
+ export function storagePoolResolver(onFailure?: (failure: StorageProbeFailure) => void): (root: string) => string | undefined {
9
+ const devices = new Map<number, string | undefined>();
10
+ return (root) => {
11
+ let stage = 'filesystem identity';
12
+ try {
13
+ const device = statSync(root).dev;
14
+ if (devices.has(device)) return devices.get(device);
15
+ // Unknown is a result too. A failed macOS probe must not be repeated for every
16
+ // World on this device within one report/admission attempt. A new resolver retries.
17
+ devices.set(device, undefined);
18
+ let pool: string | undefined;
19
+ if (process.platform === 'darwin') {
20
+ stage = 'volume lookup';
21
+ const df = execFileSync('/bin/df', ['-P', root], { encoding: 'utf8', timeout: 3000, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'ignore'] });
22
+ const disk = df.trim().split('\n').at(-1)?.trim().split(/\s+/)[0];
23
+ if (disk && /^\/dev\/disk\d+(?:s\d+)*$/.test(disk)) {
24
+ stage = 'volume metadata';
25
+ const info = diskInfo(disk);
26
+ const container = plistString(info, 'APFSContainerReference');
27
+ if (container && /^disk\d+$/.test(container)) {
28
+ stage = 'pool metadata';
29
+ const uuid = plistString(diskInfo(container), 'DiskUUID');
30
+ if (uuid && /^[a-f0-9-]+$/i.test(uuid)) pool = `apfs:${uuid.toLowerCase()}`;
31
+ } else if (plistString(info, 'FilesystemType') !== 'apfs') {
32
+ const uuid = plistString(info, 'VolumeUUID');
33
+ if (uuid && /^[a-f0-9-]+$/i.test(uuid)) pool = `volume:${uuid.toLowerCase()}`;
34
+ }
35
+ }
36
+ }
37
+ // Other hosts remain unresolved: distinct filesystem devices can still share a
38
+ // backing capacity pool (for example subvolumes or thin provisioning).
39
+ if (!pool) onFailure?.({ root, stage, reason: 'metadata did not identify a supported storage pool' });
40
+ devices.set(device, pool);
41
+ return pool;
42
+ } catch (error) {
43
+ const code = (error as NodeJS.ErrnoException).code;
44
+ const reason = typeof code === 'string' ? code : 'metadata probe failed';
45
+ onFailure?.({ root, stage, reason: reason.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 120) });
46
+ return undefined;
47
+ }
48
+ };
49
+ }
50
+
51
+ function diskInfo(disk: string): string {
52
+ return execFileSync('/usr/sbin/diskutil', ['info', '-plist', disk], {
53
+ encoding: 'utf8', timeout: 3000, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'ignore'],
54
+ });
55
+ }
56
+
57
+ // Only fixed keys and ASCII identifiers are consumed, never arbitrary XML values.
58
+ function plistString(xml: string, key: string): string | undefined {
59
+ return new RegExp(`<key>${key}</key>\\s*<string>([^<]*)</string>`).exec(xml)?.[1];
60
+ }
package/src/tail.ts ADDED
@@ -0,0 +1,205 @@
1
+ // `volter-world tail` — the live observability verb: one merged, causally-ordered (by
2
+ // `occurredAt`) feed of a world's twin action ledgers. Strictly READ-ONLY over data at rest
3
+ // (the per-service `actions.jsonl` files the control plane appends to — see
4
+ // control-plane/src/actions.ts, plus — under `--requests` — the opt-in
5
+ // `requests.jsonl` request journals serve.ts writes, shape + credential shape); the runtime stays a lifecycle primitive,
6
+ // this just watches what the twins already record. A ledger dir may not exist until a service
7
+ // records its first action, so discovery re-runs on every poll and a missing ledger is a
8
+ // notice, never an error.
9
+ import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { stateDirName } from '@volter/world-core';
12
+ import { statusWorld } from './runtime.ts';
13
+
14
+ /** The slice of a ledger row `tail` renders. Rows are vendor-recorded TwinActions
15
+ * (control-plane/src/actions.ts); everything else passes through untouched under `--json`. */
16
+ type TailRecord = Record<string, unknown> & {
17
+ occurredAt?: string;
18
+ /** request-journal rows (requests.jsonl) carry `at` instead of `occurredAt`. */
19
+ at?: string;
20
+ op?: string;
21
+ operation?: string;
22
+ method?: string;
23
+ path?: string;
24
+ status?: number;
25
+ /** how long the twin took to answer a request row, in milliseconds (serve.ts journalingFetch) */
26
+ ms?: number;
27
+ /** credential SHAPE on a request row: which header/query name carried a credential, and a
28
+ * truncated sha256 of the value. Never the value — see control-plane/src/serve.ts. */
29
+ credentials?: Array<{ name?: string; in?: string; scheme?: string; fp?: string; empty?: true }>;
30
+ subject?: { type?: string; id?: string };
31
+ };
32
+
33
+ type TailEntry = { service: string; record: TailRecord; source: 'actions' | 'requests' };
34
+
35
+ export type TailWorldOptions = {
36
+ root?: string;
37
+ /** Restrict the feed to these world service ids (default: every service in the world). */
38
+ services?: string[];
39
+ /** Keep watching for new entries after the initial dump (default true; `--no-follow` clears it). */
40
+ follow?: boolean;
41
+ /** Raw JSONL passthrough with the world service id injected as `service` (default: human lines). */
42
+ json?: boolean;
43
+ /** Also merge each service's opt-in REQUEST journal into the feed (`requests.jsonl`, written
44
+ * by twins running under VOLTER_TWIN_REQUEST_JOURNAL=1 — shape-only `{at, method, path,
45
+ * status}` rows; control-plane/src/serve.ts). Reads are otherwise invisible here: an
46
+ * empty-catalog GET that 404s leaves no trace in an actions-only tail. */
47
+ requests?: boolean;
48
+ /** Poll interval while following (ledger appends are cross-process, so this polls rather than
49
+ * fs.watch — watch descriptors can't see a file that doesn't exist yet). */
50
+ pollMs?: number;
51
+ /** Entry sink — one already-formatted line per call (default: process.stdout). */
52
+ write?: (line: string) => void;
53
+ /** Diagnostics sink for the one-time "no ledger yet" notices (default: process.stderr). */
54
+ notice?: (line: string) => void;
55
+ /** Aborting stops a following tail (the CLI wires Ctrl-C here). */
56
+ signal?: AbortSignal;
57
+ };
58
+
59
+ /** Every action ledger a world service currently owns. One world service can record actions
60
+ * under one or more STATE services (see runtime.ts `worldServiceStateService`), so this returns
61
+ * every `<data>/<service>/<state-dir>/world/<state-service>/actions.jsonl` present right now. */
62
+ function serviceLedgers(dataDir: string, service: string, file: 'actions.jsonl' | 'requests.jsonl' = 'actions.jsonl'): string[] {
63
+ const stateRoot = join(dataDir, service, stateDirName(), 'world');
64
+ if (!existsSync(stateRoot)) return [];
65
+ return readdirSync(stateRoot, { withFileTypes: true })
66
+ .filter((entry) => entry.isDirectory() && existsSync(join(stateRoot, entry.name, file)))
67
+ .map((entry) => join(stateRoot, entry.name, file))
68
+ .sort();
69
+ }
70
+
71
+ /** Read the complete lines appended to `path` since the recorded offset, advancing the offset
72
+ * only past the last newline — a half-written trailing line stays unconsumed until the writer
73
+ * finishes it, so a poll can never emit a torn row. */
74
+ function readNewLines(path: string, offsets: Map<string, number>): string[] {
75
+ let offset = offsets.get(path) ?? 0;
76
+ let size: number;
77
+ try {
78
+ size = statSync(path).size;
79
+ } catch {
80
+ return []; // vanished between discovery and read (e.g. a purge mid-tail) — next poll rediscovers
81
+ }
82
+ if (size < offset) offset = 0; // rewritten shorter (scrub/rebuild) — replay rather than silently skip
83
+ if (size === offset) return [];
84
+ const fd = openSync(path, 'r');
85
+ const buffer = Buffer.alloc(size - offset);
86
+ try {
87
+ readSync(fd, buffer, 0, buffer.length, offset);
88
+ } finally {
89
+ closeSync(fd);
90
+ }
91
+ const lastNewline = buffer.lastIndexOf(0x0a);
92
+ if (lastNewline < 0) return [];
93
+ offsets.set(path, offset + lastNewline + 1);
94
+ return buffer.subarray(0, lastNewline).toString('utf8').split('\n').filter((line) => line.trim() !== '');
95
+ }
96
+
97
+ /** Local wall-clock `HH:MM:SS.mmm` — the operator is watching a live feed on this machine. */
98
+ function formatOccurredAt(occurredAt: string | undefined): string {
99
+ const date = occurredAt === undefined ? new Date(NaN) : new Date(occurredAt);
100
+ if (Number.isNaN(date.getTime())) return '??:??:??.???';
101
+ const pad = (value: number, width = 2) => String(value).padStart(width, '0');
102
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`;
103
+ }
104
+
105
+ /** The instant an entry sorts (and renders) by: actions carry `occurredAt`, request rows `at`. */
106
+ function entryInstant(entry: TailEntry): string | undefined {
107
+ return entry.record.occurredAt ?? entry.record.at;
108
+ }
109
+
110
+ function formatEntry(entry: TailEntry, json: boolean): string {
111
+ if (json) return JSON.stringify({ ...entry.record, service: entry.service });
112
+ const { record } = entry;
113
+ if (entry.source === 'requests') {
114
+ // a served request, not an action: `HH:MM:SS.mmm <service> <METHOD> <path> <status> in <ms>ms`, and —
115
+ // when the caller presented one — which credential arrived, named and fingerprinted. The
116
+ // fingerprint is short on purpose: enough to see "these two headers carried DIFFERENT keys"
117
+ // and "this is the same key as the line above" at a glance, never enough to be a secret.
118
+ const creds = (record.credentials ?? [])
119
+ .map((c) => `${c.name ?? '?'}${c.in === 'query' ? '(query)' : ''}=${c.empty ? 'EMPTY' : `${c.scheme ? `${c.scheme} ` : ''}${(c.fp ?? '').replace(/^sha256:/, '').slice(0, 8)}`}`)
120
+ .join(' ');
121
+ return `${formatOccurredAt(entryInstant(entry))} ${entry.service} ${record.method ?? '?'} ${record.path ?? '?'} ${record.status ?? '?'}${typeof record.ms === 'number' ? ` in ${record.ms}ms` : ''}${creds ? ` [${creds}]` : ''}`;
122
+ }
123
+ return `${formatOccurredAt(record.occurredAt)} ${entry.service} ${record.operation ?? record.op ?? '?'} ${record.subject?.id ?? '?'}`;
124
+ }
125
+
126
+ /** Stable causal order: the entry instant first; ties keep discovery order (service, file, line). */
127
+ function byOccurredAt(a: TailEntry, b: TailEntry): number {
128
+ return (entryInstant(a) ?? '') < (entryInstant(b) ?? '') ? -1
129
+ : (entryInstant(a) ?? '') > (entryInstant(b) ?? '') ? 1 : 0;
130
+ }
131
+
132
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
133
+ return new Promise((resolve) => {
134
+ const finish = (): void => {
135
+ clearTimeout(timer);
136
+ signal?.removeEventListener('abort', finish);
137
+ resolve();
138
+ };
139
+ const timer = setTimeout(finish, ms);
140
+ signal?.addEventListener('abort', finish, { once: true });
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Stream a world's action ledgers as one merged feed: dump everything recorded so far in
146
+ * `occurredAt` order, then (unless `follow` is false) poll for appends until `signal` aborts.
147
+ * New entries within each poll batch are `occurredAt`-sorted too, so concurrent services still
148
+ * read causally; cross-batch order is arrival order, as in any live tail.
149
+ */
150
+ export async function tailWorldActions(name: string, options: TailWorldOptions = {}): Promise<void> {
151
+ const root = options.root ?? process.cwd();
152
+ const instance = statusWorld(name, root);
153
+ const known = Object.keys(instance.services);
154
+ for (const service of options.services ?? []) {
155
+ if (!instance.services[service]) {
156
+ throw new Error(`World ${name} has no service "${service}"; it has: ${known.join(', ')}`);
157
+ }
158
+ }
159
+ const services = options.services?.length ? options.services : known;
160
+ const follow = options.follow ?? true;
161
+ const json = options.json ?? false;
162
+ const pollMs = options.pollMs ?? 200;
163
+ const write = options.write ?? ((line: string) => process.stdout.write(`${line}\n`));
164
+ const notice = options.notice ?? ((line: string) => process.stderr.write(`${line}\n`));
165
+
166
+ const offsets = new Map<string, number>();
167
+ const sources: Array<'actions' | 'requests'> = options.requests ? ['actions', 'requests'] : ['actions'];
168
+ const collect = (): TailEntry[] => {
169
+ const entries: TailEntry[] = [];
170
+ for (const service of services) {
171
+ for (const source of sources) {
172
+ for (const ledger of serviceLedgers(instance.dirs.data, service, source === 'actions' ? 'actions.jsonl' : 'requests.jsonl')) {
173
+ for (const line of readNewLines(ledger, offsets)) {
174
+ try {
175
+ entries.push({ service, record: JSON.parse(line) as TailRecord, source });
176
+ } catch {
177
+ // a corrupt row must not kill a live feed; the ledger itself is untouched
178
+ }
179
+ }
180
+ }
181
+ }
182
+ }
183
+ return entries.sort(byOccurredAt);
184
+ };
185
+
186
+ const emit = (entries: TailEntry[]): void => {
187
+ for (const entry of entries) write(formatEntry(entry, json));
188
+ };
189
+
190
+ emit(collect());
191
+ for (const service of services) {
192
+ if (serviceLedgers(instance.dirs.data, service).length === 0) {
193
+ notice(`World ${name}: service "${service}" has no action ledger yet${follow
194
+ ? ' — watching; drive the app (or a vendor SDK) through the world to record actions'
195
+ : '; drive the app (or a vendor SDK) through the world to record actions, then tail again'}`);
196
+ }
197
+ }
198
+ if (!follow) return;
199
+
200
+ while (!options.signal?.aborted) {
201
+ await sleep(pollMs, options.signal);
202
+ if (options.signal?.aborted) return;
203
+ emit(collect());
204
+ }
205
+ }
@@ -0,0 +1,35 @@
1
+ import { createServer } from 'node:net';
2
+
3
+ /** Select a port and exclude it from later selections. The probe closes before returning. */
4
+ export async function allocatePort(excluded: Set<number> = new Set()): Promise<number> {
5
+ for (let attempt = 0; attempt < 32; attempt++) {
6
+ let port: number;
7
+ try {
8
+ port = await probePort();
9
+ } catch (error) {
10
+ if ((error as NodeJS.ErrnoException).code === 'EADDRINUSE') continue;
11
+ throw error;
12
+ }
13
+ if (excluded.has(port)) continue;
14
+ excluded.add(port);
15
+ return port;
16
+ }
17
+ throw new Error('Could not allocate a TCP port after 32 attempts');
18
+ }
19
+
20
+ function probePort(): Promise<number> {
21
+ return new Promise((resolvePort, reject) => {
22
+ const server = createServer();
23
+ server.once('error', reject);
24
+ // Match serveHttp's default scope; a loopback-only probe can miss a wildcard listener.
25
+ server.listen(0, '0.0.0.0', () => {
26
+ const address = server.address();
27
+ if (!address || typeof address === 'string') {
28
+ server.close(() => reject(new Error('Could not allocate a TCP port')));
29
+ return;
30
+ }
31
+ const port = address.port;
32
+ server.close((error) => error ? reject(error) : resolvePort(port));
33
+ });
34
+ });
35
+ }
@@ -0,0 +1,40 @@
1
+ import { recordWorldLifecycleEvent } from './lifecycle-record.ts';
2
+ import { upWorld } from './runtime.ts';
3
+ import { loadWorldConfig } from './configs.ts';
4
+
5
+ const worldNameOf = (configId: string, root: string): string => { try { return loadWorldConfig(configId, root).config.id; } catch { return configId; } };
6
+ import type { UpTaskReply, UpTaskRequest } from './up-task.ts';
7
+
8
+ // The boot's owner (./up-task.ts). Only the caller's explicit cancel aborts the boot; a lost caller does not, and the
9
+ // boot's outcome, which the caller can no longer print, goes to the lifecycle log.
10
+ if (!process.send) throw new Error('World boot owner requires its private lifetime channel');
11
+ const abort = new AbortController();
12
+ let started = false;
13
+ let callerLost = false;
14
+ process.on('disconnect', () => {
15
+ callerLost = true;
16
+ if (!started) process.exit(0);
17
+ });
18
+ // A dead caller may also close the output reader. Reporting must not kill the boot.
19
+ process.stdout.on('error', () => {});
20
+ process.stderr.on('error', () => {});
21
+ process.on('message', async message => {
22
+ const request = message as UpTaskRequest | { type: 'cancel'; signal: 'SIGINT' | 'SIGTERM' };
23
+ if (request.type === 'cancel') { abort.abort(request.signal); return; }
24
+ if (request.type !== 'start' || started) return;
25
+ started = true;
26
+ let reply: UpTaskReply;
27
+ try { reply = { instance: await upWorld(request.configId, request.options, abort.signal) }; }
28
+ catch (error) { reply = { error: error instanceof Error ? error.message : String(error) }; }
29
+ if (callerLost && 'instance' in reply) {
30
+ try { recordWorldLifecycleEvent(reply.instance.root, reply.instance.name, 'boot completed after its caller went away; the World is up'); } catch { /* the World is up regardless */ }
31
+ }
32
+ if (callerLost && 'error' in reply) {
33
+ // The caller that would have printed the reason is gone; the lifecycle log keeps it (upWorld also records its own
34
+ // boot failure there, but a failure before the lifecycle claim would otherwise leave nothing).
35
+ try { recordWorldLifecycleEvent(request.options.root ?? process.cwd(), request.options.name ?? worldNameOf(request.configId, request.options.root ?? process.cwd()), `boot failed after its caller went away: ${reply.error}`); } catch { /* nowhere left to write it */ }
36
+ }
37
+ if ('error' in reply && !process.connected) process.stderr.write(`${reply.error}\n`);
38
+ if (process.connected) process.send!(reply, () => process.exit('error' in reply ? 1 : 0));
39
+ else process.exit('error' in reply ? 1 : 0);
40
+ });