@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/cli-commands.ts
CHANGED
|
@@ -61,10 +61,10 @@ function printHelp(): void {
|
|
|
61
61
|
# emission. Exit 0 = ready to \`up\`; exit 1 = the honest worklist.
|
|
62
62
|
# Deterministic: same repo + same pack catalog => identical bytes.
|
|
63
63
|
# init DIAGNOSES; it never boots — it prints the exact next commands
|
|
64
|
-
volter-world up <config> --env-out=<path> [--owner <label>] [--name <name>] [--mode local|share|sealed] [--isolation process|colocated|worker] [--keep-state] [--root <repo>]
|
|
64
|
+
volter-world up <config> --env-out=<path> [--owner <label>] [--name <name>] [--mode local|share|sealed] [--isolation process|colocated|worker] [--keep-state] [--json] [--root <repo>]
|
|
65
65
|
volter-world outdated <world> [--json] — each mounted twin: pinned / mounted / catalog-current version and protocol standing (R17)
|
|
66
66
|
volter-world audit <world> [--json] — each mounted twin's grade, as the catalog records it (R17)
|
|
67
|
-
volter-world down <name> [--grace-ms <ms>] [--purge] [--root <repo>] # SIGTERM, then SIGKILL survivors after the grace (default 5000ms)
|
|
67
|
+
volter-world down <name> [--grace-ms <ms>] [--purge] [--if-instance <token>] [--root <repo>] # SIGTERM, then SIGKILL survivors after the grace (default 5000ms)
|
|
68
68
|
# --purge: after teardown, delete the instance dir (and per-service twin data) too — see docs/concepts/data-and-keys.md
|
|
69
69
|
volter-world status <name> [--json] [--root <repo>]
|
|
70
70
|
volter-world consumers retire <world> [--consumer <id>] [--root <repo>] [--json] # resolve attachments whose runner, command and process group are gone
|
|
@@ -98,14 +98,15 @@ function printHelp(): void {
|
|
|
98
98
|
# fails loudly with the registration recipe. A world that boots
|
|
99
99
|
# the app itself declares an "app" service (type "process") —
|
|
100
100
|
# its URL answers without any registration
|
|
101
|
-
volter-world log <name> [service...] [--no-follow] [--json] [--requests] [--root <repo>] # the log of changes (alias: tail)
|
|
101
|
+
volter-world log <name> [service...] [--no-follow] [--json] [--requests] [--logs] [--root <repo>] # the log of changes (alias: tail)
|
|
102
102
|
# merged, occurredAt-ordered feed of the twins' action ledgers — one
|
|
103
103
|
# \`HH:MM:SS.mmm <service> <operation> <subjectId>\` line per action;
|
|
104
104
|
# follows until Ctrl-C (--no-follow: dump and exit; --json: raw JSONL;
|
|
105
105
|
# --requests: also merge the request journals — reads/404s,
|
|
106
106
|
# plus WHICH credential arrived in WHICH header/query param, named +
|
|
107
107
|
# fingerprinted, never the value — kept by every twin and link
|
|
108
|
-
# unless VOLTER_TWIN_REQUEST_JOURNAL=0
|
|
108
|
+
# unless VOLTER_TWIN_REQUEST_JOURNAL=0; --logs: also read the services'
|
|
109
|
+
# process output verbatim, labeled stdout — not shape-only journals)
|
|
109
110
|
volter-world seed <world> [--entry <file>] [--cwd <dir>] [--json] [--root <repo>]
|
|
110
111
|
# pull from the placeholder remote: the seed entry runs with every twin's
|
|
111
112
|
# window open, so the defaults land as observed state (diff empty, nothing pending)
|
|
@@ -266,9 +267,16 @@ export async function main(): Promise<void> {
|
|
|
266
267
|
const graceArg = optionValue(rest, '--grace-ms');
|
|
267
268
|
if (graceArg && !(Number(graceArg) >= 0)) throw new Error(`volter-world down: invalid --grace-ms "${graceArg}" (want a non-negative number)`);
|
|
268
269
|
const purge = rest.includes('--purge');
|
|
270
|
+
if (rest.some(arg => arg === '--if-created-at' || arg.startsWith('--if-created-at='))) throw new Error('volter-world down: use --if-instance with the token returned by your own boot');
|
|
271
|
+
// absent flag: no ownership expectation (optionValue's '' fallback would read as an empty, refused token)
|
|
272
|
+
const ifInstance = rest.some(arg => arg === '--if-instance' || arg.startsWith('--if-instance='));
|
|
273
|
+
const expectedInstance = ifInstance ? optionValue(rest, '--if-instance') : undefined;
|
|
274
|
+
if (ifInstance
|
|
275
|
+
&& (!expectedInstance || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(expectedInstance))) throw new Error('volter-world down: invalid --if-instance token');
|
|
269
276
|
const result = await downWorld(subject, root, {
|
|
270
277
|
...(graceArg ? { graceMs: Number(graceArg) } : {}),
|
|
271
278
|
...(purge ? { purge: true } : {}),
|
|
279
|
+
...(expectedInstance === undefined ? {} : { expectedInstance }),
|
|
272
280
|
});
|
|
273
281
|
process.stdout.write(result.externalErrors.length ? `World ${result.name}: teardown incomplete; lifecycle evidence retained\n`
|
|
274
282
|
: `Stopped world ${result.name}${result.stopped.length ? `: ${result.stopped.join(', ')}` : ''}\n`);
|
|
@@ -278,6 +286,7 @@ export async function main(): Promise<void> {
|
|
|
278
286
|
for (const message of result.externalErrors) process.stderr.write(`External teardown failed: ${message}\n`);
|
|
279
287
|
}
|
|
280
288
|
if (result.purged) {
|
|
289
|
+
for (const message of result.externalAbandoned ?? []) process.stdout.write(`Surrendered unavailable backend record (external stop unverified): ${message}\n`);
|
|
281
290
|
process.stdout.write(`Purged instance data: ${result.purged}\n`);
|
|
282
291
|
} else if (purge && result.externalErrors.length) {
|
|
283
292
|
process.stdout.write(`Refused to purge instance data: an external service's teardown failed (see above) — its down command/discovered env must survive.\n`);
|
|
@@ -587,6 +596,7 @@ export async function main(): Promise<void> {
|
|
|
587
596
|
follow: !rest.includes('--no-follow'),
|
|
588
597
|
json: rest.includes('--json'),
|
|
589
598
|
requests: rest.includes('--requests'),
|
|
599
|
+
logs: rest.includes('--logs'),
|
|
590
600
|
signal: controller.signal,
|
|
591
601
|
});
|
|
592
602
|
return;
|
|
@@ -594,10 +604,12 @@ export async function main(): Promise<void> {
|
|
|
594
604
|
|
|
595
605
|
if (cmd === 'seed' || cmd === 'reset') {
|
|
596
606
|
if (!subject) throw new Error(`volter-world ${cmd}: missing world name (usage: volter-world ${cmd} <world> [--entry <file>] [--cwd <dir>])`);
|
|
607
|
+
if (rest.some(arg => arg === '--env-file' || arg.startsWith('--env-file='))) throw new Error('--env-file is not an input for this command. Reset accepts --env-out=<new-output-path>.');
|
|
608
|
+
const envFile = optionValue(rest, '--env-out') || undefined;
|
|
597
609
|
const entry = optionValue(rest, '--entry') || undefined;
|
|
598
610
|
const cwd = optionValue(rest, '--cwd') || undefined;
|
|
599
611
|
const json = rest.includes('--json');
|
|
600
|
-
const outcome = cmd === 'seed' ? await seedWorld(subject, { root, ...(entry ? { entry } : {}), ...(cwd ? { cwd } : {}) }) : await resetWorld(subject, { root, ...(entry ? { entry } : {}), ...(cwd ? { cwd } : {}) });
|
|
612
|
+
const outcome = cmd === 'seed' ? await seedWorld(subject, { root, ...(entry ? { entry } : {}), ...(cwd ? { cwd } : {}) }) : await resetWorld(subject, { root, ...(envFile ? { envFile } : {}), ...(entry ? { entry } : {}), ...(cwd ? { cwd } : {}) });
|
|
601
613
|
if (json) process.stdout.write(`${JSON.stringify({ world: outcome.world, entry: outcome.entry, exitCode: outcome.exitCode, observed: outcome.observed }, null, 2)}\n`);
|
|
602
614
|
else process.stdout.write(`${cmd === 'reset' ? 'Reset' : 'Seeded'} world ${subject} from the placeholder remote (${outcome.entry})\n${Object.entries(outcome.observed).map(([s, n]) => ` ${s}: ${n} observed`).join('\n')}\n${outcome.exitCode === 0 ? '' : ` seed entry exited ${outcome.exitCode}\n`}`);
|
|
603
615
|
process.exitCode = outcome.exitCode === 0 ? 0 : 1;
|
|
@@ -610,14 +622,14 @@ export async function main(): Promise<void> {
|
|
|
610
622
|
if (!subject || !name) throw new Error('volter-world branch: usage: volter-world branch <world> <name> [--env-out=<path>]');
|
|
611
623
|
const envFile = optionValue(rest, '--env-out') || undefined;
|
|
612
624
|
const { instance, forked } = await branchWorld(subject, name, { root, forceEnvOutput: rest.includes('--force'), ...(envFile ? { envFile } : {}) });
|
|
613
|
-
if (rest.includes('--json')) process.stdout.write(`${JSON.stringify({ name: instance.name, base: subject, forked }, null, 2)}\n`);
|
|
625
|
+
if (rest.includes('--json')) process.stdout.write(`${JSON.stringify({ name: instance.name, instance: instance.instance, createdAt: instance.createdAt, base: subject, forked }, null, 2)}\n`);
|
|
614
626
|
else { process.stdout.write(`Branched world ${name} from ${subject}\n${Object.entries(forked).map(([s, states]) => ` ${s}: mirror forked (${states.join(', ')})`).join('\n')}\n`); printReady(instance); }
|
|
615
627
|
return;
|
|
616
628
|
}
|
|
617
629
|
if (cmd === 'checkout') {
|
|
618
630
|
if (!subject) throw new Error('volter-world checkout: missing world name');
|
|
619
631
|
const instance = await checkoutWorld(subject, { root });
|
|
620
|
-
if (rest.includes('--json')) process.stdout.write(`${JSON.stringify({ name: instance.name }, null, 2)}\n`); else printReady(instance);
|
|
632
|
+
if (rest.includes('--json')) process.stdout.write(`${JSON.stringify({ name: instance.name, instance: instance.instance, createdAt: instance.createdAt }, null, 2)}\n`); else printReady(instance);
|
|
621
633
|
return;
|
|
622
634
|
}
|
|
623
635
|
if (cmd === 'fetch') {
|
|
@@ -975,7 +987,7 @@ export async function main(): Promise<void> {
|
|
|
975
987
|
if (!envFile) throw new Error('volter-world run: missing required --env-out=<path>');
|
|
976
988
|
const name = optionValue(worldArgs, '--name') || undefined;
|
|
977
989
|
const mode = optionValue(worldArgs, '--mode', 'local') as 'local' | 'share' | 'sealed';
|
|
978
|
-
const result = await runWorld(subject, rest.slice(split + 1), { root, name, mode, envFile, forceEnvOutput: worldArgs.includes('--force'), owner: ownerOption(worldArgs), keep: worldArgs.includes('--keep'), verbose: worldArgs.includes('--verbose') });
|
|
990
|
+
const result = await runWorld(subject, rest.slice(split + 1), { root, cwd: process.cwd(), name, mode, envFile, forceEnvOutput: worldArgs.includes('--force'), owner: ownerOption(worldArgs), keep: worldArgs.includes('--keep'), verbose: worldArgs.includes('--verbose') });
|
|
979
991
|
if (result.exitCode !== 0) {
|
|
980
992
|
const detail = result.outcome.error ? `could not start (${result.outcome.error})`
|
|
981
993
|
: result.outcome.signal ? `was terminated by ${result.outcome.signal}`
|
|
@@ -1027,7 +1039,7 @@ export async function main(): Promise<void> {
|
|
|
1027
1039
|
if (cmd === 'unshare') {
|
|
1028
1040
|
if (!subject) throw new Error('volter-world unshare: missing world name');
|
|
1029
1041
|
const service = optionValue(rest, '--service') || undefined;
|
|
1030
|
-
const result = unshareWorld(subject, { root, service });
|
|
1042
|
+
const result = await unshareWorld(subject, { root, service });
|
|
1031
1043
|
process.stdout.write(`Unshared world ${subject}${service ? `/${service}` : ''}\n`);
|
|
1032
1044
|
if (result.escalated.length) process.stdout.write(`Escalated to SIGKILL (tunnel ignored SIGTERM past the grace): ${result.escalated.join(', ')}\n`);
|
|
1033
1045
|
return;
|
package/src/cli.ts
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
// volter-world: each command loads only what it needs. `up` (./up-cli.ts) starts the World's owner process
|
|
3
3
|
// before any other command's modules load; every other command is ./cli-commands.ts.
|
|
4
4
|
import { optionValue } from './cli-args.ts';
|
|
5
|
+
import { errorMessage } from './error-message.ts';
|
|
5
6
|
|
|
6
7
|
const [cmd, subject, ...rest] = process.argv.slice(2);
|
|
7
8
|
const run = cmd === 'up' && subject !== '--help' && subject !== '-h'
|
|
8
9
|
? import('./up-cli.ts').then(({ upCommand }) => upCommand(subject, rest, optionValue(rest, '--root', process.cwd())))
|
|
9
10
|
: import('./cli-commands.ts').then(({ main }) => main());
|
|
10
11
|
run.catch((error) => {
|
|
11
|
-
process.stderr.write(`${
|
|
12
|
+
process.stderr.write(`${errorMessage(error)}\n`);
|
|
12
13
|
process.exit(1);
|
|
13
14
|
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ChildProcess } from 'node:child_process';
|
|
2
|
+
import type { SpawnIdentity } from './process-receipt.js';
|
|
3
|
+
export const COMMAND_GRACE_MS: number;
|
|
4
|
+
export const COMMAND_CONFIRM_MS: number;
|
|
5
|
+
export const commandProcessGroup: boolean;
|
|
6
|
+
export class CommandRetirementError extends Error {}
|
|
7
|
+
export function commandLifetime(child: ChildProcess, abort?: AbortSignal, preserveOnSuccess?: boolean): {
|
|
8
|
+
identity: Promise<SpawnIdentity | undefined>;
|
|
9
|
+
cancel(signal: NodeJS.Signals): void;
|
|
10
|
+
readonly forwarded: NodeJS.Signals | undefined;
|
|
11
|
+
finish(): Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
|
|
12
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { offSignal } from './signals.mjs';
|
|
2
|
+
import { survivingOwnedGroups } from './process-groups.mjs';
|
|
3
|
+
import { childIdentity, signalOwnedProcess, signalChild } from './process-signal.mjs';
|
|
4
|
+
|
|
5
|
+
export const COMMAND_GRACE_MS = 5_000;
|
|
6
|
+
export const COMMAND_CONFIRM_MS = 1_000;
|
|
7
|
+
export const commandProcessGroup = process.platform !== 'win32';
|
|
8
|
+
export class CommandRetirementError extends Error {}
|
|
9
|
+
|
|
10
|
+
/** The child owns a private POSIX group. A bounded retirement requires both closed output and
|
|
11
|
+
* a vanished group. Unknown retirement keeps the caller's ownership record and reservation. */
|
|
12
|
+
export function commandLifetime(child, abort, preserveOnSuccess = false) {
|
|
13
|
+
const identity = child.pid && commandProcessGroup ? childIdentity(child) : Promise.resolve(undefined);
|
|
14
|
+
let signalFailure;
|
|
15
|
+
let pendingSignals = 0;
|
|
16
|
+
let forwarded;
|
|
17
|
+
let code = null;
|
|
18
|
+
let exitSignal = null;
|
|
19
|
+
let exited = false;
|
|
20
|
+
let closed = false;
|
|
21
|
+
let deadline = 0;
|
|
22
|
+
let escalated = false;
|
|
23
|
+
let timer;
|
|
24
|
+
let resolveFinished;
|
|
25
|
+
let rejectFinished;
|
|
26
|
+
const finished = new Promise((resolve, reject) => { resolveFinished = resolve; rejectFinished = reject; });
|
|
27
|
+
finished.catch(() => {});
|
|
28
|
+
const signal = (value) => {
|
|
29
|
+
if (!child.pid) return;
|
|
30
|
+
try {
|
|
31
|
+
if (signalChild(child, value, commandProcessGroup)) return;
|
|
32
|
+
if (!commandProcessGroup || !alive()) return;
|
|
33
|
+
// The leader has exited: this is now retained numeric group ownership.
|
|
34
|
+
pendingSignals++;
|
|
35
|
+
void identity.then(birth => signalOwnedProcess(birth, value, true))
|
|
36
|
+
.catch(error => { signalFailure = error; })
|
|
37
|
+
.finally(() => { pendingSignals--; check(); });
|
|
38
|
+
} catch (error) { signalFailure = error; }
|
|
39
|
+
};
|
|
40
|
+
const alive = () => {
|
|
41
|
+
if (!child.pid) return false;
|
|
42
|
+
return survivingOwnedGroups([child.pid]).length > 0;
|
|
43
|
+
};
|
|
44
|
+
const detach = () => {
|
|
45
|
+
if (timer) clearInterval(timer);
|
|
46
|
+
offSignal('SIGINT', onInt);
|
|
47
|
+
offSignal('SIGTERM', onTerm);
|
|
48
|
+
abort?.removeEventListener('abort', onAbort);
|
|
49
|
+
};
|
|
50
|
+
const check = () => {
|
|
51
|
+
if (pendingSignals) return;
|
|
52
|
+
if (exited && closed && ((preserveOnSuccess && code === 0 && !forwarded) || !alive())) {
|
|
53
|
+
detach(); resolveFinished(); return;
|
|
54
|
+
}
|
|
55
|
+
if (deadline && Date.now() >= deadline) {
|
|
56
|
+
if (!escalated) { escalated = true; signal('SIGKILL'); deadline = Date.now() + COMMAND_CONFIRM_MS; }
|
|
57
|
+
else {
|
|
58
|
+
detach();
|
|
59
|
+
child.stdout?.destroy(); child.stderr?.destroy();
|
|
60
|
+
rejectFinished(new CommandRetirementError('Command retirement could not be confirmed; ownership and cleanup evidence retained', { cause: signalFailure }));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const watch = () => {
|
|
65
|
+
if (!deadline) deadline = Date.now() + COMMAND_GRACE_MS;
|
|
66
|
+
timer ??= setInterval(check, 20);
|
|
67
|
+
check();
|
|
68
|
+
};
|
|
69
|
+
const cancel = (value) => { forwarded ??= value; signal(value); watch(); };
|
|
70
|
+
const onInt = () => cancel('SIGINT');
|
|
71
|
+
const onTerm = () => cancel('SIGTERM');
|
|
72
|
+
const onAbort = () => cancel(abort?.reason === 'SIGINT' ? 'SIGINT' : 'SIGTERM');
|
|
73
|
+
process.on('SIGINT', onInt);
|
|
74
|
+
process.on('SIGTERM', onTerm);
|
|
75
|
+
abort?.addEventListener('abort', onAbort, { once: true });
|
|
76
|
+
if (abort?.aborted) onAbort();
|
|
77
|
+
child.once('exit', (value, signalValue) => {
|
|
78
|
+
code = value; exitSignal = signalValue; exited = true;
|
|
79
|
+
if (!(preserveOnSuccess && code === 0 && !forwarded)) signal('SIGTERM');
|
|
80
|
+
watch();
|
|
81
|
+
});
|
|
82
|
+
child.once('error', () => { if (!child.pid) { exited = true; watch(); } });
|
|
83
|
+
child.once('close', () => { closed = true; check(); });
|
|
84
|
+
return {
|
|
85
|
+
identity,
|
|
86
|
+
cancel,
|
|
87
|
+
get forwarded() { return forwarded; },
|
|
88
|
+
async finish() { await finished; await identity; return { code, signal: exitSignal }; },
|
|
89
|
+
};
|
|
90
|
+
}
|
package/src/command-lifetime.ts
CHANGED
|
@@ -1,79 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import type { ChildProcess } from 'node:child_process';
|
|
3
|
-
import { survivingOwnedGroups } from './process-groups.ts';
|
|
4
|
-
|
|
5
|
-
export const COMMAND_GRACE_MS = 5_000;
|
|
6
|
-
export const commandProcessGroup = process.platform !== 'win32';
|
|
7
|
-
export class CommandRetirementError extends Error {}
|
|
8
|
-
|
|
9
|
-
/** The child owns a private POSIX group. A bounded retirement requires both closed output and
|
|
10
|
-
* a vanished group. Unknown retirement keeps the caller's ownership record and reservation. */
|
|
11
|
-
export function commandLifetime(child: ChildProcess, abort?: AbortSignal, preserveOnSuccess = false) {
|
|
12
|
-
let forwarded: NodeJS.Signals | undefined;
|
|
13
|
-
let code: number | null = null;
|
|
14
|
-
let exitSignal: NodeJS.Signals | null = null;
|
|
15
|
-
let exited = false;
|
|
16
|
-
let closed = false;
|
|
17
|
-
let deadline = 0;
|
|
18
|
-
let escalated = false;
|
|
19
|
-
let timer: ReturnType<typeof setInterval> | undefined;
|
|
20
|
-
let resolveFinished!: () => void;
|
|
21
|
-
let rejectFinished!: (error: Error) => void;
|
|
22
|
-
const finished = new Promise<void>((resolve, reject) => { resolveFinished = resolve; rejectFinished = reject; });
|
|
23
|
-
finished.catch(() => {});
|
|
24
|
-
const signal = (value: NodeJS.Signals): void => {
|
|
25
|
-
if (!child.pid) return;
|
|
26
|
-
try {
|
|
27
|
-
if (commandProcessGroup) process.kill(-child.pid, value);
|
|
28
|
-
else child.kill(value);
|
|
29
|
-
} catch { /* retirement is proved below, never inferred from signal delivery */ }
|
|
30
|
-
};
|
|
31
|
-
const alive = (): boolean => {
|
|
32
|
-
if (!child.pid) return false;
|
|
33
|
-
return survivingOwnedGroups([child.pid]).length > 0;
|
|
34
|
-
};
|
|
35
|
-
const detach = () => {
|
|
36
|
-
if (timer) clearInterval(timer);
|
|
37
|
-
offSignal('SIGINT', onInt);
|
|
38
|
-
offSignal('SIGTERM', onTerm);
|
|
39
|
-
abort?.removeEventListener('abort', onAbort);
|
|
40
|
-
};
|
|
41
|
-
const check = () => {
|
|
42
|
-
if (exited && closed && ((preserveOnSuccess && code === 0 && !forwarded) || !alive())) {
|
|
43
|
-
detach(); resolveFinished(); return;
|
|
44
|
-
}
|
|
45
|
-
if (deadline && Date.now() >= deadline) {
|
|
46
|
-
if (!escalated) { escalated = true; signal('SIGKILL'); deadline = Date.now() + 1_000; }
|
|
47
|
-
else {
|
|
48
|
-
detach();
|
|
49
|
-
child.stdout?.destroy(); child.stderr?.destroy();
|
|
50
|
-
rejectFinished(new CommandRetirementError('Command retirement could not be confirmed; ownership and cleanup evidence retained'));
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
};
|
|
54
|
-
const watch = () => {
|
|
55
|
-
if (!deadline) deadline = Date.now() + COMMAND_GRACE_MS;
|
|
56
|
-
timer ??= setInterval(check, 20);
|
|
57
|
-
check();
|
|
58
|
-
};
|
|
59
|
-
const cancel = (value: NodeJS.Signals): void => { forwarded ??= value; signal(value); watch(); };
|
|
60
|
-
const onInt = () => cancel('SIGINT');
|
|
61
|
-
const onTerm = () => cancel('SIGTERM');
|
|
62
|
-
const onAbort = () => cancel(abort?.reason === 'SIGINT' ? 'SIGINT' : 'SIGTERM');
|
|
63
|
-
process.on('SIGINT', onInt);
|
|
64
|
-
process.on('SIGTERM', onTerm);
|
|
65
|
-
abort?.addEventListener('abort', onAbort, { once: true });
|
|
66
|
-
if (abort?.aborted) onAbort();
|
|
67
|
-
child.once('exit', (value, signalValue) => {
|
|
68
|
-
code = value; exitSignal = signalValue; exited = true;
|
|
69
|
-
if (!(preserveOnSuccess && code === 0 && !forwarded)) signal('SIGTERM');
|
|
70
|
-
watch();
|
|
71
|
-
});
|
|
72
|
-
child.once('error', () => { if (!child.pid) { exited = true; watch(); } });
|
|
73
|
-
child.once('close', () => { closed = true; check(); });
|
|
74
|
-
return {
|
|
75
|
-
cancel,
|
|
76
|
-
get forwarded() { return forwarded; },
|
|
77
|
-
async finish() { await finished; return { code, signal: exitSignal }; },
|
|
78
|
-
};
|
|
79
|
-
}
|
|
1
|
+
export { COMMAND_GRACE_MS, COMMAND_CONFIRM_MS, commandProcessGroup, CommandRetirementError, commandLifetime } from './command-lifetime.mjs';
|
package/src/configs.ts
CHANGED
|
@@ -33,7 +33,7 @@ export function writeWorldConfig(path: string, config: WorldConfig): void {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
const LEGACY_TOP_LEVEL = new Set(['id', 'description', 'bare', 'remotes', 'resources', 'isolation', 'env', 'stripEnv', 'services', 'share', 'actors', 'fixtures', 'catalog', 'network']);
|
|
36
|
-
const LEGACY_SERVICE = new Set(['id', 'type', 'package', 'command', 'args', 'cwd', 'port', 'portReason', 'env', 'controlPlane', 'injectEnv', 'injectEnvTemplates', 'root', 'rootArg', 'portArg', 'cliRedirect', 'ready', 'preload', 'external', 'colocate', 'version']);
|
|
36
|
+
const LEGACY_SERVICE = new Set(['id', 'type', 'package', 'command', 'args', 'cwd', 'port', 'portReason', 'env', 'controlPlane', 'branchState', 'injectEnv', 'injectEnvTemplates', 'root', 'rootArg', 'portArg', 'cliRedirect', 'ready', 'preload', 'external', 'colocate', 'version']);
|
|
37
37
|
const LEGACY_NESTED = {
|
|
38
38
|
bare: new Set(['name']), resources: new Set(['memoryMiB', 'writableStorageMiB']), catalog: new Set(['sha', 'protocol']),
|
|
39
39
|
root: new Set(['url', 'deploy', 'refresh', 'scope']), refresh: new Set(['every', 'webhook']),
|
package/src/console-apart.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// forwarded to the host — never a twin's path, so no twin-served script ever runs on this origin.
|
|
6
6
|
// The console opens a World's pages (a vendor's screens) on the Worlds' origin, which it is told.
|
|
7
7
|
|
|
8
|
+
import { errorMessage } from './error-message.ts';
|
|
8
9
|
import { serveHttp, type HttpServer } from '@volter/world-core/runtime';
|
|
9
10
|
|
|
10
11
|
/** What a console offers a host: its shell and assets under `base`, told where the Worlds are served. */
|
|
@@ -53,7 +54,7 @@ export async function serveConsoleFor(target: string, opts: { hostname?: string;
|
|
|
53
54
|
let mount: ConsoleMount;
|
|
54
55
|
try { mount = ((await import('@volter/world-console')) as { createConsole: () => ConsoleMount }).createConsole(); }
|
|
55
56
|
catch (error) {
|
|
56
|
-
if (error instanceof Error && /Cannot find (module|package)/.test(error
|
|
57
|
+
if (error instanceof Error && /Cannot find (module|package)/.test(errorMessage(error))) throw new Error('the console is not installed: add @volter/world-console beside @volter/world', { cause: error });
|
|
57
58
|
throw error;
|
|
58
59
|
}
|
|
59
60
|
const hostname = opts.hostname ?? '127.0.0.1';
|
package/src/covers.ts
CHANGED
|
@@ -22,11 +22,13 @@
|
|
|
22
22
|
// unknown-sdk → exit 1 by default (`--allow-unknown` downgrades to a warning:
|
|
23
23
|
// unknowns still print, but stop failing the proof).
|
|
24
24
|
|
|
25
|
+
import { errorMessage } from './error-message.ts';
|
|
25
26
|
import { existsSync, readFileSync } from 'node:fs';
|
|
26
|
-
import { dirname, join, relative, resolve } from 'node:path';
|
|
27
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
27
28
|
import { fileURLToPath } from 'node:url';
|
|
28
29
|
import { vendorOfPackModule } from '@volter/world-core/runtime';
|
|
29
30
|
import { loadWorldConfig } from './configs.ts';
|
|
31
|
+
import { VENDOR_SERVER_BACKINGS } from './backings.ts';
|
|
30
32
|
import { injectableVendorKeys, loadInject, twinUrlVendorFor } from './inject-map.ts';
|
|
31
33
|
import { connectionProtocol, projectConnections, projectDependencies, projectManifestDirs, projectEnvNames, projectFetchUrls, projectNpmRegistries, PYPI_TWINS, SDK_TWINS } from './project-inspect.ts';
|
|
32
34
|
import { overlayCoversMaps, packFacts } from './pack-facts.ts';
|
|
@@ -340,6 +342,7 @@ export type WorldTwinId = {
|
|
|
340
342
|
};
|
|
341
343
|
|
|
342
344
|
type WorldTwinInventory = {
|
|
345
|
+
services: Array<WorldServiceConfig | WorldServiceInstance>;
|
|
343
346
|
source: 'instance' | 'config';
|
|
344
347
|
path: string;
|
|
345
348
|
/** normalized identifier → id record (e.g. 'stripe' → { label: 'stripe (service)', … }) */
|
|
@@ -350,15 +353,56 @@ type WorldTwinInventory = {
|
|
|
350
353
|
declared: Map<string, WorldTwinId>;
|
|
351
354
|
};
|
|
352
355
|
|
|
353
|
-
|
|
356
|
+
/** Identity comes from the registered wrapper, not an arbitrary process with the vendor's name. */
|
|
357
|
+
function serverBacking(service: WorldServiceConfig | WorldServiceInstance) {
|
|
358
|
+
const backing = VENDOR_SERVER_BACKINGS[service.id];
|
|
359
|
+
if (!backing || service.type !== backing.service.type) return undefined;
|
|
360
|
+
// an instance records the whole argv in `command`; a config splits it into `command` and `args`
|
|
361
|
+
const command = Array.isArray(service.command) ? service.command : [service.command, ...(('args' in service ? service.args : undefined) ?? [])];
|
|
362
|
+
if (!command[0] || basename(command[0]) !== backing.service.command || command[1] !== backing.service.args?.[0]) return undefined;
|
|
363
|
+
return backing;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function addBackingIds(
|
|
367
|
+
ids: Map<string, WorldTwinId>,
|
|
368
|
+
services: Array<WorldServiceConfig | WorldServiceInstance>,
|
|
369
|
+
effectiveEnv?: Record<string, string>,
|
|
370
|
+
): void {
|
|
371
|
+
for (const service of services) {
|
|
372
|
+
const backing = serverBacking(service);
|
|
373
|
+
if (backing) {
|
|
374
|
+
const template = backing.facts.endpointEnv.templates[backing.facts.endpointEnv.name];
|
|
375
|
+
const instance = 'pid' in service;
|
|
376
|
+
const values = instance ? service.env : service.injectEnvTemplates ?? {};
|
|
377
|
+
const endpoint = instance && service.url && service.port ? template?.replaceAll('${host}', new URL(service.url).hostname)
|
|
378
|
+
.replaceAll('${port}', String(service.port)).replaceAll('${url}', service.url) : undefined;
|
|
379
|
+
const routed = template !== undefined && (instance
|
|
380
|
+
? endpoint !== undefined && Object.entries(values).some(([name, value]) => value === endpoint && effectiveEnv?.[name] === value)
|
|
381
|
+
: service.port !== undefined && Object.values(values).includes(template));
|
|
382
|
+
addId(ids, normalizeId(service.id), `${service.id} (server backing)`, routed);
|
|
383
|
+
}
|
|
384
|
+
// A protocol already proved owned by this service can satisfy its declared native driver.
|
|
385
|
+
// HTTP-only siblings of the same vendor retain their independent interface obligations.
|
|
386
|
+
for (const binding of serviceConnections([service], effectiveEnv).values()) {
|
|
387
|
+
for (const [vendor, facts] of Object.entries(packFacts())) {
|
|
388
|
+
if (facts.transport === 'raw-tcp' && facts.managedService?.scheme === binding.protocol) {
|
|
389
|
+
addId(ids, normalizeId(vendor), `${binding.label}; managed ${facts.managedService.kind} backing`, true);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function declaredTwin(services: Array<WorldServiceConfig | WorldServiceInstance>, effectiveEnv?: Record<string, string>): Map<string, WorldTwinId> {
|
|
354
397
|
const declared = new Map<string, WorldTwinId>();
|
|
355
398
|
for (const service of services) {
|
|
356
|
-
if (service.type !== undefined && service.type !== 'twin') continue;
|
|
399
|
+
if (service.type !== undefined && service.type !== 'twin' && !serverBacking(service)) continue;
|
|
357
400
|
const config = service as WorldServiceConfig;
|
|
358
401
|
const identity = config.package?.match(/^@volter\/twin-(.+)$/)?.[1] ?? servicePackNames(config)[0] ?? service.id;
|
|
359
402
|
const vendor = Object.keys(packFacts()).find((candidate) => worldIdsFor(candidate).includes(normalizeId(identity))) ?? identity;
|
|
360
403
|
const ids = new Map<string, WorldTwinId>();
|
|
361
|
-
addServiceIds(ids, service);
|
|
404
|
+
if (!serverBacking(service)) addServiceIds(ids, service);
|
|
405
|
+
addBackingIds(ids, [service], effectiveEnv);
|
|
362
406
|
const match = [...worldIdsFor(vendor), vendor].map(normalizeId).map((id) => ids.get(id)).find(Boolean);
|
|
363
407
|
declared.set(vendor, match ?? { label: `${service.id} (declared twin)`, interceptable: false });
|
|
364
408
|
}
|
|
@@ -373,14 +417,22 @@ function addId(ids: Map<string, WorldTwinId>, key: string, label: string, interc
|
|
|
373
417
|
else if (interceptable && !existing.interceptable) existing.interceptable = true;
|
|
374
418
|
}
|
|
375
419
|
|
|
376
|
-
function servicePackNames(service: { package?: string; command?: string | string[]; args?: string[]; colocate?: { module?: string } }): string[] {
|
|
420
|
+
function servicePackNames(service: { id?: string; package?: string; command?: string | string[]; args?: string[]; colocate?: { module?: string } }): string[] {
|
|
377
421
|
const commandParts = [
|
|
378
422
|
...(service.package ? [service.package] : []),
|
|
379
423
|
...(Array.isArray(service.command) ? service.command : service.command ? [service.command] : []),
|
|
380
424
|
...(service.args ?? []),
|
|
381
425
|
...(service.colocate?.module ? [service.colocate.module] : []),
|
|
382
426
|
];
|
|
383
|
-
return commandParts.flatMap((
|
|
427
|
+
return commandParts.flatMap((argument, index) => {
|
|
428
|
+
// A shared host command is recorded on each service. Only that service's
|
|
429
|
+
// module identifies its pack; its neighbours' modules must not cover it.
|
|
430
|
+
let part = argument;
|
|
431
|
+
if (commandParts[index - 1] === '--spec') {
|
|
432
|
+
const [id, module] = argument.split('|');
|
|
433
|
+
if (id !== service.id || !module) return [];
|
|
434
|
+
part = module;
|
|
435
|
+
}
|
|
384
436
|
const fromPath = part.match(/(?:^|[/\\])twin-packs-p[23][/\\]([a-z0-9-]+)[/\\]/);
|
|
385
437
|
const fromBin = part.match(/^world-([a-z0-9-]+)$/);
|
|
386
438
|
const fromPkg = part.match(/(?:^|[/\\])@volter\/twin-([a-z0-9-]+)(?:[/\\]|$)/);
|
|
@@ -519,29 +571,31 @@ export function worldTwinInventory(world: string, root: string): WorldTwinInvent
|
|
|
519
571
|
try {
|
|
520
572
|
const status = statusWorld(world, root, { readOnly: true });
|
|
521
573
|
const ids = new Map<string, WorldTwinId>();
|
|
522
|
-
for (const service of Object.values(status.services)) addServiceIds(ids, service);
|
|
574
|
+
for (const service of Object.values(status.services)) if (!serverBacking(service)) addServiceIds(ids, service);
|
|
575
|
+
addBackingIds(ids, Object.values(status.services), status.env);
|
|
523
576
|
for (const key of Object.keys(status.env)) {
|
|
524
577
|
const stem = key.match(/^(.+)_TWIN_URL$/);
|
|
525
578
|
// a bare env `*_TWIN_URL` var is only a REACHABLE twin when the injector reads it
|
|
526
579
|
if (stem) addId(ids, normalizeId(stem[1]!), `env ${key}`, twinUrlVendorFor(key) !== null);
|
|
527
580
|
}
|
|
528
|
-
return { source: 'instance', path: `${status.dirs.instance}/instance.json`, ids, connections: serviceConnections(Object.values(status.services), status.env), selection: status.selection, declared: declaredTwin(Object.values(status.services)) };
|
|
581
|
+
return { services: Object.values(status.services), source: 'instance', path: `${status.dirs.instance}/instance.json`, ids, connections: serviceConnections(Object.values(status.services), status.env), selection: status.selection, declared: declaredTwin(Object.values(status.services), status.env) };
|
|
529
582
|
} catch (error) {
|
|
530
|
-
instanceError =
|
|
583
|
+
instanceError = errorMessage(error);
|
|
531
584
|
}
|
|
532
585
|
try {
|
|
533
586
|
const { path, config } = loadWorldConfig(world, root);
|
|
534
587
|
const ids = new Map<string, WorldTwinId>();
|
|
535
|
-
for (const service of config.services as WorldServiceConfig[]) addServiceIds(ids, service);
|
|
588
|
+
for (const service of config.services as WorldServiceConfig[]) if (!serverBacking(service)) addServiceIds(ids, service);
|
|
589
|
+
addBackingIds(ids, config.services);
|
|
536
590
|
const connections = serviceConnections(config.services);
|
|
537
591
|
// a name the World's env leaves to a twin's credential door (`$issue:<service>`) is that twin's, whatever scheme the
|
|
538
592
|
// issued value has: the twin that issues it serves it
|
|
539
593
|
for (const [name, value] of Object.entries(config.env ?? {})) {
|
|
540
594
|
if (value.startsWith(ISSUE_ENV_PREFIX)) connections.set(name, { protocol: 'issued', label: `${value.slice(ISSUE_ENV_PREFIX.length)} (${name}; issued)` });
|
|
541
595
|
}
|
|
542
|
-
return { source: 'config', path, ids, connections, selection: config.selection, declared: declaredTwin(config.services) };
|
|
596
|
+
return { services: config.services, source: 'config', path, ids, connections, selection: config.selection, declared: declaredTwin(config.services) };
|
|
543
597
|
} catch (error) {
|
|
544
|
-
const configError =
|
|
598
|
+
const configError = errorMessage(error);
|
|
545
599
|
throw new Error(
|
|
546
600
|
`volter-world covers: world "${world}" not found as an instance or a config.\n` +
|
|
547
601
|
` instance: ${instanceError}\n config: ${configError}`,
|
|
@@ -614,7 +668,14 @@ function usesOf(signals: Map<string, string[]>): Map<string, Array<{ usage: Worl
|
|
|
614
668
|
* directly to fetch in production source. URL attribution reads the injector's VENDOR_HOSTS table
|
|
615
669
|
* rather than re-encoding host rules here. Insertion order follows the sorted dependency, env-name,
|
|
616
670
|
* and source lists, so callers that preserve it are deterministic. */
|
|
617
|
-
export function detectRepoVendors(repoPath: string): RepoVendorSignals {
|
|
671
|
+
export function detectRepoVendors(repoPath: string, services: Array<WorldServiceConfig | WorldServiceInstance> = []): RepoVendorSignals {
|
|
672
|
+
// Application aliases are names the selected backing actually injects, never global vendor adoption.
|
|
673
|
+
const backingEnvVendors = new Map<string, string>();
|
|
674
|
+
for (const service of services) {
|
|
675
|
+
if (!serverBacking(service)) continue;
|
|
676
|
+
const bindings = 'pid' in service ? service.env : service.injectEnvTemplates;
|
|
677
|
+
for (const name of Object.keys(bindings ?? {})) backingEnvVendors.set(name, service.id);
|
|
678
|
+
}
|
|
618
679
|
const repo = resolve(repoPath);
|
|
619
680
|
// the platform's own packages — the twins an app installed, the command, the runtime — are
|
|
620
681
|
// never a vendor signal: an app that installed @volter/twin-slack did not gain a Slack dependency.
|
|
@@ -660,7 +721,7 @@ export function detectRepoVendors(repoPath: string): RepoVendorSignals {
|
|
|
660
721
|
detected.set('smtp', [...(detected.get('smtp') ?? []), `env: ${name}`]);
|
|
661
722
|
continue;
|
|
662
723
|
}
|
|
663
|
-
const vendor = envStemVendor(name, ENV_SUFFIX_BROAD);
|
|
724
|
+
const vendor = backingEnvVendors.get(name) ?? envStemVendor(name, ENV_SUFFIX_BROAD);
|
|
664
725
|
if (vendor) detected.set(vendor, [...(detected.get(vendor) ?? []), `env: ${name}`]);
|
|
665
726
|
}
|
|
666
727
|
|
|
@@ -684,6 +745,9 @@ export function detectRepoVendors(repoPath: string): RepoVendorSignals {
|
|
|
684
745
|
...Object.values(SDK_TWINS).map((entry) => entry.vendor),
|
|
685
746
|
...Object.values(ENV_STEM_VENDORS),
|
|
686
747
|
...Object.keys(VENDOR_WORLD_IDS),
|
|
748
|
+
// a pack whose vendor is called only by its hosts (no SDK, no credential: Poly Haven's and ambientCG's public
|
|
749
|
+
// catalogs) is known by its descriptor's hosts, or a raw fetch matched to it would be dropped here
|
|
750
|
+
...Object.entries(packFacts()).filter(([, facts]) => (facts.hosts ?? []).length).map(([vendor]) => vendor),
|
|
687
751
|
]) knownVendorById.set(normalizeId(vendor), vendor);
|
|
688
752
|
const canonicalVendor = (injectorKey: string): string | null => {
|
|
689
753
|
const key = normalizeId(injectorKey);
|
|
@@ -765,7 +829,7 @@ export function detectRepoVendors(repoPath: string): RepoVendorSignals {
|
|
|
765
829
|
if (/_TWIN_URL$/.test(name) || /^VOLTER_/.test(name)) continue;
|
|
766
830
|
if (isSmtpEnvSignal(name)) continue; // now a DETECTED vendor (above), no longer an unknown
|
|
767
831
|
const stem = envVendorStem(name, ENV_SUFFIX_STRICT);
|
|
768
|
-
if (stem === null || envStemVendor(name, ENV_SUFFIX_STRICT) || ENV_STEM_IGNORE.has(stem)) continue;
|
|
832
|
+
if (stem === null || backingEnvVendors.has(name) || envStemVendor(name, ENV_SUFFIX_STRICT) || ENV_STEM_IGNORE.has(stem)) continue;
|
|
769
833
|
// The registry's notExternal suppressions apply to ENV STEMS too — the census adjudicated
|
|
770
834
|
// 40 app-internal stems ("names no vendor") that this loop kept flagging because only the
|
|
771
835
|
// dep-side checks consulted the registry (§9 round two H4, 2026-08-31: 17 of 18 formally
|
|
@@ -786,7 +850,7 @@ export function coverWorld(world: string, repoPath: string, options: CoverageOpt
|
|
|
786
850
|
const repo = resolve(repoPath);
|
|
787
851
|
const inventory = worldTwinInventory(world, root);
|
|
788
852
|
|
|
789
|
-
const { detected, unknown: unknownSources, uses, unknownUses } = detectRepoVendors(repo);
|
|
853
|
+
const { detected, unknown: unknownSources, uses, unknownUses } = detectRepoVendors(repo, inventory.services);
|
|
790
854
|
|
|
791
855
|
// Does ANY injector vendor key exist for this vendor? Decides whether an UNINTERCEPTABLE
|
|
792
856
|
// finding is acknowledgeable: a key that exists means the twin COULD be intercepted and the
|
|
@@ -812,7 +876,14 @@ export function coverWorld(world: string, repoPath: string, options: CoverageOpt
|
|
|
812
876
|
// twin (no injector vendor, no app-read endpoint env) is a distinct, failing status.
|
|
813
877
|
const candidates = worldIdsFor(vendor).filter((id) => inventory.ids.has(id));
|
|
814
878
|
const matchId = candidates.find((id) => inventory.ids.get(id)!.interceptable) ?? candidates[0];
|
|
815
|
-
const
|
|
879
|
+
const managed = packFacts()[vendor]?.managedService;
|
|
880
|
+
// Only a booted, service-owned endpoint establishes a managed pack's reachable wire.
|
|
881
|
+
const managedBinding = inventory.source === 'instance' && managed
|
|
882
|
+
? [...inventory.connections.values()].find(binding => binding.protocol === managed.scheme)
|
|
883
|
+
: undefined;
|
|
884
|
+
const match = matchId === undefined
|
|
885
|
+
? managedBinding ? { label: managedBinding.label, interceptable: true } : null
|
|
886
|
+
: inventory.ids.get(matchId)!;
|
|
816
887
|
let status: CoverageStatus = match === null ? 'missing' : match.interceptable ? 'covered' : 'uninterceptable';
|
|
817
888
|
let ackRefused: string | undefined;
|
|
818
889
|
if (acknowledge[vendor] !== undefined) {
|
|
@@ -884,9 +955,16 @@ export function coverWorld(world: string, repoPath: string, options: CoverageOpt
|
|
|
884
955
|
|
|
885
956
|
for (const requirement of projectConnections(repo, new Set(
|
|
886
957
|
[...detected.values(), ...unknownSources.values()].flat().filter((source) => source.startsWith('env: ')).map((source) => source.slice(5)),
|
|
887
|
-
))
|
|
958
|
+
), new Map([...inventory.connections].flatMap(([name, binding]) =>
|
|
959
|
+
binding.protocol && !['http', 'issued', 'incompatible'].includes(binding.protocol) ? [[name, binding.protocol] as const] : [])))) {
|
|
888
960
|
const vendor = `${requirement.protocol ?? 'unknown-connection'}:${requirement.env ?? requirement.sources[0]}`;
|
|
889
|
-
|
|
961
|
+
const connectionNames = [vendor, ...(requirement.protocol ? [requirement.protocol] : [])];
|
|
962
|
+
const selectorMatches = (selector: WorldSelection['include'][number]) =>
|
|
963
|
+
(selector.usage === undefined || selector.usage === 'application') &&
|
|
964
|
+
(selector.vendor === undefined || connectionNames.includes(selector.vendor));
|
|
965
|
+
const connectionSelected = inventory.selection === undefined ||
|
|
966
|
+
(inventory.selection.include.some(selectorMatches) && !inventory.selection.exclude.some(selectorMatches));
|
|
967
|
+
if (!connectionSelected) {
|
|
890
968
|
excluded.push(vendor);
|
|
891
969
|
rows.push({
|
|
892
970
|
vendor,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function errorMessage(error: unknown): string;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Operator-facing failures include aggregate members and retained causes. */
|
|
2
|
+
export function errorMessage(error, seen = new Set()) {
|
|
3
|
+
const message = error && typeof error === 'object' && 'message' in error ? String(error.message) : String(error);
|
|
4
|
+
if (seen.has(error)) return '[circular error]';
|
|
5
|
+
seen.add(error);
|
|
6
|
+
const details = error instanceof AggregateError ? Array.from(error.errors, member => errorMessage(member, seen)) : [];
|
|
7
|
+
if (error instanceof Error && error.cause !== undefined) details.push(`cause: ${errorMessage(error.cause, seen)}`);
|
|
8
|
+
seen.delete(error);
|
|
9
|
+
return [message, ...details.map(detail => detail.split('\n').map(line => ` ${line}`).join('\n'))].join('\n');
|
|
10
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export function admitGuestArgv(argv: string[], runtime: string, root: string, cwd: string): { argv: string[]; cwd: string };
|
|
2
|
+
export function guestCommandGap(message: string): Error & { code: string };
|
|
3
|
+
export function guestEnvironment(env: Record<string, string>): Record<string, string>;
|
|
4
|
+
export function guestEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Declared execution limits reach packs as capability gaps, never vendor launch failures.
|
|
2
|
+
export const guestCommandGap = message => Object.assign(new Error(message), { code: 'WORLD_MACHINE_CAPABILITY_GAP' });
|
|
3
|
+
// Only World routing may restore Node preload/CA configuration after caller controls are stripped.
|
|
4
|
+
export function guestEnvironment(env) {
|
|
5
|
+
return Object.fromEntries(Object.entries(env).filter(([key]) => !/^(NODE_|LD_|DYLD_|SUBSTRATE_|OPENSSL_CONF$|OPENSSL_MODULES$)/i.test(key)));
|
|
6
|
+
}
|
|
7
|
+
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
8
|
+
import { resolve, relative, isAbsolute } from 'node:path';
|
|
9
|
+
// Interim admission list: the image's Node runtime and an ordinary JS file inside its folder.
|
|
10
|
+
export function admitGuestArgv(argv, runtime, root, cwd) {
|
|
11
|
+
if (runtime !== 'node' || !Array.isArray(argv) || argv.length < 2 || !argv.every(value => typeof value === 'string' && !value.includes('\0')) || argv[0] !== 'node') {
|
|
12
|
+
throw guestCommandGap(`unsupported guest command ${Array.isArray(argv) ? argv[0] : '(invalid argv)'}; admitted program: node with an image entry file`);
|
|
13
|
+
}
|
|
14
|
+
if (argv[1].startsWith('-')) throw guestCommandGap('unsupported Node option/eval; guest command requires an image entry file');
|
|
15
|
+
const folder = realpathSync(root);
|
|
16
|
+
const within = path => {
|
|
17
|
+
const real = realpathSync(path), rest = relative(folder, real);
|
|
18
|
+
if (rest === '..' || rest.startsWith('../') || isAbsolute(rest)) throw guestCommandGap('guest command path leaves its folder');
|
|
19
|
+
return real;
|
|
20
|
+
};
|
|
21
|
+
const directory = within(cwd);
|
|
22
|
+
if (!statSync(directory).isDirectory()) throw guestCommandGap('guest command cwd is not a directory');
|
|
23
|
+
const entry = resolve(directory, argv[1]);
|
|
24
|
+
if (!existsSync(entry) || !/\.(?:mjs|cjs|js)$/.test(entry) || !statSync(within(entry)).isFile()) throw guestCommandGap('guest command needs an ordinary JS entry file in its folder');
|
|
25
|
+
return { argv: ['node', within(entry), ...argv.slice(2)], cwd: directory };
|
|
26
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function guestControl<T>(operation: (signal: AbortSignal) => Promise<T>, label: string, signal?: AbortSignal, budget?: number): Promise<T>;
|