@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.
- package/dist/known-external-services.json +3 -10
- package/dist/src/actions-runner-cli.js +3 -3
- package/dist/src/actions-runner.js +2 -1
- package/dist/src/app-url.js +2 -1
- package/dist/src/backings.d.ts +10 -1
- package/dist/src/backings.js +66 -7
- package/dist/src/branch.js +85 -52
- package/dist/src/browser-routes.cjs +59 -0
- package/dist/src/ca-trust.js +4 -3
- package/dist/src/changeset.js +4 -3
- package/dist/src/cli-commands.js +25 -9
- package/dist/src/cli.js +2 -1
- package/dist/src/command-lifetime.d.mts +12 -0
- package/dist/src/command-lifetime.d.ts +1 -15
- package/dist/src/command-lifetime.js +1 -98
- package/dist/src/command-lifetime.mjs +90 -0
- package/dist/src/configs.js +1 -1
- package/dist/src/console-apart.js +3 -2
- package/dist/src/covers.d.ts +3 -2
- package/dist/src/covers.js +95 -18
- package/dist/src/error-message.d.mts +1 -0
- package/dist/src/error-message.d.ts +1 -0
- package/dist/src/error-message.js +2 -0
- package/dist/src/error-message.mjs +10 -0
- package/dist/src/guest-command.d.mts +4 -0
- package/dist/src/guest-command.mjs +26 -0
- package/dist/src/guest-control.d.mts +1 -0
- package/dist/src/guest-control.mjs +17 -0
- package/dist/src/guest-entry.mjs +146 -0
- package/dist/src/guest-processes.d.mts +9 -0
- package/dist/src/guest-processes.mjs +47 -0
- package/dist/src/guest-runner.d.ts +6 -0
- package/dist/src/guest-runner.js +567 -0
- package/dist/src/host.js +5 -4
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/infra-cli.js +4 -0
- package/dist/src/infra.js +133 -23
- package/dist/src/init.js +60 -23
- package/dist/src/lifecycle-record.d.ts +3 -1
- package/dist/src/lifecycle-record.js +29 -6
- package/dist/src/livekit-backing.mjs +37 -0
- package/dist/src/local-branches.js +11 -3
- package/dist/src/mongodb-backing.d.ts +7 -0
- package/dist/src/mongodb-backing.js +58 -0
- package/dist/src/native-postgres-backing.d.ts +4 -0
- package/dist/src/native-postgres-backing.js +404 -0
- package/dist/src/origin.js +2 -1
- package/dist/src/owned-path.d.ts +4 -0
- package/dist/src/owned-path.js +37 -0
- package/dist/src/pack-facts.d.ts +1 -0
- package/dist/src/pglite-backing.d.ts +4 -13
- package/dist/src/pglite-backing.js +198 -106
- package/dist/src/pglite-host.mjs +47 -17
- package/dist/src/placeholder.d.ts +3 -2
- package/dist/src/placeholder.js +9 -4
- package/dist/src/process-groups.d.mts +2 -0
- package/dist/src/process-groups.d.ts +1 -4
- package/dist/src/process-groups.js +1 -49
- package/dist/src/process-groups.mjs +59 -0
- package/dist/src/process-receipt.d.mts +6 -0
- package/dist/src/process-receipt.d.ts +11 -0
- package/dist/src/process-receipt.js +3 -0
- package/dist/src/process-receipt.mjs +45 -0
- package/dist/src/process-signal.d.mts +9 -0
- package/dist/src/process-signal.mjs +92 -0
- package/dist/src/project-inspect.d.ts +1 -1
- package/dist/src/project-inspect.js +4 -2
- package/dist/src/proxy-daemon.js +2 -1
- package/dist/src/redirect-proxy.js +186 -19
- package/dist/src/redis-backing.js +99 -35
- package/dist/src/reflect.js +2 -1
- package/dist/src/root.js +7 -6
- package/dist/src/run-task-worker.js +2 -1
- package/dist/src/runtime.d.ts +24 -7
- package/dist/src/runtime.js +703 -286
- package/dist/src/schema.d.ts +12 -1
- package/dist/src/schema.js +20 -5
- package/dist/src/served-world.d.ts +1 -0
- package/dist/src/served-world.js +26 -14
- package/dist/src/service-recorder.js +8 -3
- package/dist/src/signals.d.mts +1 -0
- package/dist/src/signals.d.ts +1 -1
- package/dist/src/signals.js +1 -11
- package/dist/src/signals.mjs +10 -0
- package/dist/src/tail.d.ts +2 -0
- package/dist/src/tail.js +9 -2
- package/dist/src/tcp-port.js +27 -13
- package/dist/src/temporal-backing.mjs +28 -0
- package/dist/src/up-cli.js +8 -3
- package/dist/src/up-task-worker.js +2 -1
- package/dist/src/websocket-relay.d.ts +1 -1
- package/dist/src/websocket-relay.js +23 -10
- package/dist/src/world-view.js +2 -2
- package/known-external-services.json +3 -10
- package/package.json +12 -7
- package/src/actions-runner-cli.ts +3 -3
- package/src/actions-runner.ts +2 -1
- package/src/app-url.ts +2 -1
- package/src/backings.ts +65 -7
- package/src/branch.ts +73 -34
- package/src/browser-routes.cjs +59 -0
- package/src/ca-trust.ts +4 -3
- package/src/changeset.ts +4 -3
- package/src/cli-commands.ts +21 -9
- package/src/cli.ts +2 -1
- package/src/command-lifetime.d.mts +12 -0
- package/src/command-lifetime.mjs +90 -0
- package/src/command-lifetime.ts +1 -79
- package/src/configs.ts +1 -1
- package/src/console-apart.ts +2 -1
- package/src/covers.ts +97 -19
- package/src/error-message.d.mts +1 -0
- package/src/error-message.mjs +10 -0
- package/src/error-message.ts +2 -0
- package/src/guest-command.d.mts +4 -0
- package/src/guest-command.mjs +26 -0
- package/src/guest-control.d.mts +1 -0
- package/src/guest-control.mjs +17 -0
- package/src/guest-entry.mjs +146 -0
- package/src/guest-processes.d.mts +9 -0
- package/src/guest-processes.mjs +47 -0
- package/src/guest-runner.ts +414 -0
- package/src/host.ts +5 -4
- package/src/index.ts +1 -0
- package/src/infra-cli.ts +4 -0
- package/src/infra.ts +103 -22
- package/src/init.ts +55 -22
- package/src/lifecycle-record.ts +21 -6
- package/src/livekit-backing.mjs +37 -0
- package/src/local-branches.ts +6 -3
- package/src/mongodb-backing.ts +55 -0
- package/src/native-postgres-backing.ts +329 -0
- package/src/origin.ts +2 -1
- package/src/owned-path.ts +32 -0
- package/src/pack-facts.ts +1 -0
- package/src/pglite-backing.ts +155 -79
- package/src/pglite-host.mjs +47 -17
- package/src/placeholder.ts +10 -5
- package/src/process-groups.d.mts +2 -0
- package/src/process-groups.mjs +59 -0
- package/src/process-groups.ts +1 -33
- package/src/process-receipt.d.mts +6 -0
- package/src/process-receipt.mjs +45 -0
- package/src/process-receipt.ts +5 -0
- package/src/process-signal.d.mts +9 -0
- package/src/process-signal.mjs +92 -0
- package/src/project-inspect.ts +4 -2
- package/src/proxy-daemon.ts +2 -1
- package/src/redirect-proxy.ts +153 -18
- package/src/redis-backing.ts +74 -25
- package/src/reflect.ts +2 -1
- package/src/root.ts +7 -6
- package/src/run-task-worker.ts +2 -1
- package/src/runtime.ts +596 -265
- package/src/schema.ts +30 -6
- package/src/served-world.ts +21 -15
- package/src/service-recorder.ts +4 -2
- package/src/signals.d.mts +1 -0
- package/src/signals.mjs +10 -0
- package/src/signals.ts +1 -10
- package/src/tail.ts +10 -3
- package/src/tcp-port.ts +24 -13
- package/src/temporal-backing.mjs +28 -0
- package/src/up-cli.ts +6 -3
- package/src/up-task-worker.ts +2 -1
- package/src/websocket-relay.ts +18 -11
- 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:
|
|
109
|
-
const onUncaught = (err: unknown) => onEvent({ twin: '(host)', kind: 'uncaughtException', detail:
|
|
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:
|
|
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:
|
|
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']
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
|
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: ${
|
|
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
|
|
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: ${
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
/**
|
|
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)
|
|
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
|
-
//
|
|
501
|
-
//
|
|
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
|
-
//
|
|
730
|
-
//
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
|
|
796
|
-
//
|
|
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(
|
|
800
|
-
|
|
801
|
-
defaultsCopies.push({ vendor, kind: 'seed
|
|
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
|
|
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 (
|
|
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
|
`;
|
package/src/lifecycle-record.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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:
|
|
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,
|
|
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); });
|
package/src/local-branches.ts
CHANGED
|
@@ -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(); }
|
|
193
|
-
|
|
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: ${
|
|
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; }
|