@volter/world-runtime 3.0.38 → 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/src/host.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { errorMessage } from './error-message.ts';
1
2
  import { getPack, registerPack, type TwinPack } from '@volter/world-core/runtime';
2
3
  // Co-located twin host — run many twins in ONE process instead of one OS process
3
4
  // per twin. The twin core is a pure, transport-agnostic handler (see a pack's
@@ -105,8 +106,8 @@ export async function startColocatedHost(
105
106
  // the shared process. (A thrown *request* handler is already contained by Bun.serve → 500.)
106
107
  // We log and attribute rather than exit — state stays consistent because every kernel write
107
108
  // is atomic (temp→rename) and root-keyed, so a swallowed error can't corrupt a sibling.
108
- const onRejection = (reason: unknown) => onEvent({ twin: '(host)', kind: 'unhandledRejection', detail: String(reason) });
109
- const onUncaught = (err: unknown) => onEvent({ twin: '(host)', kind: 'uncaughtException', detail: String(err) });
109
+ const onRejection = (reason: unknown) => onEvent({ twin: '(host)', kind: 'unhandledRejection', detail: errorMessage(reason) });
110
+ const onUncaught = (err: unknown) => onEvent({ twin: '(host)', kind: 'uncaughtException', detail: errorMessage(err) });
110
111
  process.on('unhandledRejection', onRejection);
111
112
  process.on('uncaughtException', onUncaught);
112
113
 
@@ -150,7 +151,7 @@ export async function startColocatedHost(
150
151
  stop: async () => {
151
152
  // Every stop runs, even when a sibling hangs or throws. Host shutdown
152
153
  // still resolves within its budget so the owning runtime can retire it.
153
- for (const error of await stop()) onEvent({ twin: '(host)', kind: 'stop-failed', detail: String(error) });
154
+ for (const error of await stop()) onEvent({ twin: '(host)', kind: 'stop-failed', detail: errorMessage(error) });
154
155
  },
155
156
  };
156
157
  }
@@ -178,7 +179,7 @@ export async function startColocatedHost(
178
179
  }
179
180
  });
180
181
  worker.on('error', (err) => {
181
- onEvent({ twin: spec.id, kind: 'error', detail: String(err) });
182
+ onEvent({ twin: spec.id, kind: 'error', detail: errorMessage(err) });
182
183
  if (!ready) rejectReady(err);
183
184
  });
184
185
  worker.on('exit', (code) => {
package/src/index.ts CHANGED
@@ -177,5 +177,6 @@ export { CONSOLE_BASE, consoleRedirect, serveConsoleApart, serveConsoleFor, type
177
177
  export { assertWorldStateRemovable } from './ancestry.ts';
178
178
 
179
179
  export { withoutVaultSignIn } from './runtime.ts';
180
+ export { errorMessage } from './error-message.ts';
180
181
 
181
182
  export { materializeBrowserFiles, browsersDir, worldBrowser, worldBrowsers, putWorldBrowser, deleteWorldBrowser, inheritWorldBrowsers, type WorldBrowser } from './browsers.ts';
package/src/infra-cli.ts CHANGED
@@ -2,9 +2,13 @@
2
2
  // `volter-world-infra up|status|down`: the World-managed infrastructure's declared lifecycle commands (infra.ts), as
3
3
  // a process. World configs invoke this declared service; application agents never operate or diagnose its backing.
4
4
  import { runInfrastructure } from './infra.ts';
5
+ import { errorMessage } from './error-message.ts';
5
6
 
6
7
  const code = await runInfrastructure(process.argv[2] ?? '', process.env, {
7
8
  out: (text) => { process.stdout.write(text); },
8
9
  err: (text) => { process.stderr.write(text); },
10
+ }).catch(error => {
11
+ process.stderr.write(`managed infrastructure ${process.argv[2] ?? ''} failed: ${errorMessage(error)}\n`);
12
+ return 1;
9
13
  });
10
14
  process.exit(code);
package/src/infra.ts 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.ts';
16
+ import { errorMessage } from './error-message.ts';
15
17
 
16
18
  export interface InfrastructureIo { out: (text: string) => void; err: (text: string) => void }
17
19
 
@@ -24,10 +26,14 @@ function runCommand(command: string, args: string[], options: { env: NodeJS.Proc
24
26
  return new Promise((resolveRan) => {
25
27
  let stdout = ''; let stderr = ''; let settled = false;
26
28
  let grace: ReturnType<typeof setTimeout> | undefined;
27
- const child = spawn(command, args, { windowsHide: true, env: options.env, stdio: ['ignore', 'pipe', 'pipe'], ...(options.signal ? { signal: options.signal } : {}) });
29
+ const child = spawn(command, args, { windowsHide: true, env: options.env, stdio: ['ignore', 'pipe', 'pipe'] });
30
+ const abort = () => {
31
+ try { if (child.pid) signalChild(child, 'SIGTERM'); }
32
+ catch (error) { settle({ status: null, stdout, stderr, error: error as Error }); }
33
+ };
28
34
  const settle = (ran: Ran) => {
29
35
  if (settled) return;
30
- settled = true; clearTimeout(timer); if (grace) clearTimeout(grace);
36
+ settled = true; options.signal?.removeEventListener('abort', abort); clearTimeout(timer); if (grace) clearTimeout(grace);
31
37
  // a grandchild that still holds the pipes does not hold this answer
32
38
  child.stdout?.destroy(); child.stderr?.destroy();
33
39
  resolveRan(ran);
@@ -35,7 +41,8 @@ function runCommand(command: string, args: string[], options: { env: NodeJS.Proc
35
41
  // the timeout is a hard limit and answers as spawnSync's did: the command is killed and the result carries an
36
42
  // ETIMEDOUT error (a hung `docker info`, a daemon that never answers, is then no container runtime, as before)
37
43
  const timer = setTimeout(() => {
38
- child.kill('SIGTERM');
44
+ try { if (child.pid) signalChild(child, 'SIGTERM'); }
45
+ catch (error) { settle({ status: null, stdout, stderr, error: error as Error }); return; }
39
46
  settle({ status: null, stdout, stderr, error: Object.assign(new Error(`spawn ${command} ETIMEDOUT`), { code: 'ETIMEDOUT' }) });
40
47
  }, options.timeout);
41
48
  child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8'); });
@@ -46,6 +53,8 @@ function runCommand(command: string, args: string[], options: { env: NodeJS.Proc
46
53
  // answer by at most a moment, never to the timeout
47
54
  child.on('exit', (code) => { grace = setTimeout(() => settle({ status: code, stdout, stderr }), 500); });
48
55
  child.on('close', (code) => settle({ status: code, stdout, stderr }));
56
+ options.signal?.addEventListener('abort', abort, { once: true });
57
+ if (options.signal?.aborted) abort();
49
58
  });
50
59
  }
51
60
 
@@ -83,31 +92,48 @@ export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, i
83
92
  : /cannot connect|not running|daemon/iu.test(raw)
84
93
  ? 'local execution capacity is unavailable'
85
94
  : 'managed infrastructure operation failed';
95
+ // The existing backend redaction precedes truncation so a cut cannot expose part of a secret.
96
+ const diagnostic = (raw: string): string => {
97
+ let detail = raw;
98
+ const secrets = Object.entries(env).filter(([name, value]) => value && value.length >= 4 && /secret|token|password|credential|api_key|database_url/iu.test(name))
99
+ .map(([, value]) => value!).sort((a, b) => b.length - a.length);
100
+ for (const secret of secrets) detail = detail.split(secret).join('[redacted]');
101
+ detail = detail.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/@]+(?::[^\s/@]*)?@/giu, '$1[redacted]@');
102
+ return detail.split('\n').slice(-40).join('\n').slice(-8192);
103
+ };
86
104
  const fail = (result: Ran): number => {
87
- const raw = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
105
+ const raw = [result.stdout, result.stderr, result.error ? errorMessage(result.error) : undefined].filter(Boolean).join('\n').trim();
106
+ const detail = diagnostic(raw);
88
107
  io.err(`managed infrastructure ${phase} failed: ${classify(raw)}\n`);
108
+ if (detail) io.err(`backend diagnostic:\n${detail}\n`);
89
109
  return result.status && result.status > 0 ? result.status : 1;
90
110
  };
91
111
 
92
112
  // ---- backing selection ------------------------------------------------------
93
113
  // The declared service contract (up/status/down) is the boundary; WHICH
94
114
  // runtime answers it is private and chosen here, per machine, at each phase:
95
- // 1. VOLTER_WORLD_INFRA_BACKING=docker|pglite — explicit, for tests/operators;
115
+ // 1. VOLTER_WORLD_INFRA_BACKING=docker|pglite|native — explicit, for tests/operators;
96
116
  // 2. a working container runtime — the compose path, byte-identical to before;
97
117
  // 3. no container runtime + a definition of postgres and/or mongodb services —
98
118
  // the containerless backing (pglite-backing.ts: PGlite for postgres, the
99
- // MongoDB twin for mongodb), announced loudly;
119
+ // MongoDB's own server for mongodb), announced loudly;
100
120
  // 4. otherwise the honest refusal naming what this machine cannot serve.
101
- async function selectBacking(): Promise<'docker' | 'pglite' | number> {
121
+ async function selectBacking(): Promise<'docker' | 'pglite' | 'native' | number> {
102
122
  const forced = env.VOLTER_WORLD_INFRA_BACKING;
103
- if (forced === 'docker' || forced === 'pglite') return forced;
123
+ if (forced === 'docker' || forced === 'pglite' || forced === 'native') return forced;
104
124
  if (forced !== undefined) {
105
- io.err(`managed infrastructure: VOLTER_WORLD_INFRA_BACKING must be docker or pglite (got ${JSON.stringify(forced)})\n`);
125
+ io.err(`managed infrastructure: VOLTER_WORLD_INFRA_BACKING must be docker, pglite or native (got ${JSON.stringify(forced)})\n`);
106
126
  return 2;
107
127
  }
108
128
  const probe = await runCommand('docker', ['info', '--format', '{{.ServerVersion}}'], { env, timeout: 10_000, ...(signal ? { signal } : {}) });
109
129
  signal?.throwIfAborted();
110
- if (probe.status === 0) return 'docker';
130
+ if (probe.status === 0 && probe.stdout.trim()) return 'docker';
131
+ if (probe.status === 0) {
132
+ io.err('managed infrastructure: container runtime probe returned no server version; using containerless backing\n');
133
+ return 'pglite';
134
+ }
135
+ const probeOutput = [probe.stdout, probe.stderr, probe.error ? errorMessage(probe.error) : undefined].filter(Boolean).join('\n').trim();
136
+ if (probeOutput) io.err(`managed infrastructure: container runtime probe failed\n${probeOutput}\n`);
111
137
  // Absence of a container runtime (no binary, no daemon) is a capability
112
138
  // difference: swap backings. A PRESENT runtime failing on resources stays
113
139
  // on the docker path so the real capacity problem is classified and
@@ -127,7 +153,7 @@ export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, i
127
153
  return 1;
128
154
  }
129
155
  const unsupported = unsupportedKinds(services);
130
- if (unsupported.length > 0) {
156
+ if (phase !== 'down' && unsupported.length > 0) {
131
157
  io.err(`managed infrastructure ${phase} failed: local execution capacity is unavailable (no container runtime, and the containerless backing cannot serve: ${unsupported.join(', ')})\n`);
132
158
  return 1;
133
159
  }
@@ -136,7 +162,7 @@ export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, i
136
162
  try {
137
163
  await pgliteUp(services, worldData!, launch);
138
164
  } catch (error) {
139
- io.err(`managed infrastructure up failed: ${String((error as Error).message ?? error)}\n`);
165
+ io.err(`managed infrastructure up failed: ${diagnostic(errorMessage(error))}\n`);
140
166
  return 1;
141
167
  }
142
168
  io.out('managed infrastructure ready\n');
@@ -166,7 +192,26 @@ export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, i
166
192
  const twinCli = redisTwinCli([dirname(fileURLToPath(import.meta.url)), dirname(worldConfig)]);
167
193
  const twinned = env.VOLTER_WORLD_INFRA_BACKING === 'docker' || twinCli === undefined ? [] : declared.filter((s) => s.kind === 'redis');
168
194
  /** 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);
195
+ const native = declared.filter((s) => s.kind === 'mongodb');
196
+ const composed = twinned.length === 0 && native.length === 0 ? null : declared.filter((s) => !twinned.includes(s) && !native.includes(s)).map((s) => s.kind);
197
+ // A database's native wire and storage are its vendor server's. MongoDB never needs a Docker probe or container.
198
+ if (native.length > 0) {
199
+ const { pgliteUp, pgliteStatus, pgliteDown } = await import('./pglite-backing.ts');
200
+ try {
201
+ if (phase === 'up') {
202
+ io.out('managed infrastructure backing: MongoDB server (containerless)\n');
203
+ await pgliteUp(native, worldData!, launch);
204
+ } else if (phase === 'status') {
205
+ const status = await pgliteStatus(native, worldData!);
206
+ if (!status.ok) throw new Error(`${status.ready}/${native.length} MongoDB services are ready`);
207
+ } else {
208
+ await pgliteDown(native, worldData!);
209
+ }
210
+ } catch (error) {
211
+ io.err(`managed infrastructure ${phase} failed: ${diagnostic(errorMessage(error))}\n`);
212
+ return 1;
213
+ }
214
+ }
170
215
  if (twinned.length > 0) {
171
216
  const { redisTwinDown, redisTwinStatus, redisTwinUp } = await import('./redis-backing.ts');
172
217
  const cli = twinCli!;
@@ -175,7 +220,7 @@ export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, i
175
220
  try {
176
221
  await redisTwinUp(twinned, worldData!, cli, launch);
177
222
  } catch (error) {
178
- io.err(`managed infrastructure up failed: ${String((error as Error).message ?? error)}\n`);
223
+ io.err(`managed infrastructure up failed: ${diagnostic(errorMessage(error))}\n`);
179
224
  return 1;
180
225
  }
181
226
  } else if (phase === 'status') {
@@ -187,17 +232,53 @@ export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, i
187
232
  } else {
188
233
  await redisTwinDown(twinned, worldData!);
189
234
  }
190
- if (composed!.length === 0) {
191
- if (phase === 'up') io.out('managed infrastructure ready\n');
192
- else if (phase === 'status') io.out(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, env) })}\n`);
193
- else io.out('managed infrastructure stopped\n');
194
- return 0;
195
- }
235
+ }
236
+ if (composed?.length === 0) {
237
+ if (phase === 'up') io.out('managed infrastructure ready\n');
238
+ else if (phase === 'status') io.out(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, env) })}\n`);
239
+ else io.out('managed infrastructure stopped\n');
240
+ return 0;
196
241
  }
197
242
 
198
243
  const backing = await selectBacking();
199
244
  if (typeof backing === 'number') return backing;
200
245
  if (backing === 'pglite') return runPglite();
246
+ if (backing === 'native') {
247
+ const { nativePostgresUp, nativePostgresStatus, nativePostgresDown } = await import('./native-postgres-backing.ts');
248
+ const postgres = declared.filter(s => s.kind === 'postgres');
249
+ const others = declared.filter(s => s.kind !== 'postgres' && !twinned.includes(s));
250
+ const { unsupportedKinds, pgliteUp, pgliteStatus, pgliteDown } = await import('./pglite-backing.ts');
251
+ if (postgres.length !== 1 || unsupportedKinds(others).length) {
252
+ io.err('managed infrastructure native backing requires one postgres and only supported containerless services alongside it\n');
253
+ return 1;
254
+ }
255
+ try {
256
+ if (phase === 'up') {
257
+ io.out('managed infrastructure backing: native PostgreSQL (multi-session)\n');
258
+ await nativePostgresUp(postgres, worldData, readFileSync(definition, 'utf8'), launch);
259
+ try { await pgliteUp(others, worldData, launch); }
260
+ catch (error) {
261
+ try { await nativePostgresDown(worldData); }
262
+ catch (cleanup) { throw new AggregateError([error, cleanup], 'managed infrastructure startup and native cleanup failed'); }
263
+ throw error;
264
+ }
265
+ io.out('managed infrastructure ready\n');
266
+ } else if (phase === 'status') {
267
+ const ready = others.length === 0 || (await pgliteStatus(others, worldData)).ok;
268
+ if (!ready || !await nativePostgresStatus(worldData)) return 1;
269
+ io.out(`${JSON.stringify({ ok: true, services: declared.length, connections: infraConnections(declared, env) })}\n`);
270
+ } else {
271
+ await pgliteDown(others, worldData);
272
+ await nativePostgresDown(worldData);
273
+ io.out('managed infrastructure stopped\n');
274
+ }
275
+ return 0;
276
+ } catch (error) {
277
+ io.err(`managed infrastructure ${phase} failed: ${diagnostic(errorMessage(error))}\n`);
278
+ return 1;
279
+ }
280
+ }
281
+
201
282
 
202
283
  const only = composed ?? [];
203
284
  if (phase === 'up') {
@@ -212,10 +293,10 @@ export async function runInfrastructure(phase: string, env: NodeJS.ProcessEnv, i
212
293
  const expectedNames = expected.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
213
294
  const runningNames = running.stdout.split(/\s+/u).filter(Boolean).filter((n) => composed === null || composed.includes(n)).sort();
214
295
  if (expectedNames.length === 0 || expectedNames.join('\0') !== runningNames.join('\0')) {
215
- io.err(`managed infrastructure status failed: ${runningNames.length + twinned.length}/${expectedNames.length + twinned.length} declared services are ready\n`);
296
+ io.err(`managed infrastructure status failed: ${runningNames.length + twinned.length + native.length}/${expectedNames.length + twinned.length + native.length} declared services are ready\n`);
216
297
  return 1;
217
298
  }
218
- io.out(`${JSON.stringify({ ok: true, services: expectedNames.length + twinned.length, connections: infraConnections(declared, env) })}\n`);
299
+ io.out(`${JSON.stringify({ ok: true, services: expectedNames.length + twinned.length + native.length, connections: infraConnections(declared, env) })}\n`);
219
300
  } else {
220
301
  const result = await run(['down', '--remove-orphans']);
221
302
  if (result.status !== 0) return fail(result);
package/src/init.ts CHANGED
@@ -55,12 +55,13 @@ import { overlayEndpointEnv, packFacts } from './pack-facts.ts';
55
55
  import { VENDOR_SERVER_BACKINGS } from './backings.ts';
56
56
  import { APP_URL_ENV, DEFAULT_WORLD_SELECTION, ISSUE_ENV_PREFIX, MINT_ENV, worldConfigDocument, worldSelectionIncludes, type WorldConfig, type WorldSelection, type WorldServiceConfig } from './schema.ts';
57
57
  import { loadWorldConfig } from './configs.ts';
58
+ import { ownedPath } from './owned-path.ts';
58
59
 
59
- /** The names a pack's credential door fills with a file it writes (`file:<field>`, the descriptor's): a path, never a value. */
60
+ /** Credential artifacts (files or directories) hold paths, never invented credential values. */
60
61
  function fileCredentialNames(): Set<string> {
61
62
  const out = new Set<string>();
62
63
  try {
63
- for (const facts of Object.values(packFacts())) for (const [name, spec] of Object.entries(facts?.credentialDoor?.fill ?? {})) if (String(spec).startsWith('file:')) out.add(name);
64
+ for (const facts of Object.values(packFacts())) for (const [name, spec] of Object.entries(facts?.credentialDoor?.fill ?? {})) if (/^(file|directory):/.test(String(spec))) out.add(name);
64
65
  } catch { /* no artifact: no file doors known */ }
65
66
  return out;
66
67
  }
@@ -497,8 +498,8 @@ const COMPOSE_INFRA: Record<string, {
497
498
  volumePath: '/data',
498
499
  healthcheck: () => ['CMD', 'redis-cli', 'ping'],
499
500
  },
500
- // Without a container runtime the containerless backing serves this kind with the MongoDB twin
501
- // (@volter/twin-mongodb), at the same loopback port and URL.
501
+ // The runtime serves this kind with MongoDB's own mongod on PATH, without a container,
502
+ // at the same loopback port and URL; command: ["mongod", "--replSet", "world"] enables transactions.
502
503
  mongodb: {
503
504
  image: 'mongo:7',
504
505
  containerPort: 27017,
@@ -726,10 +727,11 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
726
727
  // this catalog lacks
727
728
  const indexDir = findInstalledPackage(root, CATALOG_INDEX_PACKAGE);
728
729
  const indexDefaults = (() => { try { return indexDir === undefined ? {} : catalogDefaults(indexDir); } catch { return {}; } })();
729
- // Pack defaults (defaults/handlers.json, defaults/seed.ts): copied VISIBLY into the world
730
- // dir — the author's to edit or delete (copy = ownership; never baked into the pack).
730
+ // A pack's starters, copied VISIBLY into the world dir: the author's to edit or delete (copy = ownership; never
731
+ // baked into the pack). Its handlers starter is the answering turns its own life scripts (journeys/scenario.json, the
732
+ // World's handler format, its faults left out); its default data is src/semantics/seed.ts.
731
733
  const defaultsCopies: InitPlan['defaultsCopies'] = [];
732
- const signals = options.vendors ? null : detectRepoVendors(repo);
734
+ const signals = options.vendors ? null : detectRepoVendors(repo, existing?.services);
733
735
  const detected = options.vendors
734
736
  ? new Map<string, string[]>(options.vendors.map((v) => [v, ['--twins']]))
735
737
  : new Map([...signals!.detected].filter(([vendor]) => (signals!.uses.get(vendor) ?? []).some((use) => worldSelectionIncludes(selection, vendor, use.usage))));
@@ -752,6 +754,7 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
752
754
  for (const name of endpointNames(service)) claimedEnv.set(name, service.id);
753
755
  }
754
756
  for (const vendor of [...detected.keys()].sort()) {
757
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(vendor)) throw new Error('Invalid service id');
755
758
  const detectedVia = [...(detected.get(vendor) ?? [])].sort();
756
759
  const backing = catalog.has(vendor) ? undefined : VENDOR_SERVER_BACKINGS[vendor];
757
760
  if (backing !== undefined) {
@@ -788,26 +791,29 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
788
791
  : `${wiring.note} CONFLICT: ${wiring.injectEnv} is already claimed by the "${conflict}" service, so this twin is emitted `
789
792
  + `without it — one world cannot point ${wiring.injectEnv} at two twins. Decide which vendor that var serves, or run them in separate worlds.`;
790
793
  const packDir = catalogSource.packDir(vendor)!;
791
- const defaultsHandlersPath = join(packDir, 'defaults', 'handlers.json');
792
- const handlersTo = existsSync(defaultsHandlersPath) ? resolve(out, 'handlers', `${vendor}.json`) : null;
793
- if (handlersTo !== null) defaultsCopies.push({ vendor, kind: 'handlers', from: defaultsHandlersPath, to: handlersTo });
794
+ // the starter is the life's handlers that answer; its faults (an overload, a dropped turn) are the life's own tests and
795
+ // would fail a new World's first calls, so they stay behind
796
+ const starterHandlers = ((): string | null => {
797
+ try {
798
+ const doc = JSON.parse(readFileSync(join(packDir, 'journeys', 'scenario.json'), 'utf8')) as { handlers?: Array<Record<string, unknown>> };
799
+ const answering = (doc.handlers ?? []).filter((h) => h.fault === undefined);
800
+ return answering.length ? `${JSON.stringify({ ...doc, handlers: answering }, null, 2)}\n` : null;
801
+ } catch { return null; }
802
+ })();
803
+ const handlersTo = starterHandlers !== null ? ownedPath(out, `handlers/${vendor}.json`) : null;
804
+ if (handlersTo !== null) defaultsCopies.push({ vendor, kind: 'handlers', content: starterHandlers!, to: handlersTo });
794
805
  const handlersArg = handlersTo === null ? null : carried(handlersTo);
795
- const defaultsSeedPath = join(packDir, 'defaults', 'seed.ts');
796
- // a protocol-3 pack's default data (src/semantics/seed.ts: calls to the vendor's own operations, as data) is copied
797
- // beside a runner that makes each call against the twin, both the author's from then on
806
+ // a pack's default data (src/semantics/seed.ts: calls to the vendor's own operations, as data) is copied beside a
807
+ // runner that makes each call against the twin, both the author's from then on
798
808
  const semanticsSeedPath = join(packDir, 'src', 'semantics', 'seed.ts');
799
- if (existsSync(defaultsSeedPath)) defaultsCopies.push({ vendor, kind: 'seed', from: defaultsSeedPath, to: resolve(out, 'seeds', 'defaults', `${vendor}.ts`) });
800
- else if (existsSync(semanticsSeedPath)) {
801
- defaultsCopies.push({ vendor, kind: 'seed-data', from: semanticsSeedPath, to: resolve(out, 'seeds', 'defaults', `${vendor}.data.ts`) });
802
- defaultsCopies.push({ vendor, kind: 'seed', content: seedCallsRunner(vendor), to: resolve(out, 'seeds', 'defaults', `${vendor}.ts`) });
809
+ if (existsSync(semanticsSeedPath)) {
810
+ defaultsCopies.push({ vendor, kind: 'seed-data', from: semanticsSeedPath, to: ownedPath(out, `seeds/defaults/${vendor}.data.ts`) });
811
+ defaultsCopies.push({ vendor, kind: 'seed', content: seedCallsRunner(vendor), to: ownedPath(out, `seeds/defaults/${vendor}.ts`) });
803
812
  }
804
813
  // COLOCATION (runtime contract R2a): a pack whose serve factory is known (derived into the
805
814
  // pack-facts artifact) is emitted with `colocate`, so `up` boots it inside the ONE
806
815
  // volter-world-host process instead of spawning a process per twin. The spawn `command`
807
- // stays alongside as the process-isolation path. A service carrying a --scenario file
808
- // keeps the spawn path for now: the colocate factory contract has no scenarioPath yet,
809
- // and silently dropping the world's handlers would be a fake success.
810
- // A service carrying a handlers file colocates too (R2a): the colocate contract carries
816
+ // stays alongside as the process-isolation path. A service carrying a handlers file colocates too (R2a): the colocate contract carries
811
817
  // `scenarioPath` and the host hands it to the factory — the same file the spawn path passes
812
818
  // as --scenario.
813
819
  const serveExport = packFacts()[vendor]?.serveExport;
@@ -898,7 +904,7 @@ export function planWorldInit(name: string, repoPath: string, options: InitOptio
898
904
  ...services.filter((service) => !existingIds.has(service.id)),
899
905
  ].flatMap(endpointNames));
900
906
  // Every name a twin of this World issues (its pack's credentialDoor fill), by the twin: the pack names them, so a name
901
- // is issued whatever its stem (PlanetScale's DATABASE_URL, Vercel's TEAM_ID_VERCEL, Upstash's KV_REST_API_TOKEN) once
907
+ // is issued whatever its stem (Vercel's TEAM_ID_VERCEL, Upstash's KV_REST_API_TOKEN) once
902
908
  // that vendor's twin is in the World, and never when it is not.
903
909
  const issuedBy = new Map<string, string>();
904
910
  for (const service of [...(existing?.services ?? []).map(withIssues), ...services.filter((service) => !existingIds.has(service.id))]) {
@@ -1286,6 +1292,8 @@ export function writeWorldInit(plan: InitPlan, options: { force?: boolean } = {}
1286
1292
  + 'Pass --force to regenerate it, or --out <dir> to emit elsewhere.',
1287
1293
  );
1288
1294
  }
1295
+ // Recheck the planned name-derived paths before any output is written.
1296
+ for (const copy of plan.defaultsCopies) ownedPath(plan.out, relative(plan.out, copy.to));
1289
1297
  mkdirSync(plan.out, { recursive: true });
1290
1298
  writeFileSync(plan.configPath, `${JSON.stringify(worldConfigDocument(plan.config), null, 2)}\n`);
1291
1299
  if (plan.inRepo) writeFileSync(join(plan.out, '.gitignore'), STATE_GITIGNORE);
@@ -1325,6 +1333,7 @@ export function seedCallsRunner(vendor: string): string {
1325
1333
  const stem = vendor.toUpperCase().replace(/[^A-Z0-9]/g, '_');
1326
1334
  return `// ${vendor}'s default data, as its twin's pack declares it (./${vendor}.data.ts: calls to the vendor's own
1327
1335
  // operations), made against the twin in order. Ownership transferred on copy: both files are yours — edit or delete.
1336
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
1328
1337
  import { seed as calls } from './${vendor}.data.ts';
1329
1338
  import * as defaults from './${vendor}.data.ts';
1330
1339
  import type { SeedBrowser } from '@volter/world-core/browser';
@@ -1334,6 +1343,29 @@ export async function seed(): Promise<void> {
1334
1343
  if ((process.env.VOLTER_SEED_SKIP ?? '').split(',').includes('${vendor}')) return;
1335
1344
  const base = process.env.${stem}_TWIN_URL;
1336
1345
  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)');
1346
+ <<<<<<< HEAD
1347
+ // a call dated before the World's time (\`at\`: state the account made then, through the API as it was) is made with
1348
+ // the World's clock frozen at that instant, and the clock is put back as it was found
1349
+ const clock = process.env.TWIN_WORLD_CLOCK_FILE;
1350
+ const found = clock && existsSync(clock) ? readFileSync(clock, 'utf8') : undefined;
1351
+ try {
1352
+ for (const call of calls) {
1353
+ if (call.at !== undefined) {
1354
+ 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');
1355
+ writeFileSync(clock, \`\${new Date(call.at).toISOString()}\n\`);
1356
+ }
1357
+ const res = await fetch(\`\${base.replace(/\\/$/, '')}\${call.path}\`, {
1358
+ method: call.method,
1359
+ headers: { ...(call.body !== undefined ? { 'content-type': 'application/json' } : {}), ...(call.headers ?? {}) },
1360
+ ...(call.body !== undefined ? { body: JSON.stringify(call.body) } : {}),
1361
+ });
1362
+ if (!res.ok) throw new Error(\`${vendor} seed: \${call.method} \${call.path}: \${res.status} \${await res.text()}\`);
1363
+ }
1364
+ } finally {
1365
+ if (clock && calls.some((call) => call.at !== undefined)) {
1366
+ if (found === undefined) rmSync(clock, { force: true }); else writeFileSync(clock, found);
1367
+ }
1368
+ =======
1337
1369
  const clients = new Map<string, SeedBrowser>();
1338
1370
  const snapshots = new Map<string, Awaited<ReturnType<SeedBrowser['state']>>>();
1339
1371
  let browserHelper: typeof import('@volter/world-core/browser') | undefined;
@@ -1370,6 +1402,7 @@ export async function seed(): Promise<void> {
1370
1402
  headers, ...(body === undefined ? {} : { body }),
1371
1403
  });
1372
1404
  if (!res.ok) throw new Error(\`${vendor} seed: \${call.method} \${call.path}: \${res.status} \${await res.text()}\`);
1405
+ >>>>>>> origin/main
1373
1406
  }
1374
1407
  }
1375
1408
  `;
@@ -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';
@@ -14,10 +14,14 @@ export type WorldLifecycleRecord = {
14
14
  };
15
15
 
16
16
  const directory = () => join(volterHome(), 'world-lifecycles');
17
- const identity = (root: string, world: string) => createHash('sha256').update(`${resolve(root)}\0${world}`).digest('hex');
17
+ /** One directory is one root however it is spelled: macOS's /tmp is /private/tmp, and a caller's cwd is the real path
18
+ * while its argument may be the alias. An identity hashed from the spelling made doctor report a running World's
19
+ * ownership missing when the World was started under /tmp and inspected from /private/tmp. */
20
+ const canonical = (root: string): string => { try { return realpathSync(root); } catch { return resolve(root); } };
21
+ const identity = (root: string, world: string) => createHash('sha256').update(`${canonical(root)}\0${world}`).digest('hex');
18
22
  const recordPath = (root: string, world: string) => join(directory(), `${identity(root, world)}.json`);
19
23
  const legacyPath = (root: string, world: string) => join(volterHome(), 'world-resource-claims', `${identity(root, world)}.json`);
20
- export const lifecycleLogPath = (root: string, world: string) => join(resolve(root), stateDirName(), 'worlds', '.lifecycle', `${world}.log`);
24
+ export const lifecycleLogPath = (root: string, world: string) => join(canonical(root), stateDirName(), 'worlds', '.lifecycle', `${world}.log`);
21
25
 
22
26
  function read(path: string): WorldLifecycleRecord {
23
27
  const record = JSON.parse(readFileSync(path, 'utf8')) as WorldLifecycleRecord;
@@ -52,7 +56,7 @@ export function beginWorldLifecycle(root: string, world: string, instanceCreated
52
56
  checkedOwner(owner);
53
57
  assertLifecycleStartable(root, world);
54
58
  const record: WorldLifecycleRecord = {
55
- version: 1, world, root: resolve(root), identity: identity(root, world), instanceCreatedAt,
59
+ version: 1, world, root: canonical(root), identity: identity(root, world), instanceCreatedAt,
56
60
  owner, ownerUid: process.getuid?.(), ownerPid: process.pid, hostname: hostname(),
57
61
  startedAt: new Date().toISOString(), phase: 'starting', pids: [], log: lifecycleLogPath(root, world),
58
62
  };
@@ -87,7 +91,7 @@ export function recordWorldLifecycleEvent(root: string, world: string, message:
87
91
  }
88
92
 
89
93
  /** Called only after verified cleanup. Never touches legacy records. */
90
- export function releaseWorldLifecycle(root: string, world: string): void {
94
+ export function releaseWorldLifecycle(root: string, world: string, receipt = 'teardown verified; ownership released'): void {
91
95
  const path = recordPath(root, world);
92
96
  if (!existsSync(path)) return;
93
97
  withFileLock(`${path}.lock`, () => {
@@ -96,11 +100,22 @@ export function releaseWorldLifecycle(root: string, world: string): void {
96
100
  if (record.identity !== identity(root, world)) throw new Error('World lifecycle identity mismatch');
97
101
  // The receipt line is evidence, not a precondition: failing to append it must not keep ownership (and so the next
98
102
  // `up`) blocked after a verified teardown.
99
- try { recordWorldLifecycleEvent(root, world, 'teardown verified; ownership released'); } catch { /* released regardless */ }
103
+ try { recordWorldLifecycleEvent(root, world, receipt); } catch { /* released regardless */ }
100
104
  rmSync(path);
101
105
  });
102
106
  }
103
107
 
108
+ /** Explicit surrender must prove this generation's complete local inventory has exited. */
109
+ export function exitedWorldLifecyclePids(root: string, world: string, createdAt: string): number[] | undefined {
110
+ try {
111
+ const record = read(recordPath(root, world));
112
+ if (record.hostname !== hostname() || record.instanceCreatedAt !== createdAt
113
+ || (record.ownerUid !== undefined && record.ownerUid !== process.getuid?.())) return undefined;
114
+ const pids = [...new Set([record.ownerPid, ...record.pids])];
115
+ return pids.some(processExists) ? undefined : pids;
116
+ } catch { return undefined; }
117
+ }
118
+
104
119
  /**
105
120
  * Releases the record of a World whose instance metadata is gone when nothing it
106
121
  * 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.ts';
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';
@@ -189,8 +190,10 @@ export class LocalBranches {
189
190
  return { name, token: byKey ? await keyOfBranch(mounted, origin, byKey) : mounted.token, readToken: mounted.readToken, expiresAt };
190
191
  } catch (error) {
191
192
  this.o.worlds.delete(name);
192
- try { await mounted?.stop(); } catch { /* reported below */ }
193
- withStateRemoval(root, () => rmSync(root, { recursive: true, force: true }));
193
+ try { await mounted?.stop(); }
194
+ catch (cleanup) { throw new AggregateError([error, cleanup], 'Branch startup and host cleanup failed; state retained'); }
195
+ try { withStateRemoval(root, () => rmSync(root, { recursive: true, force: true })); }
196
+ catch (cleanup) { throw new AggregateError([error, cleanup], 'Branch startup and state cleanup failed'); }
194
197
  throw error;
195
198
  }
196
199
  }
@@ -230,7 +233,7 @@ export class LocalBranches {
230
233
  for (const [name, world] of [...this.o.worlds]) {
231
234
  const r = this.record(world);
232
235
  if (r?.expiresAt && Date.parse(r.expiresAt) <= now && this.o.worlds.has(name)) {
233
- try { await this.remove(name); } catch (error) { this.o.announce?.(`branch ${name}: removal failed: ${error instanceof Error ? error.message : String(error)}`); }
236
+ try { await this.remove(name); } catch (error) { this.o.announce?.(`branch ${name}: removal failed: ${errorMessage(error)}`); }
234
237
  }
235
238
  }
236
239
  } finally { this.sweeping = false; }