@volter/world-runtime 3.0.37 → 3.0.39

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 (168) hide show
  1. package/dist/known-external-services.json +3 -10
  2. package/dist/src/actions-runner-cli.js +3 -3
  3. package/dist/src/actions-runner.js +2 -1
  4. package/dist/src/app-url.js +2 -1
  5. package/dist/src/backings.d.ts +10 -1
  6. package/dist/src/backings.js +66 -7
  7. package/dist/src/branch.js +85 -52
  8. package/dist/src/browser-routes.cjs +59 -0
  9. package/dist/src/ca-trust.js +4 -3
  10. package/dist/src/changeset.js +4 -3
  11. package/dist/src/cli-commands.js +25 -9
  12. package/dist/src/cli.js +2 -1
  13. package/dist/src/command-lifetime.d.mts +12 -0
  14. package/dist/src/command-lifetime.d.ts +1 -15
  15. package/dist/src/command-lifetime.js +1 -98
  16. package/dist/src/command-lifetime.mjs +90 -0
  17. package/dist/src/configs.js +1 -1
  18. package/dist/src/console-apart.js +3 -2
  19. package/dist/src/covers.d.ts +3 -2
  20. package/dist/src/covers.js +95 -18
  21. package/dist/src/error-message.d.mts +1 -0
  22. package/dist/src/error-message.d.ts +1 -0
  23. package/dist/src/error-message.js +2 -0
  24. package/dist/src/error-message.mjs +10 -0
  25. package/dist/src/guest-command.d.mts +4 -0
  26. package/dist/src/guest-command.mjs +26 -0
  27. package/dist/src/guest-control.d.mts +1 -0
  28. package/dist/src/guest-control.mjs +17 -0
  29. package/dist/src/guest-entry.mjs +146 -0
  30. package/dist/src/guest-processes.d.mts +9 -0
  31. package/dist/src/guest-processes.mjs +47 -0
  32. package/dist/src/guest-runner.d.ts +6 -0
  33. package/dist/src/guest-runner.js +567 -0
  34. package/dist/src/host.js +5 -4
  35. package/dist/src/index.d.ts +1 -0
  36. package/dist/src/index.js +1 -0
  37. package/dist/src/infra-cli.js +4 -0
  38. package/dist/src/infra.js +133 -23
  39. package/dist/src/init.js +60 -23
  40. package/dist/src/lifecycle-record.d.ts +3 -1
  41. package/dist/src/lifecycle-record.js +29 -6
  42. package/dist/src/livekit-backing.mjs +37 -0
  43. package/dist/src/local-branches.js +11 -3
  44. package/dist/src/mongodb-backing.d.ts +7 -0
  45. package/dist/src/mongodb-backing.js +58 -0
  46. package/dist/src/native-postgres-backing.d.ts +4 -0
  47. package/dist/src/native-postgres-backing.js +404 -0
  48. package/dist/src/origin.js +2 -1
  49. package/dist/src/owned-path.d.ts +4 -0
  50. package/dist/src/owned-path.js +37 -0
  51. package/dist/src/pack-facts.d.ts +1 -0
  52. package/dist/src/pglite-backing.d.ts +4 -13
  53. package/dist/src/pglite-backing.js +198 -106
  54. package/dist/src/pglite-host.mjs +47 -17
  55. package/dist/src/placeholder.d.ts +3 -2
  56. package/dist/src/placeholder.js +9 -4
  57. package/dist/src/process-groups.d.mts +2 -0
  58. package/dist/src/process-groups.d.ts +1 -4
  59. package/dist/src/process-groups.js +1 -49
  60. package/dist/src/process-groups.mjs +59 -0
  61. package/dist/src/process-receipt.d.mts +6 -0
  62. package/dist/src/process-receipt.d.ts +11 -0
  63. package/dist/src/process-receipt.js +3 -0
  64. package/dist/src/process-receipt.mjs +45 -0
  65. package/dist/src/process-signal.d.mts +9 -0
  66. package/dist/src/process-signal.mjs +92 -0
  67. package/dist/src/project-inspect.d.ts +1 -1
  68. package/dist/src/project-inspect.js +4 -2
  69. package/dist/src/proxy-daemon.js +2 -1
  70. package/dist/src/redirect-proxy.js +186 -19
  71. package/dist/src/redis-backing.js +99 -35
  72. package/dist/src/reflect.js +2 -1
  73. package/dist/src/root.js +7 -6
  74. package/dist/src/run-task-worker.js +2 -1
  75. package/dist/src/runtime.d.ts +24 -7
  76. package/dist/src/runtime.js +703 -286
  77. package/dist/src/schema.d.ts +12 -1
  78. package/dist/src/schema.js +20 -5
  79. package/dist/src/served-world.d.ts +1 -0
  80. package/dist/src/served-world.js +26 -14
  81. package/dist/src/service-recorder.js +8 -3
  82. package/dist/src/signals.d.mts +1 -0
  83. package/dist/src/signals.d.ts +1 -1
  84. package/dist/src/signals.js +1 -11
  85. package/dist/src/signals.mjs +10 -0
  86. package/dist/src/tail.d.ts +2 -0
  87. package/dist/src/tail.js +9 -2
  88. package/dist/src/tcp-port.js +27 -13
  89. package/dist/src/temporal-backing.mjs +28 -0
  90. package/dist/src/up-cli.js +8 -3
  91. package/dist/src/up-task-worker.js +2 -1
  92. package/dist/src/websocket-relay.d.ts +1 -1
  93. package/dist/src/websocket-relay.js +23 -10
  94. package/dist/src/world-view.js +2 -2
  95. package/known-external-services.json +3 -10
  96. package/package.json +12 -7
  97. package/src/actions-runner-cli.ts +3 -3
  98. package/src/actions-runner.ts +2 -1
  99. package/src/app-url.ts +2 -1
  100. package/src/backings.ts +65 -7
  101. package/src/branch.ts +73 -34
  102. package/src/browser-routes.cjs +59 -0
  103. package/src/ca-trust.ts +4 -3
  104. package/src/changeset.ts +4 -3
  105. package/src/cli-commands.ts +21 -9
  106. package/src/cli.ts +2 -1
  107. package/src/command-lifetime.d.mts +12 -0
  108. package/src/command-lifetime.mjs +90 -0
  109. package/src/command-lifetime.ts +1 -79
  110. package/src/configs.ts +1 -1
  111. package/src/console-apart.ts +2 -1
  112. package/src/covers.ts +97 -19
  113. package/src/error-message.d.mts +1 -0
  114. package/src/error-message.mjs +10 -0
  115. package/src/error-message.ts +2 -0
  116. package/src/guest-command.d.mts +4 -0
  117. package/src/guest-command.mjs +26 -0
  118. package/src/guest-control.d.mts +1 -0
  119. package/src/guest-control.mjs +17 -0
  120. package/src/guest-entry.mjs +146 -0
  121. package/src/guest-processes.d.mts +9 -0
  122. package/src/guest-processes.mjs +47 -0
  123. package/src/guest-runner.ts +414 -0
  124. package/src/host.ts +5 -4
  125. package/src/index.ts +1 -0
  126. package/src/infra-cli.ts +4 -0
  127. package/src/infra.ts +103 -22
  128. package/src/init.ts +55 -22
  129. package/src/lifecycle-record.ts +21 -6
  130. package/src/livekit-backing.mjs +37 -0
  131. package/src/local-branches.ts +6 -3
  132. package/src/mongodb-backing.ts +55 -0
  133. package/src/native-postgres-backing.ts +329 -0
  134. package/src/origin.ts +2 -1
  135. package/src/owned-path.ts +32 -0
  136. package/src/pack-facts.ts +1 -0
  137. package/src/pglite-backing.ts +155 -79
  138. package/src/pglite-host.mjs +47 -17
  139. package/src/placeholder.ts +10 -5
  140. package/src/process-groups.d.mts +2 -0
  141. package/src/process-groups.mjs +59 -0
  142. package/src/process-groups.ts +1 -33
  143. package/src/process-receipt.d.mts +6 -0
  144. package/src/process-receipt.mjs +45 -0
  145. package/src/process-receipt.ts +5 -0
  146. package/src/process-signal.d.mts +9 -0
  147. package/src/process-signal.mjs +92 -0
  148. package/src/project-inspect.ts +4 -2
  149. package/src/proxy-daemon.ts +2 -1
  150. package/src/redirect-proxy.ts +153 -18
  151. package/src/redis-backing.ts +74 -25
  152. package/src/reflect.ts +2 -1
  153. package/src/root.ts +7 -6
  154. package/src/run-task-worker.ts +2 -1
  155. package/src/runtime.ts +596 -265
  156. package/src/schema.ts +30 -6
  157. package/src/served-world.ts +21 -15
  158. package/src/service-recorder.ts +4 -2
  159. package/src/signals.d.mts +1 -0
  160. package/src/signals.mjs +10 -0
  161. package/src/signals.ts +1 -10
  162. package/src/tail.ts +10 -3
  163. package/src/tcp-port.ts +24 -13
  164. package/src/temporal-backing.mjs +28 -0
  165. package/src/up-cli.ts +6 -3
  166. package/src/up-task-worker.ts +2 -1
  167. package/src/websocket-relay.ts +18 -11
  168. package/src/world-view.ts +2 -2
package/dist/src/infra.js CHANGED
@@ -9,9 +9,11 @@
9
9
  // the moment `volter-world-infra up` returned, and the World's readiness probe never saw it.
10
10
  import { existsSync, readFileSync } from 'node:fs';
11
11
  import { dirname, join } from 'node:path';
12
+ import { signalChild } from './process-signal.mjs';
12
13
  import { spawn } from 'node:child_process';
13
14
  import { fileURLToPath } from 'node:url';
14
15
  import { infraConnections, parseInfraDefinition } from "./pglite-backing.js";
16
+ import { errorMessage } from "./error-message.js";
15
17
  /** A command run to its end without blocking this process: in the World's own `up` the phase shares the event loop
16
18
  * with the boot's signal handling, where the tool's spawnSync blocked only the tool. Ended by `timeout` (SIGTERM,
17
19
  * as spawnSync's timeout) or by the boot's abort. */
@@ -21,11 +23,21 @@ function runCommand(command, args, options) {
21
23
  let stderr = '';
22
24
  let settled = false;
23
25
  let grace;
24
- const child = spawn(command, args, { windowsHide: true, env: options.env, stdio: ['ignore', 'pipe', 'pipe'], ...(options.signal ? { signal: options.signal } : {}) });
26
+ const child = spawn(command, args, { windowsHide: true, env: options.env, stdio: ['ignore', 'pipe', 'pipe'] });
27
+ const abort = () => {
28
+ try {
29
+ if (child.pid)
30
+ signalChild(child, 'SIGTERM');
31
+ }
32
+ catch (error) {
33
+ settle({ status: null, stdout, stderr, error: error });
34
+ }
35
+ };
25
36
  const settle = (ran) => {
26
37
  if (settled)
27
38
  return;
28
39
  settled = true;
40
+ options.signal?.removeEventListener('abort', abort);
29
41
  clearTimeout(timer);
30
42
  if (grace)
31
43
  clearTimeout(grace);
@@ -37,7 +49,14 @@ function runCommand(command, args, options) {
37
49
  // the timeout is a hard limit and answers as spawnSync's did: the command is killed and the result carries an
38
50
  // ETIMEDOUT error (a hung `docker info`, a daemon that never answers, is then no container runtime, as before)
39
51
  const timer = setTimeout(() => {
40
- child.kill('SIGTERM');
52
+ try {
53
+ if (child.pid)
54
+ signalChild(child, 'SIGTERM');
55
+ }
56
+ catch (error) {
57
+ settle({ status: null, stdout, stderr, error: error });
58
+ return;
59
+ }
41
60
  settle({ status: null, stdout, stderr, error: Object.assign(new Error(`spawn ${command} ETIMEDOUT`), { code: 'ETIMEDOUT' }) });
42
61
  }, options.timeout);
43
62
  child.stdout?.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
@@ -48,6 +67,9 @@ function runCommand(command, args, options) {
48
67
  // answer by at most a moment, never to the timeout
49
68
  child.on('exit', (code) => { grace = setTimeout(() => settle({ status: code, stdout, stderr }), 500); });
50
69
  child.on('close', (code) => settle({ status: code, stdout, stderr }));
70
+ options.signal?.addEventListener('abort', abort, { once: true });
71
+ if (options.signal?.aborted)
72
+ abort();
51
73
  });
52
74
  }
53
75
  /** One phase of the World-managed infrastructure. `env` is the infrastructure service's environment, which the
@@ -82,32 +104,52 @@ export async function runInfrastructure(phase, env, io, signal) {
82
104
  : /cannot connect|not running|daemon/iu.test(raw)
83
105
  ? 'local execution capacity is unavailable'
84
106
  : 'managed infrastructure operation failed';
107
+ // The existing backend redaction precedes truncation so a cut cannot expose part of a secret.
108
+ const diagnostic = (raw) => {
109
+ let detail = raw;
110
+ const secrets = Object.entries(env).filter(([name, value]) => value && value.length >= 4 && /secret|token|password|credential|api_key|database_url/iu.test(name))
111
+ .map(([, value]) => value).sort((a, b) => b.length - a.length);
112
+ for (const secret of secrets)
113
+ detail = detail.split(secret).join('[redacted]');
114
+ detail = detail.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/@]+(?::[^\s/@]*)?@/giu, '$1[redacted]@');
115
+ return detail.split('\n').slice(-40).join('\n').slice(-8192);
116
+ };
85
117
  const fail = (result) => {
86
- const raw = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
118
+ const raw = [result.stdout, result.stderr, result.error ? errorMessage(result.error) : undefined].filter(Boolean).join('\n').trim();
119
+ const detail = diagnostic(raw);
87
120
  io.err(`managed infrastructure ${phase} failed: ${classify(raw)}\n`);
121
+ if (detail)
122
+ io.err(`backend diagnostic:\n${detail}\n`);
88
123
  return result.status && result.status > 0 ? result.status : 1;
89
124
  };
90
125
  // ---- backing selection ------------------------------------------------------
91
126
  // The declared service contract (up/status/down) is the boundary; WHICH
92
127
  // runtime answers it is private and chosen here, per machine, at each phase:
93
- // 1. VOLTER_WORLD_INFRA_BACKING=docker|pglite — explicit, for tests/operators;
128
+ // 1. VOLTER_WORLD_INFRA_BACKING=docker|pglite|native — explicit, for tests/operators;
94
129
  // 2. a working container runtime — the compose path, byte-identical to before;
95
130
  // 3. no container runtime + a definition of postgres and/or mongodb services —
96
131
  // the containerless backing (pglite-backing.ts: PGlite for postgres, the
97
- // MongoDB twin for mongodb), announced loudly;
132
+ // MongoDB's own server for mongodb), announced loudly;
98
133
  // 4. otherwise the honest refusal naming what this machine cannot serve.
99
134
  async function selectBacking() {
100
135
  const forced = env.VOLTER_WORLD_INFRA_BACKING;
101
- if (forced === 'docker' || forced === 'pglite')
136
+ if (forced === 'docker' || forced === 'pglite' || forced === 'native')
102
137
  return forced;
103
138
  if (forced !== undefined) {
104
- io.err(`managed infrastructure: VOLTER_WORLD_INFRA_BACKING must be docker or pglite (got ${JSON.stringify(forced)})\n`);
139
+ io.err(`managed infrastructure: VOLTER_WORLD_INFRA_BACKING must be docker, pglite or native (got ${JSON.stringify(forced)})\n`);
105
140
  return 2;
106
141
  }
107
142
  const probe = await runCommand('docker', ['info', '--format', '{{.ServerVersion}}'], { env, timeout: 10_000, ...(signal ? { signal } : {}) });
108
143
  signal?.throwIfAborted();
109
- if (probe.status === 0)
144
+ if (probe.status === 0 && probe.stdout.trim())
110
145
  return 'docker';
146
+ if (probe.status === 0) {
147
+ io.err('managed infrastructure: container runtime probe returned no server version; using containerless backing\n');
148
+ return 'pglite';
149
+ }
150
+ const probeOutput = [probe.stdout, probe.stderr, probe.error ? errorMessage(probe.error) : undefined].filter(Boolean).join('\n').trim();
151
+ if (probeOutput)
152
+ io.err(`managed infrastructure: container runtime probe failed\n${probeOutput}\n`);
111
153
  // Absence of a container runtime (no binary, no daemon) is a capability
112
154
  // difference: swap backings. A PRESENT runtime failing on resources stays
113
155
  // on the docker path so the real capacity problem is classified and
@@ -127,7 +169,7 @@ export async function runInfrastructure(phase, env, io, signal) {
127
169
  return 1;
128
170
  }
129
171
  const unsupported = unsupportedKinds(services);
130
- if (unsupported.length > 0) {
172
+ if (phase !== 'down' && unsupported.length > 0) {
131
173
  io.err(`managed infrastructure ${phase} failed: local execution capacity is unavailable (no container runtime, and the containerless backing cannot serve: ${unsupported.join(', ')})\n`);
132
174
  return 1;
133
175
  }
@@ -137,7 +179,7 @@ export async function runInfrastructure(phase, env, io, signal) {
137
179
  await pgliteUp(services, worldData, launch);
138
180
  }
139
181
  catch (error) {
140
- io.err(`managed infrastructure up failed: ${String(error.message ?? error)}\n`);
182
+ io.err(`managed infrastructure up failed: ${diagnostic(errorMessage(error))}\n`);
141
183
  return 1;
142
184
  }
143
185
  io.out('managed infrastructure ready\n');
@@ -166,7 +208,30 @@ export async function runInfrastructure(phase, env, io, signal) {
166
208
  const twinCli = redisTwinCli([dirname(fileURLToPath(import.meta.url)), dirname(worldConfig)]);
167
209
  const twinned = env.VOLTER_WORLD_INFRA_BACKING === 'docker' || twinCli === undefined ? [] : declared.filter((s) => s.kind === 'redis');
168
210
  /** The services the backing below answers for: every one, or those the twin does not serve (by compose service name). */
169
- const composed = twinned.length === 0 ? null : declared.filter((s) => s.kind !== 'redis').map((s) => s.kind);
211
+ const native = declared.filter((s) => s.kind === 'mongodb');
212
+ const composed = twinned.length === 0 && native.length === 0 ? null : declared.filter((s) => !twinned.includes(s) && !native.includes(s)).map((s) => s.kind);
213
+ // A database's native wire and storage are its vendor server's. MongoDB never needs a Docker probe or container.
214
+ if (native.length > 0) {
215
+ const { pgliteUp, pgliteStatus, pgliteDown } = await import("./pglite-backing.js");
216
+ try {
217
+ if (phase === 'up') {
218
+ io.out('managed infrastructure backing: MongoDB server (containerless)\n');
219
+ await pgliteUp(native, worldData, launch);
220
+ }
221
+ else if (phase === 'status') {
222
+ const status = await pgliteStatus(native, worldData);
223
+ if (!status.ok)
224
+ throw new Error(`${status.ready}/${native.length} MongoDB services are ready`);
225
+ }
226
+ else {
227
+ await pgliteDown(native, worldData);
228
+ }
229
+ }
230
+ catch (error) {
231
+ io.err(`managed infrastructure ${phase} failed: ${diagnostic(errorMessage(error))}\n`);
232
+ return 1;
233
+ }
234
+ }
170
235
  if (twinned.length > 0) {
171
236
  const { redisTwinDown, redisTwinStatus, redisTwinUp } = await import("./redis-backing.js");
172
237
  const cli = twinCli;
@@ -176,7 +241,7 @@ export async function runInfrastructure(phase, env, io, signal) {
176
241
  await redisTwinUp(twinned, worldData, cli, launch);
177
242
  }
178
243
  catch (error) {
179
- io.err(`managed infrastructure up failed: ${String(error.message ?? error)}\n`);
244
+ io.err(`managed infrastructure up failed: ${diagnostic(errorMessage(error))}\n`);
180
245
  return 1;
181
246
  }
182
247
  }
@@ -190,21 +255,66 @@ export async function runInfrastructure(phase, env, io, signal) {
190
255
  else {
191
256
  await redisTwinDown(twinned, worldData);
192
257
  }
193
- if (composed.length === 0) {
194
- if (phase === 'up')
195
- io.out('managed infrastructure ready\n');
196
- else if (phase === 'status')
197
- io.out(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, env) })}\n`);
198
- else
199
- io.out('managed infrastructure stopped\n');
200
- return 0;
201
- }
258
+ }
259
+ if (composed?.length === 0) {
260
+ if (phase === 'up')
261
+ io.out('managed infrastructure ready\n');
262
+ else if (phase === 'status')
263
+ io.out(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, env) })}\n`);
264
+ else
265
+ io.out('managed infrastructure stopped\n');
266
+ return 0;
202
267
  }
203
268
  const backing = await selectBacking();
204
269
  if (typeof backing === 'number')
205
270
  return backing;
206
271
  if (backing === 'pglite')
207
272
  return runPglite();
273
+ if (backing === 'native') {
274
+ const { nativePostgresUp, nativePostgresStatus, nativePostgresDown } = await import("./native-postgres-backing.js");
275
+ const postgres = declared.filter(s => s.kind === 'postgres');
276
+ const others = declared.filter(s => s.kind !== 'postgres' && !twinned.includes(s));
277
+ const { unsupportedKinds, pgliteUp, pgliteStatus, pgliteDown } = await import("./pglite-backing.js");
278
+ if (postgres.length !== 1 || unsupportedKinds(others).length) {
279
+ io.err('managed infrastructure native backing requires one postgres and only supported containerless services alongside it\n');
280
+ return 1;
281
+ }
282
+ try {
283
+ if (phase === 'up') {
284
+ io.out('managed infrastructure backing: native PostgreSQL (multi-session)\n');
285
+ await nativePostgresUp(postgres, worldData, readFileSync(definition, 'utf8'), launch);
286
+ try {
287
+ await pgliteUp(others, worldData, launch);
288
+ }
289
+ catch (error) {
290
+ try {
291
+ await nativePostgresDown(worldData);
292
+ }
293
+ catch (cleanup) {
294
+ throw new AggregateError([error, cleanup], 'managed infrastructure startup and native cleanup failed');
295
+ }
296
+ throw error;
297
+ }
298
+ io.out('managed infrastructure ready\n');
299
+ }
300
+ else if (phase === 'status') {
301
+ const ready = others.length === 0 || (await pgliteStatus(others, worldData)).ok;
302
+ if (!ready || !await nativePostgresStatus(worldData))
303
+ return 1;
304
+ io.out(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, env) })}\n`);
305
+ }
306
+ else {
307
+ await pgliteDown(others, worldData);
308
+ await nativePostgresDown(worldData);
309
+ io.out('managed infrastructure stopped\n');
310
+ }
311
+ return 0;
312
+ }
313
+ catch (error) {
314
+ io.err(`managed infrastructure ${phase} failed: ${diagnostic(errorMessage(error))}\n`);
315
+ return 1;
316
+ }
317
+ }
208
318
  const only = composed ?? [];
209
319
  if (phase === 'up') {
210
320
  const result = await run(['up', '-d', '--wait', ...only]);
@@ -222,10 +332,10 @@ export async function runInfrastructure(phase, env, io, signal) {
222
332
  const expectedNames = expected.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
223
333
  const runningNames = running.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
224
334
  if (expectedNames.length === 0 || expectedNames.join('\0') !== runningNames.join('\0')) {
225
- io.err(`managed infrastructure status failed: ${runningNames.length + twinned.length}/${expectedNames.length + twinned.length} declared services are ready\n`);
335
+ io.err(`managed infrastructure status failed: ${runningNames.length + twinned.length + native.length}/${expectedNames.length + twinned.length + native.length} declared services are ready\n`);
226
336
  return 1;
227
337
  }
228
- io.out(`${JSON.stringify({ ok: true, services: expectedNames.length + twinned.length, connections: infraConnections(declared, env) })}\n`);
338
+ io.out(`${JSON.stringify({ ok: true, services: expectedNames.length + twinned.length + native.length, connections: infraConnections(declared, env) })}\n`);
229
339
  }
230
340
  else {
231
341
  const result = await run(['down', '--remove-orphans']);
package/dist/src/init.js CHANGED
@@ -45,13 +45,14 @@ import { overlayEndpointEnv, packFacts } from "./pack-facts.js";
45
45
  import { VENDOR_SERVER_BACKINGS } from "./backings.js";
46
46
  import { APP_URL_ENV, DEFAULT_WORLD_SELECTION, ISSUE_ENV_PREFIX, MINT_ENV, worldConfigDocument, worldSelectionIncludes } from "./schema.js";
47
47
  import { loadWorldConfig } from "./configs.js";
48
- /** The names a pack's credential door fills with a file it writes (`file:<field>`, the descriptor's): a path, never a value. */
48
+ import { ownedPath } from "./owned-path.js";
49
+ /** Credential artifacts (files or directories) hold paths, never invented credential values. */
49
50
  function fileCredentialNames() {
50
51
  const out = new Set();
51
52
  try {
52
53
  for (const facts of Object.values(packFacts()))
53
54
  for (const [name, spec] of Object.entries(facts?.credentialDoor?.fill ?? {}))
54
- if (String(spec).startsWith('file:'))
55
+ if (/^(file|directory):/.test(String(spec)))
55
56
  out.add(name);
56
57
  }
57
58
  catch { /* no artifact: no file doors known */ }
@@ -297,8 +298,8 @@ const COMPOSE_INFRA = {
297
298
  volumePath: '/data',
298
299
  healthcheck: () => ['CMD', 'redis-cli', 'ping'],
299
300
  },
300
- // Without a container runtime the containerless backing serves this kind with the MongoDB twin
301
- // (@volter/twin-mongodb), at the same loopback port and URL.
301
+ // The runtime serves this kind with MongoDB's own mongod on PATH, without a container,
302
+ // at the same loopback port and URL; command: ["mongod", "--replSet", "world"] enables transactions.
302
303
  mongodb: {
303
304
  image: 'mongo:7',
304
305
  containerPort: 27017,
@@ -528,10 +529,11 @@ export function planWorldInit(name, repoPath, options = {}) {
528
529
  catch {
529
530
  return {};
530
531
  } })();
531
- // Pack defaults (defaults/handlers.json, defaults/seed.ts): copied VISIBLY into the world
532
- // dir — the author's to edit or delete (copy = ownership; never baked into the pack).
532
+ // A pack's starters, copied VISIBLY into the world dir: the author's to edit or delete (copy = ownership; never
533
+ // baked into the pack). Its handlers starter is the answering turns its own life scripts (journeys/scenario.json, the
534
+ // World's handler format, its faults left out); its default data is src/semantics/seed.ts.
533
535
  const defaultsCopies = [];
534
- const signals = options.vendors ? null : detectRepoVendors(repo);
536
+ const signals = options.vendors ? null : detectRepoVendors(repo, existing?.services);
535
537
  const detected = options.vendors
536
538
  ? new Map(options.vendors.map((v) => [v, ['--twins']]))
537
539
  : new Map([...signals.detected].filter(([vendor]) => (signals.uses.get(vendor) ?? []).some((use) => worldSelectionIncludes(selection, vendor, use.usage))));
@@ -554,6 +556,8 @@ export function planWorldInit(name, repoPath, options = {}) {
554
556
  claimedEnv.set(name, service.id);
555
557
  }
556
558
  for (const vendor of [...detected.keys()].sort()) {
559
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(vendor))
560
+ throw new Error('Invalid service id');
557
561
  const detectedVia = [...(detected.get(vendor) ?? [])].sort();
558
562
  const backing = catalog.has(vendor) ? undefined : VENDOR_SERVER_BACKINGS[vendor];
559
563
  if (backing !== undefined) {
@@ -593,28 +597,33 @@ export function planWorldInit(name, repoPath, options = {}) {
593
597
  : `${wiring.note} CONFLICT: ${wiring.injectEnv} is already claimed by the "${conflict}" service, so this twin is emitted `
594
598
  + `without it — one world cannot point ${wiring.injectEnv} at two twins. Decide which vendor that var serves, or run them in separate worlds.`;
595
599
  const packDir = catalogSource.packDir(vendor);
596
- const defaultsHandlersPath = join(packDir, 'defaults', 'handlers.json');
597
- const handlersTo = existsSync(defaultsHandlersPath) ? resolve(out, 'handlers', `${vendor}.json`) : null;
600
+ // the starter is the life's handlers that answer; its faults (an overload, a dropped turn) are the life's own tests and
601
+ // would fail a new World's first calls, so they stay behind
602
+ const starterHandlers = (() => {
603
+ try {
604
+ const doc = JSON.parse(readFileSync(join(packDir, 'journeys', 'scenario.json'), 'utf8'));
605
+ const answering = (doc.handlers ?? []).filter((h) => h.fault === undefined);
606
+ return answering.length ? `${JSON.stringify({ ...doc, handlers: answering }, null, 2)}\n` : null;
607
+ }
608
+ catch {
609
+ return null;
610
+ }
611
+ })();
612
+ const handlersTo = starterHandlers !== null ? ownedPath(out, `handlers/${vendor}.json`) : null;
598
613
  if (handlersTo !== null)
599
- defaultsCopies.push({ vendor, kind: 'handlers', from: defaultsHandlersPath, to: handlersTo });
614
+ defaultsCopies.push({ vendor, kind: 'handlers', content: starterHandlers, to: handlersTo });
600
615
  const handlersArg = handlersTo === null ? null : carried(handlersTo);
601
- const defaultsSeedPath = join(packDir, 'defaults', 'seed.ts');
602
- // a protocol-3 pack's default data (src/semantics/seed.ts: calls to the vendor's own operations, as data) is copied
603
- // beside a runner that makes each call against the twin, both the author's from then on
616
+ // a pack's default data (src/semantics/seed.ts: calls to the vendor's own operations, as data) is copied beside a
617
+ // runner that makes each call against the twin, both the author's from then on
604
618
  const semanticsSeedPath = join(packDir, 'src', 'semantics', 'seed.ts');
605
- if (existsSync(defaultsSeedPath))
606
- defaultsCopies.push({ vendor, kind: 'seed', from: defaultsSeedPath, to: resolve(out, 'seeds', 'defaults', `${vendor}.ts`) });
607
- else if (existsSync(semanticsSeedPath)) {
608
- defaultsCopies.push({ vendor, kind: 'seed-data', from: semanticsSeedPath, to: resolve(out, 'seeds', 'defaults', `${vendor}.data.ts`) });
609
- defaultsCopies.push({ vendor, kind: 'seed', content: seedCallsRunner(vendor), to: resolve(out, 'seeds', 'defaults', `${vendor}.ts`) });
619
+ if (existsSync(semanticsSeedPath)) {
620
+ defaultsCopies.push({ vendor, kind: 'seed-data', from: semanticsSeedPath, to: ownedPath(out, `seeds/defaults/${vendor}.data.ts`) });
621
+ defaultsCopies.push({ vendor, kind: 'seed', content: seedCallsRunner(vendor), to: ownedPath(out, `seeds/defaults/${vendor}.ts`) });
610
622
  }
611
623
  // COLOCATION (runtime contract R2a): a pack whose serve factory is known (derived into the
612
624
  // pack-facts artifact) is emitted with `colocate`, so `up` boots it inside the ONE
613
625
  // volter-world-host process instead of spawning a process per twin. The spawn `command`
614
- // stays alongside as the process-isolation path. A service carrying a --scenario file
615
- // keeps the spawn path for now: the colocate factory contract has no scenarioPath yet,
616
- // and silently dropping the world's handlers would be a fake success.
617
- // A service carrying a handlers file colocates too (R2a): the colocate contract carries
626
+ // stays alongside as the process-isolation path. A service carrying a handlers file colocates too (R2a): the colocate contract carries
618
627
  // `scenarioPath` and the host hands it to the factory — the same file the spawn path passes
619
628
  // as --scenario.
620
629
  const serveExport = packFacts()[vendor]?.serveExport;
@@ -710,7 +719,7 @@ export function planWorldInit(name, repoPath, options = {}) {
710
719
  ...services.filter((service) => !existingIds.has(service.id)),
711
720
  ].flatMap(endpointNames));
712
721
  // Every name a twin of this World issues (its pack's credentialDoor fill), by the twin: the pack names them, so a name
713
- // is issued whatever its stem (PlanetScale's DATABASE_URL, Vercel's TEAM_ID_VERCEL, Upstash's KV_REST_API_TOKEN) once
722
+ // is issued whatever its stem (Vercel's TEAM_ID_VERCEL, Upstash's KV_REST_API_TOKEN) once
714
723
  // that vendor's twin is in the World, and never when it is not.
715
724
  const issuedBy = new Map();
716
725
  for (const service of [...(existing?.services ?? []).map(withIssues), ...services.filter((service) => !existingIds.has(service.id))]) {
@@ -1098,6 +1107,9 @@ export function writeWorldInit(plan, options = {}) {
1098
1107
  throw new Error(`volter init: a world already exists at ${plan.configPath}. `
1099
1108
  + 'Pass --force to regenerate it, or --out <dir> to emit elsewhere.');
1100
1109
  }
1110
+ // Recheck the planned name-derived paths before any output is written.
1111
+ for (const copy of plan.defaultsCopies)
1112
+ ownedPath(plan.out, relative(plan.out, copy.to));
1101
1113
  mkdirSync(plan.out, { recursive: true });
1102
1114
  writeFileSync(plan.configPath, `${JSON.stringify(worldConfigDocument(plan.config), null, 2)}\n`);
1103
1115
  if (plan.inRepo)
@@ -1138,6 +1150,7 @@ export function seedCallsRunner(vendor) {
1138
1150
  const stem = vendor.toUpperCase().replace(/[^A-Z0-9]/g, '_');
1139
1151
  return `// ${vendor}'s default data, as its twin's pack declares it (./${vendor}.data.ts: calls to the vendor's own
1140
1152
  // operations), made against the twin in order. Ownership transferred on copy: both files are yours — edit or delete.
1153
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
1141
1154
  import { seed as calls } from './${vendor}.data.ts';
1142
1155
  import * as defaults from './${vendor}.data.ts';
1143
1156
  import type { SeedBrowser } from '@volter/world-core/browser';
@@ -1147,6 +1160,29 @@ export async function seed(): Promise<void> {
1147
1160
  if ((process.env.VOLTER_SEED_SKIP ?? '').split(',').includes('${vendor}')) return;
1148
1161
  const base = process.env.${stem}_TWIN_URL;
1149
1162
  if (!base) throw new Error('${stem}_TWIN_URL is not set — bring the world up and run the seed with its env (volter-world attach/run)');
1163
+ <<<<<<< HEAD
1164
+ // a call dated before the World's time (\`at\`: state the account made then, through the API as it was) is made with
1165
+ // the World's clock frozen at that instant, and the clock is put back as it was found
1166
+ const clock = process.env.TWIN_WORLD_CLOCK_FILE;
1167
+ const found = clock && existsSync(clock) ? readFileSync(clock, 'utf8') : undefined;
1168
+ try {
1169
+ for (const call of calls) {
1170
+ if (call.at !== undefined) {
1171
+ if (!clock) throw new Error('${vendor} seed: a dated call needs the World clock (TWIN_WORLD_CLOCK_FILE): run the seed attached to the World');
1172
+ writeFileSync(clock, \`\${new Date(call.at).toISOString()}\n\`);
1173
+ }
1174
+ const res = await fetch(\`\${base.replace(/\\/$/, '')}\${call.path}\`, {
1175
+ method: call.method,
1176
+ headers: { ...(call.body !== undefined ? { 'content-type': 'application/json' } : {}), ...(call.headers ?? {}) },
1177
+ ...(call.body !== undefined ? { body: JSON.stringify(call.body) } : {}),
1178
+ });
1179
+ if (!res.ok) throw new Error(\`${vendor} seed: \${call.method} \${call.path}: \${res.status} \${await res.text()}\`);
1180
+ }
1181
+ } finally {
1182
+ if (clock && calls.some((call) => call.at !== undefined)) {
1183
+ if (found === undefined) rmSync(clock, { force: true }); else writeFileSync(clock, found);
1184
+ }
1185
+ =======
1150
1186
  const clients = new Map<string, SeedBrowser>();
1151
1187
  const snapshots = new Map<string, Awaited<ReturnType<SeedBrowser['state']>>>();
1152
1188
  let browserHelper: typeof import('@volter/world-core/browser') | undefined;
@@ -1183,6 +1219,7 @@ export async function seed(): Promise<void> {
1183
1219
  headers, ...(body === undefined ? {} : { body }),
1184
1220
  });
1185
1221
  if (!res.ok) throw new Error(\`${vendor} seed: \${call.method} \${call.path}: \${res.status} \${await res.text()}\`);
1222
+ >>>>>>> origin/main
1186
1223
  }
1187
1224
  }
1188
1225
  `;
@@ -22,7 +22,9 @@ export declare function handoffWorldLifecycle(root: string, world: string, pids:
22
22
  export declare function retainWorldLifecycleForCleanup(root: string, world: string): void;
23
23
  export declare function recordWorldLifecycleEvent(root: string, world: string, message: string): void;
24
24
  /** Called only after verified cleanup. Never touches legacy records. */
25
- export declare function releaseWorldLifecycle(root: string, world: string): void;
25
+ export declare function releaseWorldLifecycle(root: string, world: string, receipt?: string): void;
26
+ /** Explicit surrender must prove this generation's complete local inventory has exited. */
27
+ export declare function exitedWorldLifecyclePids(root: string, world: string, createdAt: string): number[] | undefined;
26
28
  /**
27
29
  * Releases the record of a World whose instance metadata is gone when nothing it
28
30
  * recorded can still run: the record is this host's and this user's, and its owner
@@ -1,5 +1,5 @@
1
1
  // Durable ownership of one World. This inventory does not allocate or reserve capacity.
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
4
  import { volterHome } from '@volter/world-core/runtime';
5
5
  import { hostname } from 'node:os';
@@ -7,10 +7,19 @@ import { dirname, join, resolve } from 'node:path';
7
7
  import { stateDirName, withFileLock } from '@volter/world-core/runtime';
8
8
  import { checkedOwner } from "./consumers.js";
9
9
  const directory = () => join(volterHome(), 'world-lifecycles');
10
- const identity = (root, world) => createHash('sha256').update(`${resolve(root)}\0${world}`).digest('hex');
10
+ /** One directory is one root however it is spelled: macOS's /tmp is /private/tmp, and a caller's cwd is the real path
11
+ * while its argument may be the alias. An identity hashed from the spelling made doctor report a running World's
12
+ * ownership missing when the World was started under /tmp and inspected from /private/tmp. */
13
+ const canonical = (root) => { try {
14
+ return realpathSync(root);
15
+ }
16
+ catch {
17
+ return resolve(root);
18
+ } };
19
+ const identity = (root, world) => createHash('sha256').update(`${canonical(root)}\0${world}`).digest('hex');
11
20
  const recordPath = (root, world) => join(directory(), `${identity(root, world)}.json`);
12
21
  const legacyPath = (root, world) => join(volterHome(), 'world-resource-claims', `${identity(root, world)}.json`);
13
- export const lifecycleLogPath = (root, world) => join(resolve(root), stateDirName(), 'worlds', '.lifecycle', `${world}.log`);
22
+ export const lifecycleLogPath = (root, world) => join(canonical(root), stateDirName(), 'worlds', '.lifecycle', `${world}.log`);
14
23
  function read(path) {
15
24
  const record = JSON.parse(readFileSync(path, 'utf8'));
16
25
  if (record?.version !== 1 || typeof record.world !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(record.world)
@@ -47,7 +56,7 @@ export function beginWorldLifecycle(root, world, instanceCreatedAt, owner) {
47
56
  checkedOwner(owner);
48
57
  assertLifecycleStartable(root, world);
49
58
  const record = {
50
- version: 1, world, root: resolve(root), identity: identity(root, world), instanceCreatedAt,
59
+ version: 1, world, root: canonical(root), identity: identity(root, world), instanceCreatedAt,
51
60
  owner, ownerUid: process.getuid?.(), ownerPid: process.pid, hostname: hostname(),
52
61
  startedAt: new Date().toISOString(), phase: 'starting', pids: [], log: lifecycleLogPath(root, world),
53
62
  };
@@ -81,7 +90,7 @@ export function recordWorldLifecycleEvent(root, world, message) {
81
90
  writeFileSync(path, `${new Date().toISOString()} ${message}\n`, { flag: 'a', mode: 0o600 });
82
91
  }
83
92
  /** Called only after verified cleanup. Never touches legacy records. */
84
- export function releaseWorldLifecycle(root, world) {
93
+ export function releaseWorldLifecycle(root, world, receipt = 'teardown verified; ownership released') {
85
94
  const path = recordPath(root, world);
86
95
  if (!existsSync(path))
87
96
  return;
@@ -94,12 +103,26 @@ export function releaseWorldLifecycle(root, world) {
94
103
  // The receipt line is evidence, not a precondition: failing to append it must not keep ownership (and so the next
95
104
  // `up`) blocked after a verified teardown.
96
105
  try {
97
- recordWorldLifecycleEvent(root, world, 'teardown verified; ownership released');
106
+ recordWorldLifecycleEvent(root, world, receipt);
98
107
  }
99
108
  catch { /* released regardless */ }
100
109
  rmSync(path);
101
110
  });
102
111
  }
112
+ /** Explicit surrender must prove this generation's complete local inventory has exited. */
113
+ export function exitedWorldLifecyclePids(root, world, createdAt) {
114
+ try {
115
+ const record = read(recordPath(root, world));
116
+ if (record.hostname !== hostname() || record.instanceCreatedAt !== createdAt
117
+ || (record.ownerUid !== undefined && record.ownerUid !== process.getuid?.()))
118
+ return undefined;
119
+ const pids = [...new Set([record.ownerPid, ...record.pids])];
120
+ return pids.some(processExists) ? undefined : pids;
121
+ }
122
+ catch {
123
+ return undefined;
124
+ }
125
+ }
103
126
  /**
104
127
  * Releases the record of a World whose instance metadata is gone when nothing it
105
128
  * recorded can still run: the record is this host's and this user's, and its owner
@@ -0,0 +1,37 @@
1
+ import { signalChild } from './process-signal.mjs';
2
+ import { errorMessage } from './error-message.mjs';
3
+ import { spawn } from 'node:child_process';
4
+ import { parse } from 'yaml';
5
+
6
+ if (!process.env.VOLTER_WORLD) throw new Error('LiveKit backing must run as a World service');
7
+ const port = Number(process.env.PORT);
8
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Missing allocated service port');
9
+ const args = process.argv.slice(2);
10
+ let supplied = {};
11
+ for (let index = 0; index < args.length; index++) {
12
+ if (args[index] !== '--config-body') continue;
13
+ const value = parse(args[index + 1]);
14
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid LiveKit config body');
15
+ supplied = { ...supplied, ...value };
16
+ args.splice(index, 2); index--;
17
+ }
18
+ // LiveKit's embedded TURN denies restricted peer addresses by default. This relay serves only this local SFU.
19
+ // Source: https://github.com/livekit/livekit/blob/v1.13.1/pkg/config/config.go (TURNConfig).
20
+ const config = {
21
+ ...supplied, port, bind_addresses: ['127.0.0.1'],
22
+ rtc: { ...supplied.rtc, node_ip: '127.0.0.1', tcp_port: 0 },
23
+ turn: { ...supplied.turn, enabled: true, domain: '127.0.0.1', udp_port: port, bind_addresses: ['127.0.0.1'],
24
+ allow_restricted_peer_cidrs: ['127.0.0.0/8'] },
25
+ };
26
+ const child = spawn('livekit-server', ['--config-body', JSON.stringify(config), ...args], {
27
+ stdio: 'inherit', env: process.env,
28
+ });
29
+ let stopping = false;
30
+ const stop = () => {
31
+ stopping = true;
32
+ try { if (child.pid) signalChild(child, 'SIGTERM'); }
33
+ catch (error) { console.error(errorMessage(error)); process.exitCode = 1; }
34
+ };
35
+ process.once('SIGINT', stop); process.once('SIGTERM', stop);
36
+ child.once('error', error => { console.error(errorMessage(error)); process.exitCode = 1; });
37
+ child.once('exit', (code, signal) => { process.exitCode = stopping ? 0 : code ?? (signal ? 1 : 0); });
@@ -3,6 +3,7 @@
3
3
  // parent through its own doors (its history cut at an instant, when one is asked), its clock frozen at
4
4
  // that instant so no twin's catch-up walks it forward, and removed when its time runs out. world-host
5
5
  // and `volter world view` both make them with this; a hosted World's supervisor makes its own.
6
+ import { errorMessage } from "./error-message.js";
6
7
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
8
  import { dirname, join } from 'node:path';
8
9
  import { readBranchMeta, stateDirName, withHistoryLock, withStateRemoval, worldPaths, worldStateRoot, writeBranchMeta } from '@volter/world-core/runtime';
@@ -202,8 +203,15 @@ export class LocalBranches {
202
203
  try {
203
204
  await mounted?.stop();
204
205
  }
205
- catch { /* reported below */ }
206
- withStateRemoval(root, () => rmSync(root, { recursive: true, force: true }));
206
+ catch (cleanup) {
207
+ throw new AggregateError([error, cleanup], 'Branch startup and host cleanup failed; state retained');
208
+ }
209
+ try {
210
+ withStateRemoval(root, () => rmSync(root, { recursive: true, force: true }));
211
+ }
212
+ catch (cleanup) {
213
+ throw new AggregateError([error, cleanup], 'Branch startup and state cleanup failed');
214
+ }
207
215
  throw error;
208
216
  }
209
217
  }
@@ -250,7 +258,7 @@ export class LocalBranches {
250
258
  await this.remove(name);
251
259
  }
252
260
  catch (error) {
253
- this.o.announce?.(`branch ${name}: removal failed: ${error instanceof Error ? error.message : String(error)}`);
261
+ this.o.announce?.(`branch ${name}: removal failed: ${errorMessage(error)}`);
254
262
  }
255
263
  }
256
264
  }
@@ -0,0 +1,7 @@
1
+ import type { HostLaunch, InfraService } from './pglite-backing.js';
2
+ /** PATH is the binary contract, as for the native PostgreSQL example and LiveKit. No acquisition at boot. */
3
+ export declare function mongodbArgv(service: InfraService, data: string): string[];
4
+ /** MongoDB's structured record comes from this child, not a listener already holding the port. */
5
+ export declare function mongodbListening(log: string, port: number): boolean;
6
+ /** Ping the server; initialize only an explicitly declared one-member set and await its primary. */
7
+ export declare function mongodbReady(service: InfraService, launch: HostLaunch, deadline: number, initialize?: boolean): Promise<void>;