@volter/world-runtime 3.0.143 → 3.0.145
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cli-commands.js +13 -2
- package/dist/src/covers.d.ts +21 -1
- package/dist/src/covers.js +46 -4
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +1 -1
- package/dist/src/infra.js +42 -8
- package/dist/src/init.d.ts +4 -0
- package/dist/src/init.js +20 -2
- package/dist/src/native-postgres-backing.d.ts +15 -1
- package/dist/src/native-postgres-backing.js +178 -7
- package/dist/src/package-manifest.d.ts +7 -0
- package/dist/src/package-manifest.js +29 -0
- package/dist/src/pglite-backing.d.ts +85 -0
- package/dist/src/pglite-backing.js +249 -33
- package/dist/src/process-groups.mjs +25 -4
- package/dist/src/process-signal.mjs +63 -14
- package/dist/src/project-inspect.d.ts +9 -0
- package/dist/src/project-inspect.js +31 -8
- package/dist/src/runtime.js +23 -2
- package/dist/src/schema.d.ts +68 -0
- package/dist/src/schema.js +70 -4
- package/dist/src/up-cli.js +21 -1
- package/package.json +4 -4
- package/src/cli-commands.ts +10 -2
- package/src/covers.ts +53 -5
- package/src/index.ts +4 -0
- package/src/infra.ts +40 -8
- package/src/init.ts +22 -1
- package/src/native-postgres-backing.ts +175 -8
- package/src/package-manifest.ts +31 -0
- package/src/pglite-backing.ts +227 -22
- package/src/process-groups.mjs +25 -4
- package/src/process-signal.mjs +63 -14
- package/src/project-inspect.ts +38 -1
- package/src/runtime.ts +15 -1
- package/src/schema.ts +99 -5
- package/src/up-cli.ts +16 -1
|
@@ -12,15 +12,18 @@
|
|
|
12
12
|
// services only. A definition declaring mysql without a container runtime is
|
|
13
13
|
// refused with the kinds named, never half-booted. Redis never reaches it: every
|
|
14
14
|
// redis service is the redis twin's (redis-backing.ts).
|
|
15
|
-
import { appendFileSync, closeSync, cpSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
|
-
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { appendFileSync, closeSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, renameSync, rmdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
17
|
+
import { assertWorldConfig } from "./schema.js";
|
|
17
18
|
import { connect } from 'node:net';
|
|
18
19
|
import { dirname, join } from 'node:path';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
19
21
|
import { spawn } from 'node:child_process';
|
|
20
22
|
import { commandLifetime, COMMAND_GRACE_MS, COMMAND_CONFIRM_MS } from "./command-lifetime.js";
|
|
21
23
|
import { survivingOwnedGroups } from "./process-groups.js";
|
|
22
24
|
import { publishProcessReceipt, readProcessReceipt, readSpawnReceipt, spawnReceiptPath, verifySpawnIdentity, signalOwnedProcess } from "./process-receipt.js";
|
|
23
25
|
import { errorMessage } from "./error-message.js";
|
|
26
|
+
import { runtimeManifest } from "./package-manifest.js";
|
|
24
27
|
import { fileURLToPath } from 'node:url';
|
|
25
28
|
import { parse } from 'yaml';
|
|
26
29
|
import { mongodbArgv, mongodbReady, mongodbServes } from "./mongodb-backing.js";
|
|
@@ -212,34 +215,142 @@ export function preparedCluster(worldConfig) {
|
|
|
212
215
|
const directory = join(dirname(worldConfig), PREPARED_DIRECTORY, containerlessDataDirectory('postgres'));
|
|
213
216
|
return { directory, stamp: `${directory}.json` };
|
|
214
217
|
}
|
|
215
|
-
|
|
216
|
-
* What a prepared cluster must have been made with: this runtime (whose pglite-host.mjs is the open path that made
|
|
217
|
-
* it), its exact PGlite pin (initdb's encoding and locale are PGlite's defaults: the host passes no initdb
|
|
218
|
-
* parameters), and the declared user and database. The password is not in the cluster: the role is created without
|
|
219
|
-
* one (LOGIN SUPERUSER) and the host's gateway checks the declared password on each connection, so a World with
|
|
220
|
-
* another password takes the same cluster and authenticates against its own. Extensions are loaded into the module
|
|
221
|
-
* at each open, never created in the cluster by the open path.
|
|
222
|
-
*/
|
|
223
|
-
/** This package's own manifest, found by its name walking up from this module: the package root from src/ in the
|
|
224
|
-
* repository and from dist/src/ in the published package alike, where a fixed `..` reached dist/package.json. */
|
|
225
|
-
function runtimeManifest() {
|
|
226
|
-
for (let dir = here();; dir = dirname(dir)) {
|
|
227
|
-
const candidate = join(dir, 'package.json');
|
|
228
|
-
if (existsSync(candidate)) {
|
|
229
|
-
const manifest = JSON.parse(readFileSync(candidate, 'utf8'));
|
|
230
|
-
if (manifest.name === '@volter/world-runtime')
|
|
231
|
-
return manifest;
|
|
232
|
-
}
|
|
233
|
-
if (dirname(dir) === dir)
|
|
234
|
-
throw new Error(`no @volter/world-runtime package.json above ${here()}`);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
function preparedStamp(service) {
|
|
218
|
+
function preparedStamp(service, migration) {
|
|
238
219
|
const manifest = runtimeManifest();
|
|
239
220
|
const pglite = manifest.dependencies?.['@electric-sql/pglite'];
|
|
240
221
|
if (!pglite || !/^\d+\.\d+\.\d+$/.test(pglite))
|
|
241
222
|
throw new Error('world-runtime pins no exact @electric-sql/pglite version');
|
|
242
|
-
return `${JSON.stringify({ runtime: manifest.version ?? 'unversioned', pglite, user: service.postgres.user, database: service.postgres.database
|
|
223
|
+
return `${JSON.stringify({ runtime: manifest.version ?? 'unversioned', pglite, user: service.postgres.user, database: service.postgres.database,
|
|
224
|
+
...(migration ? { migration } : {}) })}\n`;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* The SHA-256 of each of a migration step's inputs, under its cwd in the app's tree: a file's bytes; a directory's
|
|
228
|
+
* sorted relative paths, each with its type, its mode and its bytes (a link, its target). What the image build records
|
|
229
|
+
* for an image, and what `up` hashes only for a World run outside one.
|
|
230
|
+
*/
|
|
231
|
+
export function migrationInputDigests(cwd, inputs) {
|
|
232
|
+
const digests = {};
|
|
233
|
+
for (const input of inputs) {
|
|
234
|
+
const hash = createHash('sha256');
|
|
235
|
+
const walk = (absolute, relative) => {
|
|
236
|
+
const stat = lstatSync(absolute);
|
|
237
|
+
if (stat.isSymbolicLink()) {
|
|
238
|
+
hash.update(`l ${relative} ${readlinkSync(absolute)}\n`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (stat.isDirectory()) {
|
|
242
|
+
hash.update(`d ${relative} ${(stat.mode & 0o7777).toString(8)}\n`);
|
|
243
|
+
for (const name of readdirSync(absolute).sort())
|
|
244
|
+
walk(join(absolute, name), relative === '' ? name : `${relative}/${name}`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
hash.update(`f ${relative} ${(stat.mode & 0o7777).toString(8)} ${stat.size}\n`);
|
|
248
|
+
hash.update(readFileSync(absolute));
|
|
249
|
+
};
|
|
250
|
+
walk(join(cwd, input), '');
|
|
251
|
+
digests[input] = `sha256:${hash.digest('hex')}`;
|
|
252
|
+
}
|
|
253
|
+
return digests;
|
|
254
|
+
}
|
|
255
|
+
/** The World's declared migration step for its prepared database, if any (runtime.database.prepare). */
|
|
256
|
+
export function databasePrepareOf(worldConfig) {
|
|
257
|
+
return assertWorldConfig(JSON.parse(readFileSync(worldConfig, 'utf8')), worldConfig).databasePrepare;
|
|
258
|
+
}
|
|
259
|
+
/** `NAME=value` lines (an image's environment, VOLTER_WORLD_APP_ENV), as an environment. */
|
|
260
|
+
function environmentFile(path) {
|
|
261
|
+
const values = {};
|
|
262
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
263
|
+
const at = line.indexOf('=');
|
|
264
|
+
if (at > 0)
|
|
265
|
+
values[line.slice(0, at)] = line.slice(at + 1);
|
|
266
|
+
}
|
|
267
|
+
return values;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The World's declared step (runtime.database.prepare) run once against a prepared cluster its backing is serving on
|
|
271
|
+
* the World's port: the declared command in the app's tree, with the image's environment and the World's values over
|
|
272
|
+
* it. The build host's own PATH finds the command's tools (the image's PATH names its own paths). Its failure throws,
|
|
273
|
+
* with its output; its answer is what the cluster's stamp records.
|
|
274
|
+
*/
|
|
275
|
+
/**
|
|
276
|
+
* A FAILED PREPARATION KEEPS ITS WHOLE EVIDENCE. An error carries a tail, and the preparation's own directory is
|
|
277
|
+
* its caller's to remove (an image build prepares in a staging directory it retires when the preparation fails), so
|
|
278
|
+
* the tail was all that survived: a postmaster's first lines, its shared-memory setup among them, were gone with the
|
|
279
|
+
* directory (Cal runs 23 and 24). The whole text or file is written under the preparation's temporary directory
|
|
280
|
+
* (TMPDIR, which a failed build retains; the system's otherwise), outside the World's directory, and the sentence
|
|
281
|
+
* naming it is added to the error. Never on success, and a copy that fails says so instead of hiding the failure.
|
|
282
|
+
*/
|
|
283
|
+
export function keepFailureEvidence(name, evidence, env) {
|
|
284
|
+
try {
|
|
285
|
+
const directory = env.TMPDIR ?? tmpdir();
|
|
286
|
+
mkdirSync(directory, { recursive: true });
|
|
287
|
+
const path = join(directory, `volter-world-prepare-${name}-${Date.now()}-${process.pid}.log`);
|
|
288
|
+
if (typeof evidence === 'string')
|
|
289
|
+
writeFileSync(path, evidence);
|
|
290
|
+
else
|
|
291
|
+
copyFileSync(evidence.file, path);
|
|
292
|
+
return `\nThe whole ${name} is kept at ${path}`;
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
return `\n(the whole ${name} could not be kept: ${errorMessage(error)})`;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
export async function runDatabasePrepare(step, worldConfig, launch) {
|
|
299
|
+
const env = launch.env ?? process.env;
|
|
300
|
+
const appRoot = env.VOLTER_WORLD_APP_ROOT;
|
|
301
|
+
if (!appRoot)
|
|
302
|
+
throw new Error('the World declares runtime.database.prepare: VOLTER_WORLD_APP_ROOT must name the app image\'s root');
|
|
303
|
+
const cwd = join(appRoot, step.cwd);
|
|
304
|
+
const imageEnv = env.VOLTER_WORLD_APP_ENV ? environmentFile(env.VOLTER_WORLD_APP_ENV) : {};
|
|
305
|
+
const { PATH: _imagePath, ...imageValues } = imageEnv;
|
|
306
|
+
const worldValues = assertWorldConfig(JSON.parse(readFileSync(worldConfig, 'utf8')), worldConfig).env ?? {};
|
|
307
|
+
const [program, ...words] = step.command;
|
|
308
|
+
const migration = spawn(program, words, { windowsHide: true, cwd, env: { ...env, ...imageValues, ...worldValues }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
309
|
+
let output = '';
|
|
310
|
+
migration.stdout.on('data', (chunk) => { output += chunk.toString('utf8'); });
|
|
311
|
+
migration.stderr.on('data', (chunk) => { output += chunk.toString('utf8'); });
|
|
312
|
+
const { code } = await commandLifetime(migration, launch.signal).finish();
|
|
313
|
+
if (code !== 0)
|
|
314
|
+
throw new Error(`the World's database prepare step (${step.command.join(' ')}) exited ${code ?? 'null'}\n${output.trim().split('\n').slice(-40).join('\n')}${keepFailureEvidence('database-prepare-step-output', output, env)}`);
|
|
315
|
+
launch.note?.(`database prepare step: ${step.command.join(' ')}\n${output.trim()}`);
|
|
316
|
+
return { command: [...step.command], inputs: migrationInputDigests(cwd, step.inputs) };
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* The app's own migration against a prepared PGlite cluster: the cluster served by the same host at the World's port
|
|
320
|
+
* for the step (runDatabasePrepare), then the host closed cleanly.
|
|
321
|
+
*/
|
|
322
|
+
async function migratePreparedCluster(service, directory, step, worldConfig, launch) {
|
|
323
|
+
const env = launch.env ?? process.env;
|
|
324
|
+
const pidFile = join(dirname(directory), 'pglite-prepare.pid');
|
|
325
|
+
const boot = randomUUID();
|
|
326
|
+
rmSync(listeningReceiptPath(pidFile), { force: true });
|
|
327
|
+
const [command, ...args] = [...hostArgv(service, directory), '--prepared', '--listening-file', listeningReceiptPath(pidFile), '--boot', boot];
|
|
328
|
+
let hostOutput = '';
|
|
329
|
+
const host = spawn(command, args, { windowsHide: true,
|
|
330
|
+
env: { ...env, VOLTER_WORLD_PGLITE_USER: service.postgres.user, VOLTER_WORLD_PGLITE_DATABASE: service.postgres.database, VOLTER_WORLD_PGLITE_PASSWORD: service.postgres.password },
|
|
331
|
+
stdio: ['ignore', 'pipe', 'pipe'] });
|
|
332
|
+
host.stdout.on('data', (chunk) => { hostOutput += chunk.toString('utf8'); });
|
|
333
|
+
host.stderr.on('data', (chunk) => { hostOutput += chunk.toString('utf8'); });
|
|
334
|
+
const hostLifetime = commandLifetime(host, launch.signal);
|
|
335
|
+
try {
|
|
336
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
337
|
+
while (!(host.pid && alive(host.pid) && await hostServes(service, pidFile, host.pid, boot))) {
|
|
338
|
+
if (!host.pid || !alive(host.pid))
|
|
339
|
+
throw new Error(`the prepared cluster's host exited before serving\n${hostOutput.trim().split('\n').slice(-20).join('\n')}`);
|
|
340
|
+
if (Date.now() > deadline)
|
|
341
|
+
throw new Error(`the prepared cluster's host did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s`);
|
|
342
|
+
launch.signal?.throwIfAborted();
|
|
343
|
+
await sleep(25);
|
|
344
|
+
}
|
|
345
|
+
return await runDatabasePrepare(step, worldConfig, launch);
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
// A clean close: PGlite writes its cluster back before the host exits.
|
|
349
|
+
if (host.pid && alive(host.pid))
|
|
350
|
+
host.kill('SIGTERM');
|
|
351
|
+
await hostLifetime.finish();
|
|
352
|
+
rmSync(listeningReceiptPath(pidFile), { force: true });
|
|
353
|
+
}
|
|
243
354
|
}
|
|
244
355
|
/** Run the host's open path once into the World's prepared directory; replaces an earlier preparation. */
|
|
245
356
|
export async function pglitePrepare(service, worldConfig, launch = {}) {
|
|
@@ -262,20 +373,123 @@ export async function pglitePrepare(service, worldConfig, launch = {}) {
|
|
|
262
373
|
const { code } = await lifetime.finish();
|
|
263
374
|
if (code !== 0 || !existsSync(join(directory, 'PG_VERSION'))) {
|
|
264
375
|
rmSync(directory, { recursive: true, force: true });
|
|
265
|
-
throw new Error(`pglite prepare exited ${code ?? 'null'} without a cluster\n${output.trim().split('\n').slice(-40).join('\n')}`);
|
|
376
|
+
throw new Error(`pglite prepare exited ${code ?? 'null'} without a cluster\n${output.trim().split('\n').slice(-40).join('\n')}${keepFailureEvidence('pglite-prepare-output', output, launch.env ?? process.env)}`);
|
|
266
377
|
}
|
|
267
|
-
|
|
378
|
+
// The app's own migration, where the World declares one: a cluster it did not complete is never stamped.
|
|
379
|
+
const step = databasePrepareOf(worldConfig);
|
|
380
|
+
let migration;
|
|
381
|
+
if (step) {
|
|
382
|
+
try {
|
|
383
|
+
migration = await migratePreparedCluster(service, directory, step, worldConfig, launch);
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
rmSync(directory, { recursive: true, force: true });
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
writeFileSync(stamp, preparedStamp(service, migration));
|
|
268
391
|
return directory;
|
|
269
392
|
}
|
|
270
393
|
/** Give an empty service data directory the World's prepared cluster when its stamp matches; says what it did. */
|
|
271
|
-
function placePreparedCluster(service, serviceData, worldConfig) {
|
|
394
|
+
function placePreparedCluster(service, serviceData, worldConfig, env) {
|
|
395
|
+
if (service.kind === 'postgres')
|
|
396
|
+
forgetStaleMigration(serviceData);
|
|
272
397
|
if (service.kind !== 'postgres' || !worldConfig || existsSync(join(serviceData, 'PG_VERSION')))
|
|
273
398
|
return { placed: false };
|
|
274
|
-
const
|
|
399
|
+
const prepared = preparedCluster(worldConfig);
|
|
400
|
+
if (!existsSync(join(prepared.directory, 'PG_VERSION')) || !existsSync(prepared.stamp))
|
|
401
|
+
return { placed: false };
|
|
402
|
+
const wanted = expectedMigration(worldConfig, env, prepared.directory);
|
|
403
|
+
if ('note' in wanted)
|
|
404
|
+
return { placed: false, note: wanted.note };
|
|
405
|
+
return takePreparedCluster(prepared, preparedStamp(service, wanted.migration), serviceData, 'another PGlite pin, identity or migration', wanted.migration);
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* The migration a prepared cluster must have been made with for `up` to take it, on either backing: none when the
|
|
409
|
+
* World declares no step; else the step's command and its inputs' digests, as the app's image recorded them
|
|
410
|
+
* (VOLTER_WORLD_APP_DIGESTS, no read of the tree) or, for a World run outside an image, as its tree has them. With
|
|
411
|
+
* neither given a migrated cluster is not taken, and the note says so.
|
|
412
|
+
*/
|
|
413
|
+
export function expectedMigration(worldConfig, env, directory) {
|
|
414
|
+
const step = databasePrepareOf(worldConfig);
|
|
415
|
+
if (!step)
|
|
416
|
+
return {};
|
|
417
|
+
let inputs;
|
|
418
|
+
if (env.VOLTER_WORLD_APP_DIGESTS) {
|
|
419
|
+
// The image wrote this; a value that is not a JSON object of digests is said, and the cluster is not taken.
|
|
420
|
+
let recorded;
|
|
421
|
+
try {
|
|
422
|
+
recorded = JSON.parse(env.VOLTER_WORLD_APP_DIGESTS);
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
recorded = undefined;
|
|
426
|
+
}
|
|
427
|
+
if (!recorded || typeof recorded !== 'object' || Array.isArray(recorded) || Object.values(recorded).some((value) => typeof value !== 'string')) {
|
|
428
|
+
return { note: `prepared cluster ${directory} not taken: it is migrated, and VOLTER_WORLD_APP_DIGESTS is not a JSON object of its inputs' digests` };
|
|
429
|
+
}
|
|
430
|
+
inputs = recorded;
|
|
431
|
+
}
|
|
432
|
+
else if (env.VOLTER_WORLD_APP_ROOT)
|
|
433
|
+
inputs = migrationInputDigests(join(env.VOLTER_WORLD_APP_ROOT, step.cwd), step.inputs);
|
|
434
|
+
if (!inputs)
|
|
435
|
+
return { note: `prepared cluster ${directory} not taken: it is migrated, and neither the app's recorded digests nor its tree were given to compare (VOLTER_WORLD_APP_DIGESTS, VOLTER_WORLD_APP_ROOT)` };
|
|
436
|
+
return { migration: { command: [...step.command], inputs: Object.fromEntries(step.inputs.map((input) => [input, inputs[input] ?? ''])) } };
|
|
437
|
+
}
|
|
438
|
+
/** Beside a service's data: the migration its live cluster carries, written when a migrated cluster is placed. */
|
|
439
|
+
const migrationRecord = (serviceData) => `${serviceData}.migration.json`;
|
|
440
|
+
/** A data directory with no cluster carries no migration: a record left by data since removed is dropped. */
|
|
441
|
+
export function forgetStaleMigration(serviceData) {
|
|
442
|
+
if (!existsSync(join(serviceData, 'PG_VERSION')))
|
|
443
|
+
rmSync(migrationRecord(serviceData), { force: true });
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* The declared step's command when the LIVE database carries the migration the app's image expects, else undefined
|
|
447
|
+
* (architecture, managed infrastructure). A fact of the data, not of this boot's placement: kept data is still
|
|
448
|
+
* migrated on a later boot, and kept data under an image whose step inputs have changed is older than the image.
|
|
449
|
+
*/
|
|
450
|
+
export function liveDatabaseMigration(worldConfig, env, serviceData) {
|
|
451
|
+
if (!existsSync(join(serviceData, 'PG_VERSION')) || !existsSync(migrationRecord(serviceData)))
|
|
452
|
+
return undefined;
|
|
453
|
+
const wanted = expectedMigration(worldConfig, env, serviceData);
|
|
454
|
+
if ('note' in wanted || !wanted.migration)
|
|
455
|
+
return undefined;
|
|
456
|
+
return readFileSync(migrationRecord(serviceData), 'utf8') === `${JSON.stringify(wanted.migration)}\n` ? [...wanted.migration.command] : undefined;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* The declared step's command when the World declares one and the live database does NOT carry the migration the
|
|
460
|
+
* image expects: data kept from an earlier image, or a cluster that was initialized here because the prepared one
|
|
461
|
+
* was not taken. Undefined with no step, no cluster yet, or a migrated one.
|
|
462
|
+
*/
|
|
463
|
+
export function liveDatabaseBehind(worldConfig, env, serviceData) {
|
|
464
|
+
const step = databasePrepareOf(worldConfig);
|
|
465
|
+
if (!step || !existsSync(join(serviceData, 'PG_VERSION')) || liveDatabaseMigration(worldConfig, env, serviceData))
|
|
466
|
+
return undefined;
|
|
467
|
+
return [...step.command];
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Whether this is a development World whose app may migrate its database when it starts
|
|
471
|
+
* (runtime.development.migrateDatabaseAtRuntime; schema.ts WorldDevelopment says whose the setting is).
|
|
472
|
+
*/
|
|
473
|
+
export function developmentMigratesAtRuntime(worldConfig) {
|
|
474
|
+
return assertWorldConfig(JSON.parse(readFileSync(worldConfig, 'utf8')), worldConfig).development?.migrateDatabaseAtRuntime === true;
|
|
475
|
+
}
|
|
476
|
+
/** What a development World says each time it is brought up or built into an image, so its use is never silent. */
|
|
477
|
+
export const DEVELOPMENT_MIGRATION_NOTICE = 'development World: its database migrates at run time; it is not prepared';
|
|
478
|
+
/**
|
|
479
|
+
* The one placement of a prepared cluster, for every backing that prepares one (PGlite here, native PostgreSQL in
|
|
480
|
+
* native-postgres-backing.ts): an empty `serviceData` takes `prepared.directory` by rename when the stamp beside it
|
|
481
|
+
* is exactly `expected`; a mismatch is named, never taken. A migrated cluster's `migration` is recorded beside the data
|
|
482
|
+
* it becomes (liveDatabaseMigration).
|
|
483
|
+
*/
|
|
484
|
+
export function takePreparedCluster(prepared, expected, serviceData, mismatch, migration) {
|
|
485
|
+
const { directory, stamp } = prepared;
|
|
486
|
+
if (existsSync(join(serviceData, 'PG_VERSION')))
|
|
487
|
+
return { placed: false };
|
|
275
488
|
if (!existsSync(join(directory, 'PG_VERSION')) || !existsSync(stamp))
|
|
276
489
|
return { placed: false };
|
|
277
|
-
|
|
278
|
-
|
|
490
|
+
const text = readFileSync(stamp, 'utf8');
|
|
491
|
+
if (typeof expected === 'string' ? text !== expected : !expected(text))
|
|
492
|
+
return { placed: false, note: `prepared cluster ${directory} not taken: its stamp names ${mismatch}` };
|
|
279
493
|
if (existsSync(serviceData)) {
|
|
280
494
|
if (readdirSync(serviceData).length)
|
|
281
495
|
return { placed: false };
|
|
@@ -291,6 +505,8 @@ function placePreparedCluster(service, serviceData, worldConfig) {
|
|
|
291
505
|
throw error;
|
|
292
506
|
cpSync(directory, serviceData, { recursive: true });
|
|
293
507
|
}
|
|
508
|
+
if (migration)
|
|
509
|
+
writeFileSync(migrationRecord(serviceData), `${JSON.stringify(migration)}\n`);
|
|
294
510
|
return { placed: true, note: `prepared cluster ${directory} placed at ${serviceData}` };
|
|
295
511
|
}
|
|
296
512
|
export async function pgliteUp(services, dataDir, launch = {}) {
|
|
@@ -321,7 +537,7 @@ export async function pgliteUp(services, dataDir, launch = {}) {
|
|
|
321
537
|
const started = [];
|
|
322
538
|
try {
|
|
323
539
|
for (const { service, name, serviceData, log, pidFile, argv } of plan) {
|
|
324
|
-
const placed = placePreparedCluster(service, serviceData, env.VOLTER_WORLD_CONFIG);
|
|
540
|
+
const placed = placePreparedCluster(service, serviceData, env.VOLTER_WORLD_CONFIG, env);
|
|
325
541
|
mkdirSync(serviceData, { recursive: true });
|
|
326
542
|
mkdirSync(dirname(log), { recursive: true });
|
|
327
543
|
if (placed.note) {
|
|
@@ -14,10 +14,31 @@ export function survivingOwnedGroups(pids) {
|
|
|
14
14
|
}
|
|
15
15
|
});
|
|
16
16
|
if (!candidates.length || process.platform === 'win32') return candidates;
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
const options = { windowsHide: true, encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', maxBuffer: 4 * 1024 * 1024 };
|
|
18
|
+
const result = spawnSync('ps', ['-axo', 'pid=,pgid=,stat='], options);
|
|
19
|
+
// When the whole-table listing does not answer (a non-zero status or an error; which of the two it was in the
|
|
20
|
+
// run this was written for, Cal build 43, was not read, and no measurement says why), every candidate would read
|
|
21
|
+
// as live, a group of zombies among them. Where the system can list one group by its leader (Darwin's `ps -g`: "processes with the
|
|
22
|
+
// specified process group leaders"), each candidate is asked for by itself, which does not walk the table; a
|
|
23
|
+
// group that cannot be listed either stays live.
|
|
24
|
+
if (result.status !== 0 || result.error) {
|
|
25
|
+
if (process.platform !== 'darwin') return candidates;
|
|
26
|
+
return candidates.filter(pid => {
|
|
27
|
+
const own = spawnSync('ps', ['-o', 'pid=,pgid=,stat=', '-g', String(pid)], options);
|
|
28
|
+
// Status 1 with no row is `ps` saying the group has no process; any other failure says nothing.
|
|
29
|
+
if (own.error || own.signal || (own.status !== 0 && !(own.status === 1 && !own.stdout.trim()))) return true;
|
|
30
|
+
const members = own.stdout.trim() ? own.stdout.trim().split('\n').map(line => line.trim().split(/\s+/)) : [];
|
|
31
|
+
// Visibility restrictions can omit a live group. Only ESRCH proves absence.
|
|
32
|
+
if (!members.length) {
|
|
33
|
+
for (const target of [-pid, pid]) {
|
|
34
|
+
try { process.kill(target, 0); return true; }
|
|
35
|
+
catch (error) { if (error.code !== 'ESRCH') return true; }
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
return members.some(([, , state]) => !state || !state.startsWith('Z'));
|
|
40
|
+
});
|
|
41
|
+
}
|
|
21
42
|
const rows = result.stdout.trim().split('\n').map(line => line.trim().split(/\s+/));
|
|
22
43
|
return candidates.filter(pid => {
|
|
23
44
|
const members = rows.filter(([id, group]) => Number(group) === pid || Number(id) === pid);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { survivingOwnedGroups } from './process-groups.mjs';
|
|
4
5
|
|
|
5
6
|
// Retain the existing 1000 ms inspection ceiling, not a new timeout chosen from one sample.
|
|
6
7
|
// Measured 2026-10-03 on shared Darwin 25.4.0 ARM64, Bun 1.3.11 (process.version
|
|
@@ -33,7 +34,24 @@ function birthProbe(pid) {
|
|
|
33
34
|
},
|
|
34
35
|
};
|
|
35
36
|
}
|
|
36
|
-
/**
|
|
37
|
+
/** What a probe that could not answer says, once for each distinct outcome in this process: its command, error code,
|
|
38
|
+
* exit, signal, timeout and branch. It is said, never thrown (see processBirth). */
|
|
39
|
+
const saidProbeFailures = new Set();
|
|
40
|
+
function sayProbeFailure(probe, error, code, signal, timedOut, reason) {
|
|
41
|
+
const line = `Process birth probe ${probe.command} failed (${reason}): errorCode=${error?.code ?? 'none'}, exit=${code ?? 'null'}, signal=${signal ?? 'null'}, timedOut=${timedOut}`;
|
|
42
|
+
if (saidProbeFailures.has(line)) return;
|
|
43
|
+
saidProbeFailures.add(line);
|
|
44
|
+
try { process.stderr.write(`[birth] ${line}; the birth is unknown (first asked of pid ${probe.args.at(-1)})\n`); } catch { /* an instrument */ }
|
|
45
|
+
}
|
|
46
|
+
/** A pid's birth, or undefined: no process has that pid, or the probe could not answer. A probe that cannot run is
|
|
47
|
+
* a birth unknown, never a thrown lifecycle error: spawn throws synchronously where the process may not exec `ps` at
|
|
48
|
+
* all (setuid root on macOS, which a sandbox-exec profile refuses with EPERM: an image build's prepare exited 1 for
|
|
49
|
+
* it), and reports other failures as 'error', a timeout, another exit or output that is not a birth. Every command
|
|
50
|
+
* the runtime runs captures its child's identity through this, and `down` asks it of every recorded pid, so a
|
|
51
|
+
* rejection here would fail a command that succeeded and refuse a whole `down`. Ownership is decided elsewhere and
|
|
52
|
+
* never from an unknown: verifySpawnIdentity refuses to retire a live pid whose birth it cannot read, and each
|
|
53
|
+
* spawner that needs a birth refuses a child it captured none for. What the probe met is said once on stderr.
|
|
54
|
+
* No subprocess on Linux; elsewhere the lookup never blocks the lifecycle event loop. */
|
|
37
55
|
export async function processBirth(pid) {
|
|
38
56
|
if (!Number.isInteger(pid) || pid <= 0) return undefined; // a pid enters a probe's command only as an integer
|
|
39
57
|
if (process.platform === 'linux') {
|
|
@@ -45,29 +63,43 @@ export async function processBirth(pid) {
|
|
|
45
63
|
}
|
|
46
64
|
const probe = birthProbe(pid);
|
|
47
65
|
return new Promise(resolve => {
|
|
48
|
-
// A probe that cannot run is a birth unknown, never a lifecycle failure: spawn throws synchronously where the
|
|
49
|
-
// process may not exec `ps` at all (setuid root on macOS, which a sandbox-exec profile refuses: EPERM), and reports
|
|
50
|
-
// other failures as 'error' (below). Either way the identity is the pid without a start time.
|
|
51
66
|
let child;
|
|
52
67
|
try {
|
|
53
68
|
child = spawn(probe.command, probe.args, {
|
|
54
69
|
windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'],
|
|
55
70
|
env: { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' },
|
|
56
71
|
});
|
|
57
|
-
} catch {
|
|
72
|
+
} catch (error) {
|
|
73
|
+
sayProbeFailure(probe, error, null, null, false, 'spawn threw');
|
|
58
74
|
resolve(undefined);
|
|
59
75
|
return;
|
|
60
76
|
}
|
|
61
77
|
let output = '', settled = false;
|
|
62
|
-
const finish = value
|
|
78
|
+
const finish = (value, error, code, signal, timedOut, reason) => {
|
|
79
|
+
if (settled) return;
|
|
80
|
+
settled = true;
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
child.stdout.destroy();
|
|
83
|
+
if (value === undefined) sayProbeFailure(probe, error, code, signal, timedOut, reason);
|
|
84
|
+
resolve(value);
|
|
85
|
+
};
|
|
63
86
|
// This is our still-held inspector, not a PID from a receipt.
|
|
64
87
|
const timer = setTimeout(() => {
|
|
65
88
|
try { signalChild(child, 'SIGKILL'); } catch { /* failed inspection remains unavailable */ }
|
|
66
|
-
finish(undefined); //
|
|
89
|
+
finish(undefined, undefined, child.exitCode, child.signalCode, true, 'timeout'); // a descendant may keep stdout open
|
|
67
90
|
}, BIRTH_LOOKUP_MS);
|
|
68
91
|
child.stdout.on('data', chunk => { output += chunk; });
|
|
69
|
-
child.once('error',
|
|
70
|
-
child.once('close',
|
|
92
|
+
child.once('error', error => finish(undefined, error, child.exitCode, child.signalCode, false, 'spawn error'));
|
|
93
|
+
child.once('close', (code, signal) => {
|
|
94
|
+
// No process by that pid is an answer, not a failed inspection, and is not said. `ps -p <pid>` (and the Windows
|
|
95
|
+
// probe) says it by ending on its own with status 1 and no row: read on Darwin 25.4, `ps -o pid=,lstart= -p <an
|
|
96
|
+
// exited pid>` prints nothing and exits 1; its manual gives `-p` no other way to say it. Everything else is
|
|
97
|
+
// the probe failing: it could not be spawned, was killed, ran out of time, exited otherwise, or printed
|
|
98
|
+
// something that is not a birth. That is said once and is also undefined.
|
|
99
|
+
if (code === 1 && signal === null && output.trim() === '') { if (!settled) { settled = true; clearTimeout(timer); child.stdout.destroy(); resolve(undefined); } return; }
|
|
100
|
+
finish(code === 0 ? probe.read(output.trim()) : undefined, undefined, code, signal, false,
|
|
101
|
+
code === 0 ? 'unrecognized birth output' : 'nonzero exit');
|
|
102
|
+
});
|
|
71
103
|
});
|
|
72
104
|
}
|
|
73
105
|
/** Capture at spawn for durable ownership, never when retrying a retained PID. */
|
|
@@ -96,11 +128,28 @@ export async function verifySpawnIdentity(identity) {
|
|
|
96
128
|
throw new Error(`Process ${identity.pid} identity cannot be established; ownership retained`);
|
|
97
129
|
}
|
|
98
130
|
}
|
|
131
|
+
/** A signal the system refused has nothing left to reach when the process and its group are gone or are zombies
|
|
132
|
+
* only: a zombie holds nothing and takes no signal, and a group of them is retired (Cal build 43: "kill EPERM"
|
|
133
|
+
* after the postmaster had shut down). That is read from the process table (`survivingOwnedGroups`), never from
|
|
134
|
+
* the error's code; where the table cannot be read the group counts as live and the refusal is thrown. */
|
|
135
|
+
function nothingLeftToSignal(pid) {
|
|
136
|
+
try { return survivingOwnedGroups([pid]).length === 0; } catch { return false; }
|
|
137
|
+
}
|
|
138
|
+
/** An instrument: a signal that failed with anything but ESRCH says what it targeted and who is in that group, then
|
|
139
|
+
* is thrown as before (Cal build 43: "kill EPERM" after the postmaster's clean shutdown). It changes no outcome. */
|
|
140
|
+
function saySignalFailure(error, target, signal, child) {
|
|
141
|
+
try {
|
|
142
|
+
const rows = process.platform === 'win32' ? '' : spawnSync('ps', ['-o', 'pid=,pgid=,stat=,lstart=,command=', '-g', String(Math.abs(target))],
|
|
143
|
+
{ encoding: 'utf8', timeout: BIRTH_LOOKUP_MS, killSignal: 'SIGKILL' }).stdout ?? '';
|
|
144
|
+
process.stderr.write(`[signal] ${error.code} kill(${target}, ${signal}) exitCode=${child?.exitCode} signalCode=${child?.signalCode} group: ${rows.trim() || 'no rows'}\n`);
|
|
145
|
+
} catch { /* the failure itself is still thrown by the caller */ }
|
|
146
|
+
}
|
|
99
147
|
/** Stored or retained numbers use birth verification before every delivery. */
|
|
100
148
|
export async function signalOwnedProcess(identity, signal, group = false) {
|
|
101
149
|
await verifySpawnIdentity(identity);
|
|
102
|
-
|
|
103
|
-
|
|
150
|
+
const target = group && process.platform !== 'win32' ? -identity.pid : identity.pid;
|
|
151
|
+
try { return process.kill(target, signal); }
|
|
152
|
+
catch (error) { if (error.code === 'ESRCH' || nothingLeftToSignal(identity.pid)) return false; saySignalFailure(error, target, signal); throw error; }
|
|
104
153
|
}
|
|
105
154
|
/** A live ChildProcess is ownership until exit. A group additionally needs its leader alive.
|
|
106
155
|
* False means no held delivery was possible; a caller with surviving descendants needs a birth. */
|
|
@@ -108,9 +157,9 @@ export function signalChild(child, signal, group = false) {
|
|
|
108
157
|
if (!child.pid || child.exitCode !== null || child.signalCode !== null) return false;
|
|
109
158
|
if (!group || process.platform === 'win32') return child.kill(signal);
|
|
110
159
|
try { process.kill(child.pid, 0); }
|
|
111
|
-
catch (error) { if (error.code === 'ESRCH') return false; throw error; }
|
|
160
|
+
catch (error) { if (error.code === 'ESRCH' || nothingLeftToSignal(child.pid)) return false; saySignalFailure(error, child.pid, 0, child); throw error; }
|
|
112
161
|
try { return process.kill(-child.pid, signal); }
|
|
113
|
-
catch (error) { if (error.code === 'ESRCH') return false; throw error; }
|
|
162
|
+
catch (error) { if (error.code === 'ESRCH' || nothingLeftToSignal(child.pid)) return false; saySignalFailure(error, -child.pid, signal, child); throw error; }
|
|
114
163
|
}
|
|
115
164
|
|
|
116
165
|
/** Held first; an orphaned group then becomes retained numeric ownership. */
|
|
@@ -99,6 +99,15 @@ export declare function projectConnections(input?: string, attributedEnv?: Set<s
|
|
|
99
99
|
* env heuristic. Only direct `fetch("https://...")`, `window.fetch`, and `globalThis.fetch` calls
|
|
100
100
|
* count. Dynamic destinations remain intentionally unclaimed; guessing from arbitrary URL strings
|
|
101
101
|
* would turn docs, links, and webhook examples into false external-service dependencies. */
|
|
102
|
+
/** A migration tool an application carries, by its evidence in the repository (architecture, World wiring). */
|
|
103
|
+
export interface ProjectMigrationTool {
|
|
104
|
+
tool: string;
|
|
105
|
+
/** the tool's schema, and its migrations directory, relative to the repository */
|
|
106
|
+
schema: string;
|
|
107
|
+
migrations: string;
|
|
108
|
+
}
|
|
109
|
+
/** Each migration tool the repository depends on that has migrations beside its schema (or beside its schema's folder). */
|
|
110
|
+
export declare function projectMigrationTools(input?: string): ProjectMigrationTool[];
|
|
102
111
|
export declare function projectFetchUrls(input?: string): ProjectFetchUrl[];
|
|
103
112
|
/** Env names read directly by production JS/TS source, with repo-relative `path:line` evidence.
|
|
104
113
|
* This is intentionally evidence, not validator inference: seeing an empty example value read by
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { parse as parseToml } from 'smol-toml';
|
|
3
|
-
import { basename, relative, resolve } from 'node:path';
|
|
3
|
+
import { basename, dirname, relative, resolve } from 'node:path';
|
|
4
4
|
import { normalizePypiName, overlayPypiTwins, overlaySdkTwins } from "./pack-facts.js";
|
|
5
5
|
/** npm package → vendor/twin mapping — the shared vendor↔SDK knowledge used by both
|
|
6
6
|
* `inspect-project` (adoption discovery) and `covers` (room-setup coverage proof).
|
|
@@ -663,13 +663,36 @@ export function projectConnections(input = process.cwd(), attributedEnv = new Se
|
|
|
663
663
|
}
|
|
664
664
|
return [...merged.values()];
|
|
665
665
|
}
|
|
666
|
-
/**
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
|
|
666
|
+
/**
|
|
667
|
+
* The migration tools `init` can notice from a repository: the tool as a dependency, and its own migrations beside
|
|
668
|
+
* its schema. One row per tool; the next tool is a row. What a World's `runtime.database.prepare` needs (the command
|
|
669
|
+
* as the image runs it, the image path it runs in, the inputs' paths there) is the IMAGE's, which a repository does
|
|
670
|
+
* not state: a Dockerfile's prune and copies decide it. So this names the tool and where its migrations are, never
|
|
671
|
+
* the step.
|
|
672
|
+
*/
|
|
673
|
+
const MIGRATION_TOOLS = [
|
|
674
|
+
{ tool: 'prisma', dependency: 'prisma', schemaExtension: '.prisma', migrations: 'migrations' },
|
|
675
|
+
];
|
|
676
|
+
/** Each migration tool the repository depends on that has migrations beside its schema (or beside its schema's folder). */
|
|
677
|
+
export function projectMigrationTools(input = process.cwd()) {
|
|
678
|
+
const root = resolve(input);
|
|
679
|
+
const dependencies = new Set(projectDependencies(root));
|
|
680
|
+
const found = new Map();
|
|
681
|
+
for (const row of MIGRATION_TOOLS) {
|
|
682
|
+
if (!dependencies.has(row.dependency))
|
|
683
|
+
continue;
|
|
684
|
+
for (const schema of projectSourceFiles(root, new Set([row.schemaExtension]))) {
|
|
685
|
+
const beside = [dirname(schema), dirname(dirname(schema))].map((dir) => resolve(dir, row.migrations))
|
|
686
|
+
.find((dir) => existsSync(dir) && statSync(dir).isDirectory());
|
|
687
|
+
if (!beside)
|
|
688
|
+
continue;
|
|
689
|
+
const migrations = relative(root, beside);
|
|
690
|
+
if (!found.has(`${row.tool}\0${migrations}`))
|
|
691
|
+
found.set(`${row.tool}\0${migrations}`, { tool: row.tool, schema: relative(root, schema), migrations });
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return [...found.values()].sort((a, b) => a.migrations.localeCompare(b.migrations));
|
|
695
|
+
}
|
|
673
696
|
export function projectFetchUrls(input = process.cwd()) {
|
|
674
697
|
const root = resolve(input);
|
|
675
698
|
const found = [];
|
package/dist/src/runtime.js
CHANGED
|
@@ -1334,6 +1334,24 @@ async function startExternalService(service, paths, worldEnv, stripEnv, boot) {
|
|
|
1334
1334
|
}
|
|
1335
1335
|
injected[mapping.as] = value;
|
|
1336
1336
|
}
|
|
1337
|
+
// The World's own infrastructure also says what the app's commands are told of its database (architecture,
|
|
1338
|
+
// managed infrastructure): that the live data carries the image's migration, and that this is a development
|
|
1339
|
+
// World whose app migrates when it runs.
|
|
1340
|
+
if (ownInfrastructureUp(external.up) && statusOutput) {
|
|
1341
|
+
let facts;
|
|
1342
|
+
try {
|
|
1343
|
+
facts = JSON.parse(statusOutput);
|
|
1344
|
+
}
|
|
1345
|
+
catch { /* a status that is not JSON says neither */ }
|
|
1346
|
+
const migrated = facts?.database?.migrated;
|
|
1347
|
+
if (Array.isArray(migrated) && migrated.every((word) => typeof word === 'string'))
|
|
1348
|
+
injected.VOLTER_WORLD_DATABASE_MIGRATED = JSON.stringify(migrated);
|
|
1349
|
+
const behind = facts?.database?.behind;
|
|
1350
|
+
if (Array.isArray(behind) && behind.every((word) => typeof word === 'string'))
|
|
1351
|
+
injected.VOLTER_WORLD_DATABASE_BEHIND = JSON.stringify(behind);
|
|
1352
|
+
if (facts?.development?.migrateDatabaseAtRuntime === true)
|
|
1353
|
+
injected.VOLTER_WORLD_DEVELOPMENT_MIGRATE_AT_RUNTIME = '1';
|
|
1354
|
+
}
|
|
1337
1355
|
return {
|
|
1338
1356
|
id: service.id,
|
|
1339
1357
|
type: 'external',
|
|
@@ -3972,8 +3990,11 @@ export async function attachWorld(name, command, root = process.cwd(), options =
|
|
|
3972
3990
|
throw new Error(`World "${name}" is changing lifecycle; retry after it finishes`);
|
|
3973
3991
|
}
|
|
3974
3992
|
const current = statusWorld(name, resolvedRoot);
|
|
3975
|
-
|
|
3976
|
-
|
|
3993
|
+
// Which of the two it was, with what was read: "stopped" and "another instance" are different repairs.
|
|
3994
|
+
if (!current.running)
|
|
3995
|
+
throw new Error(`World "${name}" is not running while attaching: none of its recorded processes is live and its own (recorded pids ${readPids(current.pidsFile).join(', ') || 'none'}; live and its own: none)`);
|
|
3996
|
+
if (current.createdAt !== attached.instance.createdAt)
|
|
3997
|
+
throw new Error(`World "${name}" changed while attaching: its instance was created ${attached.instance.createdAt} when the command's environment was read and ${current.createdAt} now`);
|
|
3977
3998
|
// Attaching to a World whose services died is allowed (a command may not need them), but never silent.
|
|
3978
3999
|
for (const [id, state] of Object.entries(current.serviceStates)) {
|
|
3979
4000
|
if (state === 'running' || state === 'external')
|