@volter/twin-world 0.1.0 → 0.1.2
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/known-external-services.json +1192 -0
- package/package.json +12 -5
- package/src/app-url.ts +175 -0
- package/src/attach.ts +113 -0
- package/src/browser-proxy-cli.ts +2 -1
- package/src/changeset.ts +416 -0
- package/src/cli.ts +589 -10
- package/src/covers.ts +724 -0
- package/src/fixture-env.ts +207 -0
- package/src/host-cli.ts +2 -1
- package/src/host-worker.ts +4 -3
- package/src/host.ts +26 -4
- package/src/index.ts +97 -1
- package/src/init.ts +1142 -0
- package/src/inject-map.ts +70 -0
- package/src/managed-infra-cli.ts +208 -0
- package/src/pack-facts.ts +136 -0
- package/src/pglite-backing.ts +125 -0
- package/src/pglite-host.mjs +147 -0
- package/src/prerequisites.ts +15 -58
- package/src/project-inspect.ts +683 -0
- package/src/redirect-proxy.ts +109 -85
- package/src/reflect.ts +443 -0
- package/src/resource-holder.ts +11 -0
- package/src/resources.ts +160 -0
- package/src/runtime-test-support.ts +208 -0
- package/src/runtime.ts +667 -106
- package/src/schema.ts +67 -0
- package/src/serve.ts +102 -0
- package/src/tail.ts +203 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The ONE world-runtime home for the injector's host→twin knowledge (`@volter/twin/inject`:
|
|
2
|
+
// `VENDOR_HOSTS` + `readMap` + `resolveTwin` + `twinUrlVendor`). The redirect proxy, `covers`,
|
|
3
|
+
// and `up`'s inert-injectEnv warning all read the injector THROUGH this module, so none of them
|
|
4
|
+
// can re-encode (and silently drift from) what the injector actually redirects — the exact
|
|
5
|
+
// failure mode of the LibreChat blind-adoption run, where a world injected AWS_TWIN_URL that no
|
|
6
|
+
// injector vendor reads and nothing said so.
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
|
|
9
|
+
/** The slice of `@volter/twin/inject` reused here (data + pure functions only). */
|
|
10
|
+
export type InjectModule = {
|
|
11
|
+
readMap(env: Record<string, string | undefined>): Record<string, string>;
|
|
12
|
+
/** `pathname` is optional; it only disambiguates hosts two vendors share (see inject.cjs). */
|
|
13
|
+
resolveTwin(host: string, map: Record<string, string>, pathname?: string): { vendor: string; origin: string } | null;
|
|
14
|
+
/** `S3_TWIN_URL` → 's3'; a `*_TWIN_URL` name readMap would never read → null. */
|
|
15
|
+
twinUrlVendor(name: string): string | null;
|
|
16
|
+
VENDOR_HOSTS: Record<string, (host: string, pathname?: string) => boolean>;
|
|
17
|
+
restore(): void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
let injectCache: InjectModule | null = null;
|
|
21
|
+
|
|
22
|
+
/** Lazily load the injector's host→twin table. Loading the CJS auto-installs its http/fetch
|
|
23
|
+
* patches as a side effect; we immediately `restore()` so requiring it here is inert (we only
|
|
24
|
+
* want the data + pure functions, not to patch THIS process). */
|
|
25
|
+
export function loadInject(): InjectModule {
|
|
26
|
+
if (injectCache) return injectCache;
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const mod = require('@volter/twin/inject') as InjectModule;
|
|
29
|
+
try { mod.restore(); } catch { /* nothing was installed */ }
|
|
30
|
+
injectCache = mod;
|
|
31
|
+
return mod;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The vendor keys the injector can redirect for (the keys of VENDOR_HOSTS). */
|
|
35
|
+
export function injectableVendorKeys(): Set<string> {
|
|
36
|
+
return new Set(Object.keys(loadInject().VENDOR_HOSTS));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Does the injector actually read this env var name? (`S3_TWIN_URL` → 's3'; `AWS_TWIN_URL`
|
|
40
|
+
* → null — the consolidated aws twin answers under the s3/dynamodb/timestream keys.) */
|
|
41
|
+
export function twinUrlVendorFor(name: string): string | null {
|
|
42
|
+
return loadInject().twinUrlVendor(name);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** WARN lines for twin-shaped injectEnv vars the injector will never read — an injectEnv like
|
|
46
|
+
* `AWS_TWIN_URL` is written into the world env, looks wired, and does NOTHING (readMap iterates
|
|
47
|
+
* VENDOR_HOSTS keys looking for `<VENDOR>_TWIN_URL`; there is no `aws` vendor key). `up` prints
|
|
48
|
+
* these loudly so the disagreement is visible at boot instead of surfacing as silently-real
|
|
49
|
+
* vendor traffic later. Vars that do not end in `_TWIN_URL` are app-read config (SUPABASE_URL,
|
|
50
|
+
* LIVEKIT_URL, …), not injector input — never warned about. */
|
|
51
|
+
export function inertInjectEnvWarnings(
|
|
52
|
+
services: Array<{ id: string; injectEnv?: string }>,
|
|
53
|
+
): string[] {
|
|
54
|
+
const warnings: string[] = [];
|
|
55
|
+
for (const service of services) {
|
|
56
|
+
const name = service.injectEnv;
|
|
57
|
+
if (!name || !/_TWIN_URL$/.test(name)) continue;
|
|
58
|
+
if (twinUrlVendorFor(name) !== null) continue;
|
|
59
|
+
const stem = name.replace(/_TWIN_URL$/, '');
|
|
60
|
+
const hint = stem.toUpperCase() === 'AWS'
|
|
61
|
+
? ' The consolidated aws twin is read under its per-AREA keys — S3_TWIN_URL / DYNAMODB_TWIN_URL / TIMESTREAM_TWIN_URL / SESV2_TWIN_URL / SECRETSMANAGER_TWIN_URL / BEDROCK_TWIN_URL — point those at the aws twin instead.'
|
|
62
|
+
: '';
|
|
63
|
+
warnings.push(
|
|
64
|
+
`[volter-world] WARN: service "${service.id}" injects ${name}, but the injector reads no such vendor` +
|
|
65
|
+
` ("${stem.toLowerCase()}" is not a VENDOR_HOSTS key in @volter/twin/inject) — the var is INERT and this` +
|
|
66
|
+
` vendor's SDK traffic will NOT be redirected to the twin.${hint}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return warnings;
|
|
70
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Private implementation for infrastructure emitted by `volter-world init`. World configs invoke
|
|
3
|
+
// this declared service; application agents never operate or diagnose its backing mechanism.
|
|
4
|
+
import { existsSync, readFileSync, statfsSync } from 'node:fs';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
const phase = process.argv[2];
|
|
9
|
+
if (phase !== 'up' && phase !== 'status' && phase !== 'down') {
|
|
10
|
+
process.stderr.write('managed infrastructure: expected up, status, or down\n');
|
|
11
|
+
process.exit(2);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const worldConfig = process.env.VOLTER_WORLD_CONFIG;
|
|
15
|
+
const worldData = process.env.VOLTER_WORLD_DATA;
|
|
16
|
+
if (!worldConfig || !worldData) {
|
|
17
|
+
process.stderr.write('managed infrastructure: missing World lifecycle context\n');
|
|
18
|
+
process.exit(2);
|
|
19
|
+
}
|
|
20
|
+
const definition = join(dirname(worldConfig), 'world.infrastructure.yml');
|
|
21
|
+
if (!existsSync(definition)) {
|
|
22
|
+
process.stderr.write('managed infrastructure: declared definition is missing\n');
|
|
23
|
+
process.exit(2);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const base = ['compose', '-f', definition];
|
|
27
|
+
const MIB = 1024 * 1024;
|
|
28
|
+
const run = (args: string[]) => spawnSync('docker', [...base, ...args], {
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
env: { ...process.env, VOLTER_WORLD_DATA: worldData },
|
|
31
|
+
timeout: 120_000,
|
|
32
|
+
});
|
|
33
|
+
const classify = (raw: string): string => /no space left on device|enospc/iu.test(raw)
|
|
34
|
+
? 'insufficient writable storage'
|
|
35
|
+
: /out of memory|cannot allocate memory|killed/iu.test(raw)
|
|
36
|
+
? 'insufficient memory'
|
|
37
|
+
: /cannot connect|not running|daemon/iu.test(raw)
|
|
38
|
+
? 'local execution capacity is unavailable'
|
|
39
|
+
: 'managed infrastructure operation failed';
|
|
40
|
+
const fail = (result: ReturnType<typeof run>): never => {
|
|
41
|
+
const raw = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
|
|
42
|
+
process.stderr.write(`managed infrastructure ${phase} failed: ${classify(raw)}\n`);
|
|
43
|
+
process.exit(result.status && result.status > 0 ? result.status : 1);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function writableStorageAvailableMiB(rootDir: string): number | undefined {
|
|
47
|
+
if (rootDir && existsSync(rootDir)) {
|
|
48
|
+
const fs = statfsSync(rootDir);
|
|
49
|
+
return Math.floor((Number(fs.bavail) * Number(fs.bsize)) / MIB);
|
|
50
|
+
}
|
|
51
|
+
const context = spawnSync('docker', ['context', 'inspect', '--format', '{{json .Endpoints.docker.Host}}'], { encoding: 'utf8', timeout: 5_000 });
|
|
52
|
+
let endpoint = '';
|
|
53
|
+
try { endpoint = context.status === 0 ? JSON.parse(context.stdout.trim() || '""') as string : ''; } catch { return undefined; }
|
|
54
|
+
const match = /\/colima\/([^/]+)\/docker\.sock$/u.exec(endpoint);
|
|
55
|
+
if (!match) return undefined;
|
|
56
|
+
const config = spawnSync('colima', ['-p', match[1]!, 'ssh-config'], { encoding: 'utf8', timeout: 5_000 });
|
|
57
|
+
if (config.status !== 0) return undefined;
|
|
58
|
+
const field = (name: string): string | undefined => {
|
|
59
|
+
const value = new RegExp(`^\\s*${name}\\s+(.+?)\\s*$`, 'mu').exec(config.stdout)?.[1];
|
|
60
|
+
return value?.replace(/^"|"$/gu, '');
|
|
61
|
+
};
|
|
62
|
+
const identity = field('IdentityFile');
|
|
63
|
+
const user = field('User');
|
|
64
|
+
const host = field('Hostname');
|
|
65
|
+
const port = field('Port');
|
|
66
|
+
const controlPath = field('ControlPath');
|
|
67
|
+
if (!identity || !user || !host || !port) return undefined;
|
|
68
|
+
const status = spawnSync('ssh', [
|
|
69
|
+
'-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', '-o', 'BatchMode=yes',
|
|
70
|
+
'-o', 'IdentitiesOnly=yes', ...(controlPath ? ['-o', 'ControlMaster=auto', '-o', `ControlPath=${controlPath}`, '-o', 'ControlPersist=yes'] : []),
|
|
71
|
+
'-i', identity, '-p', port, `${user}@${host}`, 'df', '-Pk', rootDir || '/',
|
|
72
|
+
], { encoding: 'utf8', timeout: 5_000 });
|
|
73
|
+
if (status.status !== 0) return undefined;
|
|
74
|
+
const fields = (status.stdout.trim().split(/\r?\n/u).at(-1) ?? '').trim().split(/\s+/u);
|
|
75
|
+
const availableKiB = Number(fields[3]);
|
|
76
|
+
return Number.isFinite(availableKiB) ? Math.floor(availableKiB / 1024) : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function admit(): void {
|
|
80
|
+
const requestedMemoryMiB = Number(process.env.VOLTER_WORLD_RESOURCE_MEMORY_MIB ?? 0);
|
|
81
|
+
const requestedStorageMiB = Number(process.env.VOLTER_WORLD_RESOURCE_WRITABLE_STORAGE_MIB ?? 0);
|
|
82
|
+
const info = spawnSync('docker', ['info', '--format', '{{.MemTotal}} {{.DockerRootDir}}'], { encoding: 'utf8', timeout: 15_000 });
|
|
83
|
+
if (info.status !== 0) {
|
|
84
|
+
process.stderr.write(`managed infrastructure resource admission refused: ${classify(`${info.stdout ?? ''}\n${info.stderr ?? ''}`)}\n`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
const [memoryRaw, rootDir = ''] = info.stdout.trim().split(/\s+/u);
|
|
88
|
+
const totalMemoryMiB = Math.floor(Number(memoryRaw) / MIB);
|
|
89
|
+
if (!Number.isFinite(totalMemoryMiB) || requestedMemoryMiB + 1024 > totalMemoryMiB) {
|
|
90
|
+
process.stderr.write(`managed infrastructure resource admission refused: memory requires ${requestedMemoryMiB} MiB plus 1024 MiB safety, ${totalMemoryMiB || 0} MiB available\n`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
const storageMiB = writableStorageAvailableMiB(rootDir);
|
|
94
|
+
if (storageMiB === undefined) {
|
|
95
|
+
process.stderr.write('managed infrastructure resource admission refused: writable-storage capacity could not be verified\n');
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
if (requestedStorageMiB + 2048 > storageMiB) {
|
|
99
|
+
process.stderr.write(`managed infrastructure resource admission refused: writable storage requires ${requestedStorageMiB} MiB plus 2048 MiB safety, ${storageMiB} MiB available\n`);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- backing selection ------------------------------------------------------
|
|
105
|
+
// The declared service contract (up/status/down) is the boundary; WHICH
|
|
106
|
+
// runtime answers it is private and chosen here, per machine, at each phase:
|
|
107
|
+
// 1. VOLTER_WORLD_INFRA_BACKING=docker|pglite — explicit, for tests/operators;
|
|
108
|
+
// 2. a working container runtime — the compose path, byte-identical to before;
|
|
109
|
+
// 3. no container runtime + a postgres-only definition — the PGlite backing
|
|
110
|
+
// (pglite-host.ts), announced loudly;
|
|
111
|
+
// 4. otherwise the honest refusal naming what this machine cannot serve.
|
|
112
|
+
function selectBacking(): 'docker' | 'pglite' {
|
|
113
|
+
const forced = process.env.VOLTER_WORLD_INFRA_BACKING;
|
|
114
|
+
if (forced === 'docker' || forced === 'pglite') return forced;
|
|
115
|
+
if (forced !== undefined) {
|
|
116
|
+
process.stderr.write(`managed infrastructure: VOLTER_WORLD_INFRA_BACKING must be docker or pglite (got ${JSON.stringify(forced)})\n`);
|
|
117
|
+
process.exit(2);
|
|
118
|
+
}
|
|
119
|
+
const probe = spawnSync('docker', ['info', '--format', '{{.ServerVersion}}'], { encoding: 'utf8', timeout: 10_000 });
|
|
120
|
+
if (probe.status === 0) return 'docker';
|
|
121
|
+
// Absence of a container runtime (no binary, no daemon) is a capability
|
|
122
|
+
// difference: swap backings. A PRESENT runtime failing on resources stays
|
|
123
|
+
// on the docker path so the real capacity problem is classified and
|
|
124
|
+
// surfaced — a full disk starves every backing equally.
|
|
125
|
+
if (probe.error !== undefined) return 'pglite';
|
|
126
|
+
const reason = classify(`${probe.stdout ?? ''}\n${probe.stderr ?? ''}`);
|
|
127
|
+
return reason === 'local execution capacity is unavailable' ? 'pglite' : 'docker';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function runPglite(): Promise<never> {
|
|
131
|
+
const { parseInfraDefinition, unsupportedKinds, pgliteUp, pgliteStatus, pgliteDown } = await import('./pglite-backing.ts');
|
|
132
|
+
const services = parseInfraDefinition(readFileSync(definition, 'utf8'));
|
|
133
|
+
if (services.length === 0) {
|
|
134
|
+
process.stderr.write('managed infrastructure: the declared definition names no services\n');
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
const unsupported = unsupportedKinds(services);
|
|
138
|
+
if (unsupported.length > 0) {
|
|
139
|
+
process.stderr.write(`managed infrastructure ${phase} failed: local execution capacity is unavailable (no container runtime, and the containerless backing cannot serve: ${unsupported.join(', ')})\n`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
if (phase === 'up') {
|
|
143
|
+
admitPglite();
|
|
144
|
+
process.stdout.write('managed infrastructure backing: pglite (no container runtime)\n');
|
|
145
|
+
try {
|
|
146
|
+
await pgliteUp(services, worldData!);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
process.stderr.write(`managed infrastructure up failed: ${String((error as Error).message ?? error)}\n`);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
process.stdout.write('managed infrastructure ready\n');
|
|
152
|
+
process.exit(0);
|
|
153
|
+
}
|
|
154
|
+
if (phase === 'status') {
|
|
155
|
+
const status = await pgliteStatus(services, worldData!);
|
|
156
|
+
if (!status.ok) {
|
|
157
|
+
process.stderr.write(`managed infrastructure status failed: ${status.ready}/${services.length} declared services are ready\n`);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
process.stdout.write(`${JSON.stringify({ ok: true, services: services.length })}\n`);
|
|
161
|
+
process.exit(0);
|
|
162
|
+
}
|
|
163
|
+
await pgliteDown(services, worldData!);
|
|
164
|
+
process.stdout.write('managed infrastructure stopped\n');
|
|
165
|
+
process.exit(0);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Admission without a container runtime: same declared bounds, measured on
|
|
169
|
+
* the host directly (PGlite runs in-process, its bytes under VOLTER_WORLD_DATA). */
|
|
170
|
+
function admitPglite(): void {
|
|
171
|
+
const requestedStorageMiB = Number(process.env.VOLTER_WORLD_RESOURCE_WRITABLE_STORAGE_MIB ?? 0);
|
|
172
|
+
const storageMiB = writableStorageAvailableMiB(worldData!);
|
|
173
|
+
if (storageMiB === undefined) {
|
|
174
|
+
process.stderr.write('managed infrastructure resource admission refused: writable-storage capacity could not be verified\n');
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
if (requestedStorageMiB + 2048 > storageMiB) {
|
|
178
|
+
process.stderr.write(`managed infrastructure resource admission refused: writable storage requires ${requestedStorageMiB} MiB plus 2048 MiB safety, ${storageMiB} MiB available\n`);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (selectBacking() === 'pglite') {
|
|
184
|
+
await runPglite();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (phase === 'up') {
|
|
188
|
+
admit();
|
|
189
|
+
const result = run(['up', '-d', '--wait']);
|
|
190
|
+
if (result.status !== 0) fail(result);
|
|
191
|
+
process.stdout.write('managed infrastructure ready\n');
|
|
192
|
+
} else if (phase === 'status') {
|
|
193
|
+
const expected = run(['config', '--services']);
|
|
194
|
+
if (expected.status !== 0) fail(expected);
|
|
195
|
+
const running = run(['ps', '--status', 'running', '--services']);
|
|
196
|
+
if (running.status !== 0) fail(running);
|
|
197
|
+
const expectedNames = expected.stdout.split(/\s+/u).filter(Boolean).sort();
|
|
198
|
+
const runningNames = running.stdout.split(/\s+/u).filter(Boolean).sort();
|
|
199
|
+
if (expectedNames.length === 0 || expectedNames.join('\0') !== runningNames.join('\0')) {
|
|
200
|
+
process.stderr.write(`managed infrastructure status failed: ${runningNames.length}/${expectedNames.length} declared services are ready\n`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
process.stdout.write(`${JSON.stringify({ ok: true, services: expectedNames.length })}\n`);
|
|
204
|
+
} else {
|
|
205
|
+
const result = run(['down', '--remove-orphans']);
|
|
206
|
+
if (result.status !== 0) fail(result);
|
|
207
|
+
process.stdout.write('managed infrastructure stopped\n');
|
|
208
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// PACK FACTS OVERLAY — the world-runtime consumer of the generated pack-facts artifact
|
|
2
|
+
// (packages/twin/control-plane/generated/pack-facts.json, compiled by scripts/pack-facts.ts
|
|
3
|
+
// from each pack's `pack: TwinPack` descriptor).
|
|
4
|
+
//
|
|
5
|
+
// During the TWIN-PACK-CONTRACT migration (company-repo BRIEFS/TWIN-PACK-CONTRACT.md), the
|
|
6
|
+
// adoption tables here are the UNION of the legacy central maps and the pack declarations.
|
|
7
|
+
// The overlay MUTATES the central tables at module init — every use-site keeps reading the
|
|
8
|
+
// object it always read — and a fact declared in BOTH places throws: dual declaration is the
|
|
9
|
+
// drift this migration exists to end, so it is loud everywhere, not just in a test.
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
export type PackAdoption = { sdks?: string[]; pypi?: string[]; scopes?: string[]; envStems?: string[]; worldIds?: string[] };
|
|
16
|
+
export type PackFacts = {
|
|
17
|
+
adoption?: PackAdoption;
|
|
18
|
+
hosts?: Array<{ host?: string; suffix?: string; hostPattern?: string; pathPattern?: string; key?: string; exclude?: true }>;
|
|
19
|
+
hostsNone?: string;
|
|
20
|
+
endpointEnv?: { name: string; templates?: Record<string, string>; note: string };
|
|
21
|
+
endpointEnvNone?: string;
|
|
22
|
+
/** The pack's colocatable `create<Name>TwinServer` factory export, derived (or descriptor-
|
|
23
|
+
* declared under ambiguity) by scripts/pack-facts.ts. Consumed by `init` to emit
|
|
24
|
+
* `colocate:` service entries so a world's twins boot inside ONE host process (R2a). */
|
|
25
|
+
serveExport?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// The artifact is committed in the control plane and shipped with it: resolved through the package (@volter/twin,
|
|
29
|
+
// installed or workspace-linked), else the sibling workspace path. A missing file is a broken checkout (or an
|
|
30
|
+
// unbuilt generator change), never a soft state.
|
|
31
|
+
const FACTS_PATH = (() => {
|
|
32
|
+
try { return fileURLToPath(import.meta.resolve('@volter/twin/generated/pack-facts.json')); } catch { /* not resolvable: a checkout without node_modules */ }
|
|
33
|
+
return join(import.meta.dir, '..', '..', 'control-plane', 'generated', 'pack-facts.json');
|
|
34
|
+
})();
|
|
35
|
+
|
|
36
|
+
let cached: Record<string, PackFacts> | null = null;
|
|
37
|
+
export function packFacts(): Record<string, PackFacts> {
|
|
38
|
+
if (cached !== null) return cached;
|
|
39
|
+
let raw: string;
|
|
40
|
+
try {
|
|
41
|
+
raw = readFileSync(FACTS_PATH, 'utf8');
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`pack-facts artifact missing at ${FACTS_PATH} — run \`bun scripts/pack-facts.ts\` at the twin repo root and commit the result (${String(error)})`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
cached = (JSON.parse(raw) as { packs: Record<string, PackFacts> }).packs;
|
|
48
|
+
return cached;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const dual = (vendor: string, kind: string, key: string, holder: string): Error =>
|
|
52
|
+
new Error(
|
|
53
|
+
`pack-facts: ${kind} "${key}" is declared BOTH on the ${vendor} pack descriptor AND in the central ${holder} table — ` +
|
|
54
|
+
`the migration rule is one home per fact. Delete the central entry (the descriptor wins).`,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
/** Overlay descriptor-declared npm SDK names onto the central SDK_TWINS map (project-inspect.ts). */
|
|
58
|
+
/** The PyPI half of the SDK map, built the same way from each descriptor's `adoption.pypi`
|
|
59
|
+
* (PEP 503 names). One name belongs to one pack; a second claim refuses at load. */
|
|
60
|
+
export function overlayPypiTwins(pypiTwins: Record<string, { vendor: string; twin: string }>): void {
|
|
61
|
+
for (const [vendor, facts] of Object.entries(packFacts())) {
|
|
62
|
+
for (const raw of facts.adoption?.pypi ?? []) {
|
|
63
|
+
const name = normalizePypiName(raw);
|
|
64
|
+
if (Object.hasOwn(pypiTwins, name)) {
|
|
65
|
+
const holder = pypiTwins[name]!.vendor;
|
|
66
|
+
if (holder !== vendor) throw new Error(`pack-facts overlay: pypi package "${name}" is claimed by BOTH the "${holder}" and "${vendor}" packs' descriptors — one name, one pack`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
pypiTwins[name] = { vendor, twin: `@volter/twin-${vendor}` };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** PEP 503: case-insensitive, runs of `-_.` collapse to one `-`. */
|
|
74
|
+
export function normalizePypiName(name: string): string {
|
|
75
|
+
return name.trim().toLowerCase().replace(/[-_.]+/g, '-');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function overlaySdkTwins(sdkTwins: Record<string, { vendor: string; twin: string }>): void {
|
|
79
|
+
for (const [vendor, facts] of Object.entries(packFacts())) {
|
|
80
|
+
for (const sdk of facts.adoption?.sdks ?? []) {
|
|
81
|
+
// Object.hasOwn, not `in`: `in` walks the prototype chain, so a pathological name like
|
|
82
|
+
// 'constructor' would throw a bogus dual error. And when the holder is another PACK's
|
|
83
|
+
// overlay (two descriptors claiming one name), the right fix is different from a hand-
|
|
84
|
+
// table collision — say which it is (§9 round two L2, 2026-08-31).
|
|
85
|
+
if (Object.hasOwn(sdkTwins, sdk)) {
|
|
86
|
+
const holder = sdkTwins[sdk]!.vendor;
|
|
87
|
+
if (holder !== vendor) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`pack-facts overlay: npm sdk "${sdk}" is claimed by BOTH the "${holder}" and "${vendor}" packs' descriptors (or by ${holder} in the central SDK_TWINS table) — one home per fact: decide which pack owns the client and remove the other claim.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
throw dual(vendor, 'sdk', sdk, 'SDK_TWINS');
|
|
93
|
+
}
|
|
94
|
+
sdkTwins[sdk] = { vendor, twin: `@volter/twin-${vendor}` };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Overlay descriptor-declared scopes/env stems/world ids onto covers.ts's central maps. */
|
|
100
|
+
export function overlayCoversMaps(maps: {
|
|
101
|
+
scopeVendors: Record<string, string>;
|
|
102
|
+
envStemVendors: Record<string, string>;
|
|
103
|
+
vendorWorldIds: Record<string, string[]>;
|
|
104
|
+
}): void {
|
|
105
|
+
for (const [vendor, facts] of Object.entries(packFacts())) {
|
|
106
|
+
for (const scope of facts.adoption?.scopes ?? []) {
|
|
107
|
+
if (scope in maps.scopeVendors) throw dual(vendor, 'scope', scope, 'SDK_SCOPE_VENDORS');
|
|
108
|
+
maps.scopeVendors[scope] = vendor;
|
|
109
|
+
}
|
|
110
|
+
for (const stemRaw of facts.adoption?.envStems ?? []) {
|
|
111
|
+
const stem = stemRaw.toLowerCase();
|
|
112
|
+
if (stem in maps.envStemVendors) throw dual(vendor, 'env stem', stem, 'ENV_STEM_VENDORS');
|
|
113
|
+
maps.envStemVendors[stem] = vendor;
|
|
114
|
+
}
|
|
115
|
+
const worldIds = facts.adoption?.worldIds ?? [];
|
|
116
|
+
if (worldIds.length > 0) {
|
|
117
|
+
if (vendor in maps.vendorWorldIds) throw dual(vendor, 'world ids', vendor, 'VENDOR_WORLD_IDS');
|
|
118
|
+
maps.vendorWorldIds[vendor] = [vendor, ...worldIds];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Overlay descriptor-declared endpoint-env wiring onto init.ts's APP_READ_ENDPOINT_ENV map. */
|
|
124
|
+
export function overlayEndpointEnv(
|
|
125
|
+
table: Record<string, { injectEnv?: string; injectEnvTemplates?: Record<string, string>; note: string }>,
|
|
126
|
+
): void {
|
|
127
|
+
for (const [vendor, facts] of Object.entries(packFacts())) {
|
|
128
|
+
if (facts.endpointEnv === undefined) continue;
|
|
129
|
+
if (vendor in table) throw dual(vendor, 'endpoint env', vendor, 'APP_READ_ENDPOINT_ENV');
|
|
130
|
+
table[vendor] = {
|
|
131
|
+
injectEnv: facts.endpointEnv.name,
|
|
132
|
+
...(facts.endpointEnv.templates ? { injectEnvTemplates: facts.endpointEnv.templates } : {}),
|
|
133
|
+
note: facts.endpointEnv.note,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// The containerless backing for World-managed infrastructure: PGlite hosts
|
|
2
|
+
// (see pglite-host.ts) instead of docker compose. Selected by
|
|
3
|
+
// `volter-world-managed-infra` when no container runtime is available (or by
|
|
4
|
+
// explicit VOLTER_WORLD_INFRA_BACKING=pglite); the world config, definition
|
|
5
|
+
// file, injected env, and declared-service contract are byte-identical either
|
|
6
|
+
// way — backing is private, exactly as the boundary comment in
|
|
7
|
+
// managed-infra-cli.ts promises.
|
|
8
|
+
//
|
|
9
|
+
// Capability is stated, not stretched: this backing serves POSTGRES services
|
|
10
|
+
// only. A definition declaring mysql/redis without a container runtime is
|
|
11
|
+
// refused with the kinds named, never half-booted.
|
|
12
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { connect } from 'node:net';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
|
|
17
|
+
export interface InfraService { kind: string; hostPort: number }
|
|
18
|
+
|
|
19
|
+
/** Parse the services out of world.infrastructure.yml. This is NOT a YAML
|
|
20
|
+
* parser: init's renderComposeFile emits a deterministic shape, and this
|
|
21
|
+
* reads exactly that shape back (service key at 2-space indent = the kind;
|
|
22
|
+
* the single loopback port mapping beneath it). Anything else is a malformed
|
|
23
|
+
* definition and fails loudly. */
|
|
24
|
+
export function parseInfraDefinition(text: string): InfraService[] {
|
|
25
|
+
const services: InfraService[] = [];
|
|
26
|
+
let current: string | null = null;
|
|
27
|
+
let inServices = false;
|
|
28
|
+
for (const line of text.split(/\r?\n/)) {
|
|
29
|
+
if (/^services:\s*$/.test(line)) { inServices = true; continue; }
|
|
30
|
+
if (!inServices) continue;
|
|
31
|
+
const service = line.match(/^ {2}([a-z][a-z0-9_-]*):\s*$/);
|
|
32
|
+
if (service) { current = service[1]!; continue; }
|
|
33
|
+
const port = line.match(/^ {6}- "127\.0\.0\.1:(\d+):\d+"\s*$/);
|
|
34
|
+
if (port && current) {
|
|
35
|
+
services.push({ kind: current, hostPort: Number(port[1]) });
|
|
36
|
+
current = null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return services;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const HOST = join(import.meta.dir, 'pglite-host.mjs');
|
|
43
|
+
const READY_TIMEOUT_MS = 60_000;
|
|
44
|
+
|
|
45
|
+
function pidPath(dataDir: string, kind: string): string {
|
|
46
|
+
return join(dataDir, `pglite-${kind}.pid`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function alive(pid: number): boolean {
|
|
50
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function listening(port: number): Promise<boolean> {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const socket = connect({ host: '127.0.0.1', port, timeout: 1000 }, () => {
|
|
56
|
+
socket.destroy();
|
|
57
|
+
resolve(true);
|
|
58
|
+
});
|
|
59
|
+
socket.on('error', () => resolve(false));
|
|
60
|
+
socket.on('timeout', () => { socket.destroy(); resolve(false); });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
65
|
+
|
|
66
|
+
/** The kinds this backing cannot serve, or [] when it can serve the world. */
|
|
67
|
+
export function unsupportedKinds(services: InfraService[]): string[] {
|
|
68
|
+
return [...new Set(services.filter((s) => s.kind !== 'postgres').map((s) => s.kind))];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function pgliteUp(services: InfraService[], dataDir: string): Promise<void> {
|
|
72
|
+
for (const service of services) {
|
|
73
|
+
const pidFile = pidPath(dataDir, service.kind);
|
|
74
|
+
if (existsSync(pidFile) && alive(Number(readFileSync(pidFile, 'utf8').trim()))) continue;
|
|
75
|
+
const serviceData = join(dataDir, `pglite-${service.kind}-data`);
|
|
76
|
+
mkdirSync(serviceData, { recursive: true });
|
|
77
|
+
const log = join(dataDir, `pglite-${service.kind}.log`);
|
|
78
|
+
mkdirSync(dirname(log), { recursive: true });
|
|
79
|
+
// a real fd, not a pipe: the host must outlive this process untethered
|
|
80
|
+
const logFd = openSync(log, 'a');
|
|
81
|
+
const child = spawn('node', [HOST, '--port', String(service.hostPort), '--data', serviceData], {
|
|
82
|
+
detached: true,
|
|
83
|
+
stdio: ['ignore', logFd, logFd],
|
|
84
|
+
});
|
|
85
|
+
child.unref();
|
|
86
|
+
closeSync(logFd);
|
|
87
|
+
writeFileSync(pidFile, `${child.pid}\n`);
|
|
88
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
89
|
+
while (!(await listening(service.hostPort))) {
|
|
90
|
+
if (!alive(child.pid!)) {
|
|
91
|
+
throw new Error(`pglite ${service.kind} exited before serving — see ${log}`);
|
|
92
|
+
}
|
|
93
|
+
if (Date.now() > deadline) {
|
|
94
|
+
throw new Error(`pglite ${service.kind} did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
|
|
95
|
+
}
|
|
96
|
+
await sleep(200);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function pgliteStatus(services: InfraService[], dataDir: string): Promise<{ ok: boolean; ready: number }> {
|
|
102
|
+
let ready = 0;
|
|
103
|
+
for (const service of services) {
|
|
104
|
+
const pidFile = pidPath(dataDir, service.kind);
|
|
105
|
+
if (!existsSync(pidFile)) continue;
|
|
106
|
+
const pid = Number(readFileSync(pidFile, 'utf8').trim());
|
|
107
|
+
if (alive(pid) && await listening(service.hostPort)) ready += 1;
|
|
108
|
+
}
|
|
109
|
+
return { ok: ready === services.length && services.length > 0, ready };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function pgliteDown(services: InfraService[], dataDir: string): Promise<void> {
|
|
113
|
+
for (const service of services) {
|
|
114
|
+
const pidFile = pidPath(dataDir, service.kind);
|
|
115
|
+
if (!existsSync(pidFile)) continue;
|
|
116
|
+
const pid = Number(readFileSync(pidFile, 'utf8').trim());
|
|
117
|
+
if (alive(pid)) {
|
|
118
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
|
|
119
|
+
const deadline = Date.now() + 5000;
|
|
120
|
+
while (alive(pid) && Date.now() < deadline) await sleep(100);
|
|
121
|
+
if (alive(pid)) { try { process.kill(pid, 'SIGKILL'); } catch { /* raced */ } }
|
|
122
|
+
}
|
|
123
|
+
rmSync(pidFile, { force: true });
|
|
124
|
+
}
|
|
125
|
+
}
|