@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,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,89 @@
|
|
|
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.ts';
|
|
9
|
+
import { loadWorldConfig } from './configs.ts';
|
|
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.ts';
|
|
14
|
+
import type { WorldInstance } from './schema.ts';
|
|
15
|
+
|
|
16
|
+
export type SeedWorldOptions = { root?: string; entry?: string; cwd?: string };
|
|
17
|
+
export type SeedWorldOutcome = { world: string; entry: string; exitCode: number; observed: Record<string, number> };
|
|
18
|
+
|
|
19
|
+
/** The seed entry `init` emits beside the world config, unless the caller names one. */
|
|
20
|
+
export function seedEntryFor(instance: WorldInstance, entry?: string): string {
|
|
21
|
+
const path = resolve(entry ?? join(dirname(instance.configPath), 'seed.ts'));
|
|
22
|
+
if (!existsSync(path)) 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`);
|
|
23
|
+
return path;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Placeholder rows under one world service's root, summed across every state service the twin
|
|
27
|
+
* records under (slack records under `chat`): every state dir with an events log, whether or not
|
|
28
|
+
* an actions log exists yet (a placeholder-only twin has none). */
|
|
29
|
+
function placeholderCount(controlRoot: string): number {
|
|
30
|
+
const stateRoot = worldStateRoot(controlRoot);
|
|
31
|
+
if (!existsSync(stateRoot)) return 0;
|
|
32
|
+
let n = 0;
|
|
33
|
+
for (const entry of readdirSync(stateRoot, { withFileTypes: true })) {
|
|
34
|
+
if (!entry.isDirectory() || !existsSync(join(stateRoot, entry.name, 'events.jsonl'))) continue;
|
|
35
|
+
try { n += listEvents(entry.name, controlRoot).filter((e) => (e.data as { remote?: unknown } | undefined)?.remote === PLACEHOLDER_REMOTE).length; } catch { /* unreadable ledger: not counted */ }
|
|
36
|
+
}
|
|
37
|
+
return n;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Pull from the placeholder: every twin's window open around the seed entry. */
|
|
41
|
+
export async function seedWorld(name: string, options: SeedWorldOptions = {}): Promise<SeedWorldOutcome> {
|
|
42
|
+
const root = resolve(options.root ?? process.cwd());
|
|
43
|
+
const instance = statusWorld(name, root);
|
|
44
|
+
const entry = seedEntryFor(instance, options.entry);
|
|
45
|
+
const services = Object.keys(instance.services).sort();
|
|
46
|
+
const at = new Date().toISOString();
|
|
47
|
+
for (const service of services) 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) as [number, number];
|
|
55
|
+
if (major < 22 || (major === 22 && minor < 6)) 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}`);
|
|
56
|
+
}
|
|
57
|
+
const runner = typeof Bun !== 'undefined' ? ['bun', entry] : [process.execPath, '--experimental-strip-types', '--no-warnings', entry];
|
|
58
|
+
exitCode = await runWithWorldEnv(name, runner, root, { cwd: options.cwd ?? dirname(entry) });
|
|
59
|
+
} finally {
|
|
60
|
+
for (const service of services) endPlaceholderPull(service, join(instance.dirs.data, service));
|
|
61
|
+
}
|
|
62
|
+
const observed: Record<string, number> = {};
|
|
63
|
+
for (const service of services) observed[service] = placeholderCount(join(instance.dirs.data, service));
|
|
64
|
+
return { world: name, entry, exitCode, observed };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Back to the defaults: down with purge, up from the same config and env file, seed. */
|
|
68
|
+
export async function resetWorld(name: string, options: SeedWorldOptions = {}): Promise<SeedWorldOutcome & { instance: WorldInstance }> {
|
|
69
|
+
const root = resolve(options.root ?? process.cwd());
|
|
70
|
+
const before = statusWorld(name, root);
|
|
71
|
+
// A world whose twins ship no default data has no seed entry: back to the defaults is back to
|
|
72
|
+
// empty, and that is a reset too — not an error.
|
|
73
|
+
const entry = existsSync(resolve(options.entry ?? join(dirname(before.configPath), 'seed.ts'))) ? seedEntryFor(before, options.entry) : null;
|
|
74
|
+
const stopped = await downWorld(name, root, { purge: true });
|
|
75
|
+
if (!stopped.purged || stopped.externalErrors.length) throw new Error(`World reset refused: teardown did not purge the old instance (${stopped.externalErrors.join('; ')})`);
|
|
76
|
+
// a world booted from a config PATH re-ups from that path; one booted by id, by id
|
|
77
|
+
const booted = await upWorld(existsSync(before.configPath) ? before.configPath : before.config, { name, root, mode: before.mode, envFile: before.envFile });
|
|
78
|
+
// the origin belongs to the branch, not to its running state (git: a reset keeps the remote);
|
|
79
|
+
// the cursors do not — the mirror is gone, so the next fetch starts from the beginning
|
|
80
|
+
const instance = before.origin === undefined ? booted : { ...booted, origin: { url: before.origin.url, namespace: before.origin.namespace, ...(before.origin.clonedAt ? { clonedAt: before.origin.clonedAt } : {}) } };
|
|
81
|
+
if (before.origin !== undefined) saveWorldInstance(instance);
|
|
82
|
+
// the roots reach the twins' state after the default data, so the seed runs simulated (as a fresh `up` does)
|
|
83
|
+
const rooted = () => materializeRoots(loadWorldConfig(existsSync(before.configPath) ? before.configPath : before.config, root).config, booted.dirs.data, root);
|
|
84
|
+
if (entry === null) { rooted(); return { world: name, entry: '', exitCode: 0, observed: {}, instance }; }
|
|
85
|
+
try {
|
|
86
|
+
const seeded = await seedWorld(name, { root, entry, ...(options.cwd ? { cwd: options.cwd } : {}) });
|
|
87
|
+
return { ...seeded, instance };
|
|
88
|
+
} finally { rooted(); }
|
|
89
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export type PrerequisiteId = 'local-execution';
|
|
4
|
+
|
|
5
|
+
export type PrerequisiteCheck = {
|
|
6
|
+
id: PrerequisiteId;
|
|
7
|
+
ok: boolean;
|
|
8
|
+
label: string;
|
|
9
|
+
message: string;
|
|
10
|
+
guidance: string[];
|
|
11
|
+
details?: Record<string, string>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type RunResult = { status: number | null; stdout: string; stderr: string };
|
|
15
|
+
type Runner = (cmd: string, args: string[]) => RunResult;
|
|
16
|
+
|
|
17
|
+
const realRunner: Runner = (cmd, args) => {
|
|
18
|
+
const result = spawnSync(cmd, args, { encoding: 'utf8' });
|
|
19
|
+
return {
|
|
20
|
+
status: result.status,
|
|
21
|
+
stdout: result.stdout || '',
|
|
22
|
+
stderr: result.stderr || '',
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Private implementation probe behind the public World capability name. Its implementation and
|
|
27
|
+
* diagnostics never become instructions for application agents. */
|
|
28
|
+
export function checkLocalExecution(runner: Runner = realRunner): PrerequisiteCheck {
|
|
29
|
+
const info = runner('docker', ['info']);
|
|
30
|
+
if (info.status === 0) {
|
|
31
|
+
return {
|
|
32
|
+
id: 'local-execution',
|
|
33
|
+
ok: true,
|
|
34
|
+
label: 'Local execution',
|
|
35
|
+
message: 'Local execution capacity is available.',
|
|
36
|
+
guidance: [],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
id: 'local-execution',
|
|
42
|
+
ok: false,
|
|
43
|
+
label: 'Local execution',
|
|
44
|
+
message: 'Local execution capacity is unavailable.',
|
|
45
|
+
guidance: [
|
|
46
|
+
'Run the World through `volter-world up`, then use `volter-world doctor <world>` and its declared service log if startup still fails.',
|
|
47
|
+
'Do not start, inspect, or clean up a backing runtime separately; the declared World service owns it.',
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function checkPrerequisites(ids: PrerequisiteId[], runner: Runner = realRunner): PrerequisiteCheck[] {
|
|
53
|
+
return ids.map((id) => {
|
|
54
|
+
if (id === 'local-execution') return checkLocalExecution(runner);
|
|
55
|
+
throw new Error(`Unknown prerequisite: ${id}`);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function formatPrerequisiteChecks(checks: PrerequisiteCheck[]): string {
|
|
60
|
+
return checks.flatMap((check) => {
|
|
61
|
+
const status = check.ok ? 'ok' : 'missing';
|
|
62
|
+
const lines = [`${status} ${check.label}: ${check.message}`];
|
|
63
|
+
if (!check.ok) lines.push(...check.guidance.map((line) => ` ${line}`));
|
|
64
|
+
return lines;
|
|
65
|
+
}).join('\n');
|
|
66
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/** Inspect only groups already owned by the lifecycle. A dead leader does not prove that its
|
|
4
|
+
* descendants exited. Zombies hold no resources; unknown inspection remains live. This is
|
|
5
|
+
* retirement evidence, never an ownership token or a way to discover processes to kill. */
|
|
6
|
+
export function survivingOwnedGroups(pids: number[]): number[] {
|
|
7
|
+
const candidates = pids.filter(pid => {
|
|
8
|
+
try { process.kill(process.platform === 'win32' ? pid : -pid, 0); return true; }
|
|
9
|
+
catch (error) {
|
|
10
|
+
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') return true;
|
|
11
|
+
try { process.kill(pid, 0); return true; }
|
|
12
|
+
catch (cause) { return (cause as NodeJS.ErrnoException).code !== 'ESRCH'; }
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
if (!candidates.length || process.platform === 'win32') return candidates;
|
|
16
|
+
const result = spawnSync('ps', ['-axo', 'pid=,pgid=,stat='], {
|
|
17
|
+
encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', maxBuffer: 4 * 1024 * 1024,
|
|
18
|
+
});
|
|
19
|
+
if (result.status !== 0 || result.error) return candidates;
|
|
20
|
+
const rows = result.stdout.trim().split('\n').map(line => line.trim().split(/\s+/));
|
|
21
|
+
return candidates.filter(pid => {
|
|
22
|
+
const members = rows.filter(([id, group]) => Number(group) === pid || Number(id) === pid);
|
|
23
|
+
// Visibility restrictions can omit a live group. Only ESRCH proves absence.
|
|
24
|
+
if (!members.length) {
|
|
25
|
+
for (const target of [-pid, pid]) {
|
|
26
|
+
try { process.kill(target, 0); return true; }
|
|
27
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') return true; }
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return members.some(([, , state]) => !state || !state.startsWith('Z'));
|
|
32
|
+
});
|
|
33
|
+
}
|