@volter/world-runtime 2.0.0
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/LICENSE +202 -0
- package/dist/known-external-services.json +1108 -0
- package/dist/src/ancestry.d.ts +2 -0
- package/dist/src/ancestry.js +42 -0
- package/dist/src/app-url.d.ts +47 -0
- package/dist/src/app-url.js +239 -0
- package/dist/src/attach.d.ts +48 -0
- package/dist/src/attach.js +87 -0
- package/dist/src/branch.d.ts +20 -0
- package/dist/src/branch.js +65 -0
- package/dist/src/browser-proxy-cli.d.ts +2 -0
- package/dist/src/browser-proxy-cli.js +41 -0
- package/dist/src/ca-trust.d.ts +5 -0
- package/dist/src/ca-trust.js +64 -0
- package/dist/src/catalog.d.ts +31 -0
- package/dist/src/catalog.js +148 -0
- package/dist/src/changeset.d.ts +142 -0
- package/dist/src/changeset.js +570 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +1262 -0
- package/dist/src/command-lifetime.d.ts +15 -0
- package/dist/src/command-lifetime.js +98 -0
- package/dist/src/configs.d.ts +18 -0
- package/dist/src/configs.js +119 -0
- package/dist/src/console-apart.d.ts +38 -0
- package/dist/src/console-apart.js +107 -0
- package/dist/src/consumers.d.ts +46 -0
- package/dist/src/consumers.js +200 -0
- package/dist/src/covers.d.ts +183 -0
- package/dist/src/covers.js +800 -0
- package/dist/src/fixture-env.d.ts +42 -0
- package/dist/src/fixture-env.js +221 -0
- package/dist/src/host-cli.d.ts +2 -0
- package/dist/src/host-cli.js +92 -0
- package/dist/src/host-fault-fixture.d.ts +32 -0
- package/dist/src/host-fault-fixture.js +100 -0
- package/dist/src/host-worker.d.ts +1 -0
- package/dist/src/host-worker.js +23 -0
- package/dist/src/host.d.ts +38 -0
- package/dist/src/host.js +135 -0
- package/dist/src/index.d.ts +48 -0
- package/dist/src/index.js +35 -0
- package/dist/src/infra-cli.d.ts +2 -0
- package/dist/src/infra-cli.js +136 -0
- package/dist/src/init.d.ts +227 -0
- package/dist/src/init.js +1117 -0
- package/dist/src/inject-map.d.ts +34 -0
- package/dist/src/inject-map.js +56 -0
- package/dist/src/lifecycle-record.d.ts +47 -0
- package/dist/src/lifecycle-record.js +196 -0
- package/dist/src/origin.d.ts +31 -0
- package/dist/src/origin.js +139 -0
- package/dist/src/pack-facts.d.ts +75 -0
- package/dist/src/pack-facts.js +98 -0
- package/dist/src/pglite-backing.d.ts +21 -0
- package/dist/src/pglite-backing.js +158 -0
- package/dist/src/pglite-host.mjs +147 -0
- package/dist/src/placeholder.d.ts +20 -0
- package/dist/src/placeholder.js +100 -0
- package/dist/src/prerequisites.d.ts +21 -0
- package/dist/src/prerequisites.js +49 -0
- package/dist/src/process-groups.d.ts +4 -0
- package/dist/src/process-groups.js +49 -0
- package/dist/src/project-inspect.d.ts +109 -0
- package/dist/src/project-inspect.js +827 -0
- package/dist/src/proxy-daemon.d.ts +2 -0
- package/dist/src/proxy-daemon.js +18 -0
- package/dist/src/redirect-proxy.d.ts +105 -0
- package/dist/src/redirect-proxy.js +665 -0
- package/dist/src/reflect.d.ts +74 -0
- package/dist/src/reflect.js +392 -0
- package/dist/src/resources.d.ts +26 -0
- package/dist/src/resources.js +22 -0
- package/dist/src/root.d.ts +114 -0
- package/dist/src/root.js +312 -0
- package/dist/src/run-task-worker.d.ts +1 -0
- package/dist/src/run-task-worker.js +38 -0
- package/dist/src/run-task.d.ts +18 -0
- package/dist/src/run-task.js +48 -0
- package/dist/src/runtime-test-support.d.ts +59 -0
- package/dist/src/runtime-test-support.js +205 -0
- package/dist/src/runtime.d.ts +256 -0
- package/dist/src/runtime.js +3502 -0
- package/dist/src/schema.d.ts +449 -0
- package/dist/src/schema.js +605 -0
- package/dist/src/serve.d.ts +30 -0
- package/dist/src/serve.js +82 -0
- package/dist/src/served-world.d.ts +194 -0
- package/dist/src/served-world.js +986 -0
- package/dist/src/service-exit.d.ts +46 -0
- package/dist/src/service-exit.js +195 -0
- package/dist/src/service-recorder.d.ts +1 -0
- package/dist/src/service-recorder.js +121 -0
- package/dist/src/sibling.d.ts +1 -0
- package/dist/src/sibling.js +9 -0
- package/dist/src/signals.d.ts +1 -0
- package/dist/src/signals.js +11 -0
- package/dist/src/storage-capacity.d.ts +8 -0
- package/dist/src/storage-capacity.js +61 -0
- package/dist/src/tail.d.ts +30 -0
- package/dist/src/tail.js +160 -0
- package/dist/src/tcp-port.d.ts +2 -0
- package/dist/src/tcp-port.js +36 -0
- package/dist/src/up-task-worker.d.ts +1 -0
- package/dist/src/up-task-worker.js +61 -0
- package/dist/src/up-task.d.ts +17 -0
- package/dist/src/up-task.js +49 -0
- package/dist/src/websocket-relay.d.ts +3 -0
- package/dist/src/websocket-relay.js +40 -0
- package/known-external-services.json +1108 -0
- package/package.json +83 -0
- package/src/ancestry.ts +36 -0
- package/src/app-url.ts +253 -0
- package/src/attach.ts +117 -0
- package/src/branch.ts +63 -0
- package/src/browser-proxy-cli.ts +44 -0
- package/src/ca-trust.ts +57 -0
- package/src/catalog.ts +156 -0
- package/src/changeset.ts +627 -0
- package/src/cli.ts +1111 -0
- package/src/command-lifetime.ts +79 -0
- package/src/configs.ts +110 -0
- package/src/console-apart.ts +90 -0
- package/src/consumers.ts +185 -0
- package/src/covers.ts +934 -0
- package/src/fixture-env.ts +230 -0
- package/src/host-cli.ts +90 -0
- package/src/host-worker.ts +23 -0
- package/src/host.ts +169 -0
- package/src/index.ts +171 -0
- package/src/infra-cli.ts +133 -0
- package/src/init.ts +1316 -0
- package/src/inject-map.ts +72 -0
- package/src/lifecycle-record.ts +168 -0
- package/src/origin.ts +134 -0
- package/src/pack-facts.ts +128 -0
- package/src/pglite-backing.ts +141 -0
- package/src/pglite-host.mjs +147 -0
- package/src/placeholder.ts +89 -0
- package/src/prerequisites.ts +66 -0
- package/src/process-groups.ts +33 -0
- package/src/project-inspect.ts +770 -0
- package/src/proxy-daemon.ts +21 -0
- package/src/redirect-proxy.ts +684 -0
- package/src/reflect.ts +440 -0
- package/src/resources.ts +22 -0
- package/src/root.ts +290 -0
- package/src/run-task-worker.ts +27 -0
- package/src/run-task.ts +44 -0
- package/src/runtime-test-support.ts +208 -0
- package/src/runtime.ts +3357 -0
- package/src/schema.ts +922 -0
- package/src/serve.ts +102 -0
- package/src/served-world.ts +812 -0
- package/src/service-exit.ts +175 -0
- package/src/service-recorder.ts +89 -0
- package/src/sibling.ts +10 -0
- package/src/signals.ts +10 -0
- package/src/storage-capacity.ts +60 -0
- package/src/tail.ts +205 -0
- package/src/tcp-port.ts +35 -0
- package/src/up-task-worker.ts +40 -0
- package/src/up-task.ts +45 -0
- package/src/websocket-relay.ts +32 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// PACK FACTS OVERLAY — the world-runtime consumer of the generated pack-facts artifact
|
|
2
|
+
// (packages/world-core/generated/pack-facts.json, compiled by scripts/pack-facts.ts
|
|
3
|
+
// from each pack's `pack: TwinPack` descriptor).
|
|
4
|
+
//
|
|
5
|
+
// During the descriptor-first migration (adding-a-twin.md §3) (adding-a-twin.md §3 (the descriptor is the one home)), 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
|
+
import factsDocument from '@volter/world-core/generated/pack-facts.json' with { type: 'json' };
|
|
11
|
+
// world-runtime and core are sibling workspace packages; the artifact is committed,
|
|
12
|
+
// so a missing file is a broken checkout (or an unbuilt generator change), never a soft state.
|
|
13
|
+
// core's artifact, imported as a JSON module: the published runtime resolves it beside an installed
|
|
14
|
+
// core, a bundle carries it, and no host needs a filesystem to read it
|
|
15
|
+
export function packFacts() {
|
|
16
|
+
return factsDocument.packs;
|
|
17
|
+
}
|
|
18
|
+
const dual = (vendor, kind, key, holder) => new Error(`pack-facts: ${kind} "${key}" is declared BOTH on the ${vendor} pack descriptor AND in the central ${holder} table — ` +
|
|
19
|
+
`the migration rule is one home per fact. Delete the central entry (the descriptor wins).`);
|
|
20
|
+
/** Overlay descriptor-declared npm SDK names onto the central SDK_TWINS map (project-inspect.ts). */
|
|
21
|
+
/** The PyPI half of the SDK map, built the same way from each descriptor's `adoption.pypi`
|
|
22
|
+
* (PEP 503 names). One name belongs to one pack; a second claim refuses at load. */
|
|
23
|
+
export function overlayPypiTwins(pypiTwins) {
|
|
24
|
+
for (const [vendor, facts] of Object.entries(packFacts())) {
|
|
25
|
+
for (const raw of facts.adoption?.pypi ?? []) {
|
|
26
|
+
const name = normalizePypiName(raw);
|
|
27
|
+
if (Object.hasOwn(pypiTwins, name)) {
|
|
28
|
+
const holder = pypiTwins[name].vendor;
|
|
29
|
+
if (holder !== vendor)
|
|
30
|
+
throw new Error(`pack-facts overlay: pypi package "${name}" is claimed by BOTH the "${holder}" and "${vendor}" packs' descriptors — one name, one pack`);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
pypiTwins[name] = { vendor, twin: `@volter/twin-${vendor}` };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** PEP 503: case-insensitive, runs of `-_.` collapse to one `-`. */
|
|
38
|
+
export function normalizePypiName(name) {
|
|
39
|
+
return name.trim().toLowerCase().replace(/[-_.]+/g, '-');
|
|
40
|
+
}
|
|
41
|
+
export function overlaySdkTwins(sdkTwins) {
|
|
42
|
+
for (const [vendor, facts] of Object.entries(packFacts())) {
|
|
43
|
+
for (const sdk of facts.adoption?.sdks ?? []) {
|
|
44
|
+
// Object.hasOwn, not `in`: `in` walks the prototype chain, so a pathological name like
|
|
45
|
+
// 'constructor' would throw a bogus dual error. And when the holder is another PACK's
|
|
46
|
+
// overlay (two descriptors claiming one name), the right fix is different from a hand-
|
|
47
|
+
// table collision — say which it is (§9 round two L2, 2026-08-31).
|
|
48
|
+
if (Object.hasOwn(sdkTwins, sdk)) {
|
|
49
|
+
const holder = sdkTwins[sdk].vendor;
|
|
50
|
+
if (holder !== vendor) {
|
|
51
|
+
throw new Error(`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.`);
|
|
52
|
+
}
|
|
53
|
+
throw dual(vendor, 'sdk', sdk, 'SDK_TWINS');
|
|
54
|
+
}
|
|
55
|
+
sdkTwins[sdk] = { vendor, twin: `@volter/twin-${vendor}` };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Overlay descriptor-declared scopes/env stems/world ids onto covers.ts's central maps. */
|
|
60
|
+
export function overlayCoversMaps(maps) {
|
|
61
|
+
for (const [vendor, facts] of Object.entries(packFacts())) {
|
|
62
|
+
for (const scope of facts.adoption?.scopes ?? []) {
|
|
63
|
+
if (scope in maps.scopeVendors)
|
|
64
|
+
throw dual(vendor, 'scope', scope, 'SDK_SCOPE_VENDORS');
|
|
65
|
+
maps.scopeVendors[scope] = vendor;
|
|
66
|
+
}
|
|
67
|
+
for (const stemRaw of facts.adoption?.envStems ?? []) {
|
|
68
|
+
const stem = stemRaw.toLowerCase();
|
|
69
|
+
if (stem in maps.envStemVendors)
|
|
70
|
+
throw dual(vendor, 'env stem', stem, 'ENV_STEM_VENDORS');
|
|
71
|
+
maps.envStemVendors[stem] = vendor;
|
|
72
|
+
}
|
|
73
|
+
const worldIds = facts.adoption?.worldIds ?? [];
|
|
74
|
+
if (worldIds.length > 0) {
|
|
75
|
+
if (vendor in maps.vendorWorldIds)
|
|
76
|
+
throw dual(vendor, 'world ids', vendor, 'VENDOR_WORLD_IDS');
|
|
77
|
+
maps.vendorWorldIds[vendor] = [vendor, ...worldIds];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Overlay descriptor-declared endpoint-env wiring for packs with no injectable host.
|
|
82
|
+
* Packs that also declare hosts are wired through the injector; init reads their optional
|
|
83
|
+
* endpoint templates directly from pack facts without misclassifying them as app-read-only. */
|
|
84
|
+
export function overlayEndpointEnv(table) {
|
|
85
|
+
for (const [vendor, facts] of Object.entries(packFacts())) {
|
|
86
|
+
if (facts.endpointEnv === undefined)
|
|
87
|
+
continue;
|
|
88
|
+
if ((facts.hosts?.length ?? 0) > 0)
|
|
89
|
+
continue;
|
|
90
|
+
if (vendor in table)
|
|
91
|
+
throw dual(vendor, 'endpoint env', vendor, 'APP_READ_ENDPOINT_ENV');
|
|
92
|
+
table[vendor] = {
|
|
93
|
+
injectEnv: facts.endpointEnv.name,
|
|
94
|
+
...(facts.endpointEnv.templates ? { injectEnvTemplates: facts.endpointEnv.templates } : {}),
|
|
95
|
+
note: facts.endpointEnv.note,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface InfraService {
|
|
2
|
+
kind: string;
|
|
3
|
+
hostPort: number;
|
|
4
|
+
}
|
|
5
|
+
/** Parse the services out of world.infrastructure.yml. This is NOT a YAML
|
|
6
|
+
* parser: init's renderComposeFile emits a deterministic shape, and this
|
|
7
|
+
* reads exactly that shape back (service key at 2-space indent = the kind;
|
|
8
|
+
* the single loopback port mapping beneath it). Anything else is a malformed
|
|
9
|
+
* definition and fails loudly. */
|
|
10
|
+
export declare function parseInfraDefinition(text: string): InfraService[];
|
|
11
|
+
/** Endpoints of the declared infrastructure, published through external.discover. Only URLs
|
|
12
|
+
* whose protocol and loopback port match a declared service belong to this lifecycle. */
|
|
13
|
+
export declare function infraConnections(services: InfraService[], env: Record<string, string | undefined>): Record<string, string>;
|
|
14
|
+
/** The kinds this backing cannot serve, or [] when it can serve the world. */
|
|
15
|
+
export declare function unsupportedKinds(services: InfraService[]): string[];
|
|
16
|
+
export declare function pgliteUp(services: InfraService[], dataDir: string): Promise<void>;
|
|
17
|
+
export declare function pgliteStatus(services: InfraService[], dataDir: string): Promise<{
|
|
18
|
+
ok: boolean;
|
|
19
|
+
ready: number;
|
|
20
|
+
}>;
|
|
21
|
+
export declare function pgliteDown(services: InfraService[], dataDir: string): Promise<void>;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// The containerless backing for World-managed infrastructure: PGlite hosts
|
|
2
|
+
// (see pglite-host.ts) instead of docker compose. Selected by
|
|
3
|
+
// `volter-world-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
|
+
// 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
|
+
/** Parse the services out of world.infrastructure.yml. This is NOT a YAML
|
|
17
|
+
* parser: init's renderComposeFile emits a deterministic shape, and this
|
|
18
|
+
* reads exactly that shape back (service key at 2-space indent = the kind;
|
|
19
|
+
* the single loopback port mapping beneath it). Anything else is a malformed
|
|
20
|
+
* definition and fails loudly. */
|
|
21
|
+
export function parseInfraDefinition(text) {
|
|
22
|
+
const services = [];
|
|
23
|
+
let current = null;
|
|
24
|
+
let inServices = false;
|
|
25
|
+
for (const line of text.split(/\r?\n/)) {
|
|
26
|
+
if (/^services:\s*$/.test(line)) {
|
|
27
|
+
inServices = true;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (!inServices)
|
|
31
|
+
continue;
|
|
32
|
+
const service = line.match(/^ {2}([a-z][a-z0-9_-]*):\s*$/);
|
|
33
|
+
if (service) {
|
|
34
|
+
current = service[1];
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const port = line.match(/^ {6}- "127\.0\.0\.1:(\d+):\d+"\s*$/);
|
|
38
|
+
if (port && current) {
|
|
39
|
+
services.push({ kind: current, hostPort: Number(port[1]) });
|
|
40
|
+
current = null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return services;
|
|
44
|
+
}
|
|
45
|
+
/** Endpoints of the declared infrastructure, published through external.discover. Only URLs
|
|
46
|
+
* whose protocol and loopback port match a declared service belong to this lifecycle. */
|
|
47
|
+
export function infraConnections(services, env) {
|
|
48
|
+
const connections = {};
|
|
49
|
+
for (const [name, value] of Object.entries(env)) {
|
|
50
|
+
if (value === undefined)
|
|
51
|
+
continue;
|
|
52
|
+
try {
|
|
53
|
+
const url = new URL(value);
|
|
54
|
+
if (url.hostname !== '127.0.0.1')
|
|
55
|
+
continue;
|
|
56
|
+
const kind = url.protocol === 'postgresql:' ? 'postgres' : url.protocol.slice(0, -1);
|
|
57
|
+
if (services.some((service) => service.kind === kind && service.hostPort === Number(url.port)))
|
|
58
|
+
connections[name] = value;
|
|
59
|
+
}
|
|
60
|
+
catch { /* not an infrastructure endpoint */ }
|
|
61
|
+
}
|
|
62
|
+
return connections;
|
|
63
|
+
}
|
|
64
|
+
const HOST = join(import.meta.dir, 'pglite-host.mjs');
|
|
65
|
+
const READY_TIMEOUT_MS = 60_000;
|
|
66
|
+
function pidPath(dataDir, kind) {
|
|
67
|
+
return join(dataDir, `pglite-${kind}.pid`);
|
|
68
|
+
}
|
|
69
|
+
function alive(pid) {
|
|
70
|
+
try {
|
|
71
|
+
process.kill(pid, 0);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function listening(port) {
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const socket = connect({ host: '127.0.0.1', port, timeout: 1000 }, () => {
|
|
81
|
+
socket.destroy();
|
|
82
|
+
resolve(true);
|
|
83
|
+
});
|
|
84
|
+
socket.on('error', () => resolve(false));
|
|
85
|
+
socket.on('timeout', () => { socket.destroy(); resolve(false); });
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
89
|
+
/** The kinds this backing cannot serve, or [] when it can serve the world. */
|
|
90
|
+
export function unsupportedKinds(services) {
|
|
91
|
+
return [...new Set(services.filter((s) => s.kind !== 'postgres').map((s) => s.kind))];
|
|
92
|
+
}
|
|
93
|
+
export async function pgliteUp(services, dataDir) {
|
|
94
|
+
for (const service of services) {
|
|
95
|
+
const pidFile = pidPath(dataDir, service.kind);
|
|
96
|
+
if (existsSync(pidFile) && alive(Number(readFileSync(pidFile, 'utf8').trim())))
|
|
97
|
+
continue;
|
|
98
|
+
const serviceData = join(dataDir, `pglite-${service.kind}-data`);
|
|
99
|
+
mkdirSync(serviceData, { recursive: true });
|
|
100
|
+
const log = join(dataDir, `pglite-${service.kind}.log`);
|
|
101
|
+
mkdirSync(dirname(log), { recursive: true });
|
|
102
|
+
// a real fd, not a pipe: the host must outlive this process untethered
|
|
103
|
+
const logFd = openSync(log, 'a');
|
|
104
|
+
const child = spawn('node', [HOST, '--port', String(service.hostPort), '--data', serviceData], {
|
|
105
|
+
detached: true,
|
|
106
|
+
stdio: ['ignore', logFd, logFd],
|
|
107
|
+
});
|
|
108
|
+
child.unref();
|
|
109
|
+
closeSync(logFd);
|
|
110
|
+
writeFileSync(pidFile, `${child.pid}\n`);
|
|
111
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
112
|
+
while (!(await listening(service.hostPort))) {
|
|
113
|
+
if (!alive(child.pid)) {
|
|
114
|
+
throw new Error(`pglite ${service.kind} exited before serving — see ${log}`);
|
|
115
|
+
}
|
|
116
|
+
if (Date.now() > deadline) {
|
|
117
|
+
throw new Error(`pglite ${service.kind} did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
|
|
118
|
+
}
|
|
119
|
+
await sleep(200);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export async function pgliteStatus(services, dataDir) {
|
|
124
|
+
let ready = 0;
|
|
125
|
+
for (const service of services) {
|
|
126
|
+
const pidFile = pidPath(dataDir, service.kind);
|
|
127
|
+
if (!existsSync(pidFile))
|
|
128
|
+
continue;
|
|
129
|
+
const pid = Number(readFileSync(pidFile, 'utf8').trim());
|
|
130
|
+
if (alive(pid) && await listening(service.hostPort))
|
|
131
|
+
ready += 1;
|
|
132
|
+
}
|
|
133
|
+
return { ok: ready === services.length && services.length > 0, ready };
|
|
134
|
+
}
|
|
135
|
+
export async function pgliteDown(services, dataDir) {
|
|
136
|
+
for (const service of services) {
|
|
137
|
+
const pidFile = pidPath(dataDir, service.kind);
|
|
138
|
+
if (!existsSync(pidFile))
|
|
139
|
+
continue;
|
|
140
|
+
const pid = Number(readFileSync(pidFile, 'utf8').trim());
|
|
141
|
+
if (alive(pid)) {
|
|
142
|
+
try {
|
|
143
|
+
process.kill(pid, 'SIGTERM');
|
|
144
|
+
}
|
|
145
|
+
catch { /* already gone */ }
|
|
146
|
+
const deadline = Date.now() + 5000;
|
|
147
|
+
while (alive(pid) && Date.now() < deadline)
|
|
148
|
+
await sleep(100);
|
|
149
|
+
if (alive(pid)) {
|
|
150
|
+
try {
|
|
151
|
+
process.kill(pid, 'SIGKILL');
|
|
152
|
+
}
|
|
153
|
+
catch { /* raced */ }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
rmSync(pidFile, { force: true });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// One managed-infrastructure postgres service, served without a container:
|
|
3
|
+
// PGlite (real Postgres compiled to WASM) behind a pg-gateway wire-protocol
|
|
4
|
+
// front. Spawned by `volter-world-infra` when the pglite backing is
|
|
5
|
+
// selected; never operated directly (the same contract as the compose file).
|
|
6
|
+
//
|
|
7
|
+
// Plain .mjs under node, deliberately: PGlite's WASM init does not complete
|
|
8
|
+
// under bun, and under node its create() promise only progresses while
|
|
9
|
+
// something holds the event loop — an interval carries the init, and the TCP
|
|
10
|
+
// server starts listening only once the backend is ready, so a port that
|
|
11
|
+
// answers is a port that serves.
|
|
12
|
+
//
|
|
13
|
+
// PGlite is a SINGLE-SESSION backend, so this host serializes connections
|
|
14
|
+
// with a transaction-affinity lock: a connection acquires the global lock at
|
|
15
|
+
// its first protocol message and keeps it for as long as the backend reports
|
|
16
|
+
// an open transaction (ReadyForQuery status != 'I'). Without the hold, two
|
|
17
|
+
// pooled clients interleave into one session and a ROLLBACK on one swallows
|
|
18
|
+
// the other's writes. A connection that dies mid-transaction is rolled back
|
|
19
|
+
// before the lock is released.
|
|
20
|
+
//
|
|
21
|
+
// Identity is aliased, not faked: trust auth accepts the world's declared
|
|
22
|
+
// user/password/database, but the backend is PGlite's single `postgres`
|
|
23
|
+
// database — apps that introspect current_database() will see that. Worlds
|
|
24
|
+
// accept this; an app needing true multi-database Postgres needs the
|
|
25
|
+
// container backing.
|
|
26
|
+
import net from 'node:net';
|
|
27
|
+
import { mkdirSync } from 'node:fs';
|
|
28
|
+
import { PGlite } from '@electric-sql/pglite';
|
|
29
|
+
import { fromNodeSocket } from 'pg-gateway/node';
|
|
30
|
+
|
|
31
|
+
function arg(name) {
|
|
32
|
+
const index = process.argv.indexOf(name);
|
|
33
|
+
const value = index >= 0 ? process.argv[index + 1] : undefined;
|
|
34
|
+
if (!value) {
|
|
35
|
+
process.stderr.write(`pglite-host: ${name} is required\n`);
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const port = Number(arg('--port'));
|
|
42
|
+
const dataDir = arg('--data');
|
|
43
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
44
|
+
process.stderr.write('pglite-host: --port must be 1-65535\n');
|
|
45
|
+
process.exit(2);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
mkdirSync(dataDir, { recursive: true });
|
|
49
|
+
// stdio may be a log fd whose reader is gone; never die on a status line
|
|
50
|
+
process.stdout.on('error', () => {});
|
|
51
|
+
process.stderr.on('error', () => {});
|
|
52
|
+
// PGlite's create() only progresses while something holds the event loop;
|
|
53
|
+
// listening starts AFTER readiness so "port answers" means "serves queries"
|
|
54
|
+
const initKeepAlive = setInterval(() => {}, 500);
|
|
55
|
+
const dbReady = PGlite.create({ dataDir });
|
|
56
|
+
dbReady.catch((error) => {
|
|
57
|
+
process.stderr.write(`pglite-host: backend failed to start: ${error?.message ?? error}\n`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ---- transaction-affinity lock ---------------------------------------------
|
|
62
|
+
let holder = null;
|
|
63
|
+
const waiters = [];
|
|
64
|
+
async function acquire(token) {
|
|
65
|
+
while (holder !== null && holder !== token) {
|
|
66
|
+
await new Promise((resolve) => waiters.push(resolve));
|
|
67
|
+
}
|
|
68
|
+
holder = token;
|
|
69
|
+
}
|
|
70
|
+
function release(token) {
|
|
71
|
+
if (holder !== token) return;
|
|
72
|
+
holder = null;
|
|
73
|
+
const next = waiters.shift();
|
|
74
|
+
if (next) next();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The transaction status of the LAST ReadyForQuery ('Z') message in a raw
|
|
78
|
+
* protocol response, or null when the response carries none (mid-pipeline:
|
|
79
|
+
* the sender keeps the lock). 'I' = idle, 'T' = in transaction, 'E' = failed
|
|
80
|
+
* transaction. */
|
|
81
|
+
function lastReadyStatus(response) {
|
|
82
|
+
let status = null;
|
|
83
|
+
let offset = 0;
|
|
84
|
+
while (offset + 5 <= response.length) {
|
|
85
|
+
const type = response[offset];
|
|
86
|
+
const length = (response[offset + 1] << 24) | (response[offset + 2] << 16)
|
|
87
|
+
| (response[offset + 3] << 8) | response[offset + 4];
|
|
88
|
+
if (length < 4) break; // malformed; stop scanning rather than loop
|
|
89
|
+
if (type === 0x5a /* 'Z' */ && offset + 5 < response.length) {
|
|
90
|
+
status = String.fromCharCode(response[offset + 5]);
|
|
91
|
+
}
|
|
92
|
+
offset += 1 + length;
|
|
93
|
+
}
|
|
94
|
+
return status;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const server = net.createServer(async (socket) => {
|
|
98
|
+
const token = {};
|
|
99
|
+
let holding = false;
|
|
100
|
+
socket.on('close', () => {
|
|
101
|
+
if (!holding) return;
|
|
102
|
+
// the client vanished inside its transaction: roll it back, then let the
|
|
103
|
+
// next connection in — never leak a half-open transaction into a stranger
|
|
104
|
+
void dbReady
|
|
105
|
+
.then((db) => db.query('ROLLBACK'))
|
|
106
|
+
.catch(() => {})
|
|
107
|
+
.then(() => {
|
|
108
|
+
holding = false;
|
|
109
|
+
release(token);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
socket.on('error', () => { /* close handler owns cleanup */ });
|
|
113
|
+
await fromNodeSocket(socket, {
|
|
114
|
+
serverVersion: '18.3 (PGlite)',
|
|
115
|
+
auth: { method: 'trust' },
|
|
116
|
+
async onMessage(data, { isAuthenticated }) {
|
|
117
|
+
if (!isAuthenticated) return undefined; // gateway owns startup/auth traffic
|
|
118
|
+
const db = await dbReady;
|
|
119
|
+
await acquire(token);
|
|
120
|
+
holding = true;
|
|
121
|
+
const response = await db.execProtocolRaw(data);
|
|
122
|
+
const status = lastReadyStatus(response);
|
|
123
|
+
if (status === 'I') {
|
|
124
|
+
holding = false;
|
|
125
|
+
release(token);
|
|
126
|
+
}
|
|
127
|
+
return response;
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
void dbReady.then(() => {
|
|
133
|
+
clearInterval(initKeepAlive);
|
|
134
|
+
server.listen(port, '127.0.0.1', () => {
|
|
135
|
+
process.stdout.write(`pglite-host serving postgres wire protocol on 127.0.0.1:${port}\n`);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
for (const signal of ['SIGTERM', 'SIGINT']) {
|
|
140
|
+
process.on(signal, () => {
|
|
141
|
+
server.close(() => {
|
|
142
|
+
void dbReady.then((db) => db.close()).finally(() => process.exit(0));
|
|
143
|
+
});
|
|
144
|
+
// a hung close must not make `down` hang the world
|
|
145
|
+
setTimeout(() => process.exit(0), 3000).unref();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { WorldInstance } from './schema.js';
|
|
2
|
+
export type SeedWorldOptions = {
|
|
3
|
+
root?: string;
|
|
4
|
+
entry?: string;
|
|
5
|
+
cwd?: string;
|
|
6
|
+
};
|
|
7
|
+
export type SeedWorldOutcome = {
|
|
8
|
+
world: string;
|
|
9
|
+
entry: string;
|
|
10
|
+
exitCode: number;
|
|
11
|
+
observed: Record<string, number>;
|
|
12
|
+
};
|
|
13
|
+
/** The seed entry `init` emits beside the world config, unless the caller names one. */
|
|
14
|
+
export declare function seedEntryFor(instance: WorldInstance, entry?: string): string;
|
|
15
|
+
/** Pull from the placeholder: every twin's window open around the seed entry. */
|
|
16
|
+
export declare function seedWorld(name: string, options?: SeedWorldOptions): Promise<SeedWorldOutcome>;
|
|
17
|
+
/** Back to the defaults: down with purge, up from the same config and env file, seed. */
|
|
18
|
+
export declare function resetWorld(name: string, options?: SeedWorldOptions): Promise<SeedWorldOutcome & {
|
|
19
|
+
instance: WorldInstance;
|
|
20
|
+
}>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// `volter-world seed <world>` and `volter-world reset <world>` — the placeholder remote's verbs
|
|
2
|
+
// (docs/concepts/the-model.md#a-branch-is-a-position-not-a-copy, "default data"). `seed` opens every twin's placeholder-pull window,
|
|
3
|
+
// runs the world's seed entry under the world env (the pack defaults and your story, through each
|
|
4
|
+
// vendor's own API), and closes the windows: the seed lands as the placeholder's observed state,
|
|
5
|
+
// `diff` reads empty, nothing is pending. `reset` is a pull from the placeholder again — down
|
|
6
|
+
// with purge, up, seed — so a world returns to its defaults at any time; local work you want to
|
|
7
|
+
// keep is a fork, taken before.
|
|
8
|
+
import { materializeRoots } from "./root.js";
|
|
9
|
+
import { loadWorldConfig } from "./configs.js";
|
|
10
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
11
|
+
import { dirname, join, resolve } from 'node:path';
|
|
12
|
+
import { beginPlaceholderPull, endPlaceholderPull, listEvents, PLACEHOLDER_REMOTE, worldStateRoot } from '@volter/world-core';
|
|
13
|
+
import { downWorld, runWithWorldEnv, saveWorldInstance, statusWorld, upWorld } from "./runtime.js";
|
|
14
|
+
/** The seed entry `init` emits beside the world config, unless the caller names one. */
|
|
15
|
+
export function seedEntryFor(instance, entry) {
|
|
16
|
+
const path = resolve(entry ?? join(dirname(instance.configPath), 'seed.ts'));
|
|
17
|
+
if (!existsSync(path))
|
|
18
|
+
throw new Error(`No seed entry at ${path} — \`volter-world init\` writes one beside the world config (seed.ts); pass --entry <file> to name another`);
|
|
19
|
+
return path;
|
|
20
|
+
}
|
|
21
|
+
/** Placeholder rows under one world service's root, summed across every state service the twin
|
|
22
|
+
* records under (slack records under `chat`): every state dir with an events log, whether or not
|
|
23
|
+
* an actions log exists yet (a placeholder-only twin has none). */
|
|
24
|
+
function placeholderCount(controlRoot) {
|
|
25
|
+
const stateRoot = worldStateRoot(controlRoot);
|
|
26
|
+
if (!existsSync(stateRoot))
|
|
27
|
+
return 0;
|
|
28
|
+
let n = 0;
|
|
29
|
+
for (const entry of readdirSync(stateRoot, { withFileTypes: true })) {
|
|
30
|
+
if (!entry.isDirectory() || !existsSync(join(stateRoot, entry.name, 'events.jsonl')))
|
|
31
|
+
continue;
|
|
32
|
+
try {
|
|
33
|
+
n += listEvents(entry.name, controlRoot).filter((e) => e.data?.remote === PLACEHOLDER_REMOTE).length;
|
|
34
|
+
}
|
|
35
|
+
catch { /* unreadable ledger: not counted */ }
|
|
36
|
+
}
|
|
37
|
+
return n;
|
|
38
|
+
}
|
|
39
|
+
/** Pull from the placeholder: every twin's window open around the seed entry. */
|
|
40
|
+
export async function seedWorld(name, options = {}) {
|
|
41
|
+
const root = resolve(options.root ?? process.cwd());
|
|
42
|
+
const instance = statusWorld(name, root);
|
|
43
|
+
const entry = seedEntryFor(instance, options.entry);
|
|
44
|
+
const services = Object.keys(instance.services).sort();
|
|
45
|
+
const at = new Date().toISOString();
|
|
46
|
+
for (const service of services)
|
|
47
|
+
beginPlaceholderPull(service, join(instance.dirs.data, service), at);
|
|
48
|
+
let exitCode = 1;
|
|
49
|
+
try {
|
|
50
|
+
// The runtime that is running runs the seed: Bun, or Node with its TypeScript type stripping
|
|
51
|
+
// (`--experimental-strip-types`, accepted from Node 22.6 and on by default since 23.6). Never
|
|
52
|
+
// `bun` by name: the published runtime supports Node alone, and the tab's engine has no Bun.
|
|
53
|
+
if (typeof Bun === 'undefined' && /\.[cm]?ts$/.test(entry)) {
|
|
54
|
+
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
55
|
+
if (major < 22 || (major === 22 && minor < 6))
|
|
56
|
+
throw new Error(`${entry} is TypeScript: seeding it under Node needs Node 22.6 or newer (type stripping), or Bun; this is Node ${process.versions.node}`);
|
|
57
|
+
}
|
|
58
|
+
const runner = typeof Bun !== 'undefined' ? ['bun', entry] : [process.execPath, '--experimental-strip-types', '--no-warnings', entry];
|
|
59
|
+
exitCode = await runWithWorldEnv(name, runner, root, { cwd: options.cwd ?? dirname(entry) });
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
for (const service of services)
|
|
63
|
+
endPlaceholderPull(service, join(instance.dirs.data, service));
|
|
64
|
+
}
|
|
65
|
+
const observed = {};
|
|
66
|
+
for (const service of services)
|
|
67
|
+
observed[service] = placeholderCount(join(instance.dirs.data, service));
|
|
68
|
+
return { world: name, entry, exitCode, observed };
|
|
69
|
+
}
|
|
70
|
+
/** Back to the defaults: down with purge, up from the same config and env file, seed. */
|
|
71
|
+
export async function resetWorld(name, options = {}) {
|
|
72
|
+
const root = resolve(options.root ?? process.cwd());
|
|
73
|
+
const before = statusWorld(name, root);
|
|
74
|
+
// A world whose twins ship no default data has no seed entry: back to the defaults is back to
|
|
75
|
+
// empty, and that is a reset too — not an error.
|
|
76
|
+
const entry = existsSync(resolve(options.entry ?? join(dirname(before.configPath), 'seed.ts'))) ? seedEntryFor(before, options.entry) : null;
|
|
77
|
+
const stopped = await downWorld(name, root, { purge: true });
|
|
78
|
+
if (!stopped.purged || stopped.externalErrors.length)
|
|
79
|
+
throw new Error(`World reset refused: teardown did not purge the old instance (${stopped.externalErrors.join('; ')})`);
|
|
80
|
+
// a world booted from a config PATH re-ups from that path; one booted by id, by id
|
|
81
|
+
const booted = await upWorld(existsSync(before.configPath) ? before.configPath : before.config, { name, root, mode: before.mode, envFile: before.envFile });
|
|
82
|
+
// the origin belongs to the branch, not to its running state (git: a reset keeps the remote);
|
|
83
|
+
// the cursors do not — the mirror is gone, so the next fetch starts from the beginning
|
|
84
|
+
const instance = before.origin === undefined ? booted : { ...booted, origin: { url: before.origin.url, namespace: before.origin.namespace, ...(before.origin.clonedAt ? { clonedAt: before.origin.clonedAt } : {}) } };
|
|
85
|
+
if (before.origin !== undefined)
|
|
86
|
+
saveWorldInstance(instance);
|
|
87
|
+
// the roots reach the twins' state after the default data, so the seed runs simulated (as a fresh `up` does)
|
|
88
|
+
const rooted = () => materializeRoots(loadWorldConfig(existsSync(before.configPath) ? before.configPath : before.config, root).config, booted.dirs.data, root);
|
|
89
|
+
if (entry === null) {
|
|
90
|
+
rooted();
|
|
91
|
+
return { world: name, entry: '', exitCode: 0, observed: {}, instance };
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const seeded = await seedWorld(name, { root, entry, ...(options.cwd ? { cwd: options.cwd } : {}) });
|
|
95
|
+
return { ...seeded, instance };
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
rooted();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type PrerequisiteId = 'local-execution';
|
|
2
|
+
export type PrerequisiteCheck = {
|
|
3
|
+
id: PrerequisiteId;
|
|
4
|
+
ok: boolean;
|
|
5
|
+
label: string;
|
|
6
|
+
message: string;
|
|
7
|
+
guidance: string[];
|
|
8
|
+
details?: Record<string, string>;
|
|
9
|
+
};
|
|
10
|
+
type RunResult = {
|
|
11
|
+
status: number | null;
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
};
|
|
15
|
+
type Runner = (cmd: string, args: string[]) => RunResult;
|
|
16
|
+
/** Private implementation probe behind the public World capability name. Its implementation and
|
|
17
|
+
* diagnostics never become instructions for application agents. */
|
|
18
|
+
export declare function checkLocalExecution(runner?: Runner): PrerequisiteCheck;
|
|
19
|
+
export declare function checkPrerequisites(ids: PrerequisiteId[], runner?: Runner): PrerequisiteCheck[];
|
|
20
|
+
export declare function formatPrerequisiteChecks(checks: PrerequisiteCheck[]): string;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
const realRunner = (cmd, args) => {
|
|
3
|
+
const result = spawnSync(cmd, args, { encoding: 'utf8' });
|
|
4
|
+
return {
|
|
5
|
+
status: result.status,
|
|
6
|
+
stdout: result.stdout || '',
|
|
7
|
+
stderr: result.stderr || '',
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
/** Private implementation probe behind the public World capability name. Its implementation and
|
|
11
|
+
* diagnostics never become instructions for application agents. */
|
|
12
|
+
export function checkLocalExecution(runner = realRunner) {
|
|
13
|
+
const info = runner('docker', ['info']);
|
|
14
|
+
if (info.status === 0) {
|
|
15
|
+
return {
|
|
16
|
+
id: 'local-execution',
|
|
17
|
+
ok: true,
|
|
18
|
+
label: 'Local execution',
|
|
19
|
+
message: 'Local execution capacity is available.',
|
|
20
|
+
guidance: [],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
id: 'local-execution',
|
|
25
|
+
ok: false,
|
|
26
|
+
label: 'Local execution',
|
|
27
|
+
message: 'Local execution capacity is unavailable.',
|
|
28
|
+
guidance: [
|
|
29
|
+
'Run the World through `volter-world up`, then use `volter-world doctor <world>` and its declared service log if startup still fails.',
|
|
30
|
+
'Do not start, inspect, or clean up a backing runtime separately; the declared World service owns it.',
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function checkPrerequisites(ids, runner = realRunner) {
|
|
35
|
+
return ids.map((id) => {
|
|
36
|
+
if (id === 'local-execution')
|
|
37
|
+
return checkLocalExecution(runner);
|
|
38
|
+
throw new Error(`Unknown prerequisite: ${id}`);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export function formatPrerequisiteChecks(checks) {
|
|
42
|
+
return checks.flatMap((check) => {
|
|
43
|
+
const status = check.ok ? 'ok' : 'missing';
|
|
44
|
+
const lines = [`${status} ${check.label}: ${check.message}`];
|
|
45
|
+
if (!check.ok)
|
|
46
|
+
lines.push(...check.guidance.map((line) => ` ${line}`));
|
|
47
|
+
return lines;
|
|
48
|
+
}).join('\n');
|
|
49
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Inspect only groups already owned by the lifecycle. A dead leader does not prove that its
|
|
2
|
+
* descendants exited. Zombies hold no resources; unknown inspection remains live. This is
|
|
3
|
+
* retirement evidence, never an ownership token or a way to discover processes to kill. */
|
|
4
|
+
export declare function survivingOwnedGroups(pids: number[]): number[];
|