@volter/world-runtime 3.0.163 → 3.0.164

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.
Files changed (137) hide show
  1. package/dist/src/ancestry.js +2 -2
  2. package/dist/src/app-url.d.ts +5 -1
  3. package/dist/src/app-url.js +14 -1
  4. package/dist/src/branch.js +28 -8
  5. package/dist/src/browser-files.d.ts +5 -0
  6. package/dist/src/browser-files.js +14 -0
  7. package/dist/src/browsers.d.ts +2 -3
  8. package/dist/src/browsers.js +4 -4
  9. package/dist/src/catalog.d.ts +2 -1
  10. package/dist/src/catalog.js +3 -2
  11. package/dist/src/changeset.js +1 -1
  12. package/dist/src/cli-commands.js +71 -18
  13. package/dist/src/cli-load-clock.d.ts +1 -0
  14. package/dist/src/cli-load-clock.js +2 -0
  15. package/dist/src/cli.js +5 -1
  16. package/dist/src/clickhouse-backing.d.ts +2 -0
  17. package/dist/src/clickhouse-backing.js +145 -0
  18. package/dist/src/command-lifetime.d.mts +1 -1
  19. package/dist/src/command-lifetime.mjs +5 -2
  20. package/dist/src/configs.js +1 -1
  21. package/dist/src/consumers.js +1 -1
  22. package/dist/src/covers.d.ts +4 -2
  23. package/dist/src/covers.js +8 -4
  24. package/dist/src/go-mysql-binding.d.ts +19 -0
  25. package/dist/src/go-mysql-binding.js +51 -0
  26. package/dist/src/guest-processes.d.mts +1 -1
  27. package/dist/src/guest-processes.mjs +22 -14
  28. package/dist/src/host-cli.js +8 -1
  29. package/dist/src/host-ready.d.ts +16 -2
  30. package/dist/src/host-ready.js +66 -39
  31. package/dist/src/host-worker.js +7 -1
  32. package/dist/src/host.d.ts +26 -0
  33. package/dist/src/host.js +93 -3
  34. package/dist/src/infra-definition.d.ts +35 -0
  35. package/dist/src/infra-definition.js +146 -0
  36. package/dist/src/infra.d.ts +3 -0
  37. package/dist/src/infra.js +375 -105
  38. package/dist/src/init.d.ts +46 -0
  39. package/dist/src/init.js +445 -18
  40. package/dist/src/lifecycle-record.d.ts +5 -1
  41. package/dist/src/lifecycle-record.js +27 -4
  42. package/dist/src/local-branches.js +1 -1
  43. package/dist/src/mariadb-bootstrap.d.ts +32 -0
  44. package/dist/src/mariadb-bootstrap.js +90 -0
  45. package/dist/src/mariadb-supplier.d.ts +23 -0
  46. package/dist/src/mariadb-supplier.js +215 -0
  47. package/dist/src/mongodb-backing.d.ts +4 -0
  48. package/dist/src/mongodb-backing.js +71 -1
  49. package/dist/src/native-mariadb-backing.d.ts +13 -0
  50. package/dist/src/native-mariadb-backing.js +512 -0
  51. package/dist/src/native-postgres-backing.d.ts +4 -10
  52. package/dist/src/native-postgres-backing.js +428 -43
  53. package/dist/src/native-server-inputs.d.ts +16 -0
  54. package/dist/src/native-server-inputs.js +204 -0
  55. package/dist/src/origin.js +3 -3
  56. package/dist/src/original-inputs.d.ts +14 -0
  57. package/dist/src/original-inputs.js +84 -0
  58. package/dist/src/pack-facts.d.ts +9 -1
  59. package/dist/src/pglite-backing.d.ts +41 -17
  60. package/dist/src/pglite-backing.js +213 -79
  61. package/dist/src/pglite-host.mjs +482 -10
  62. package/dist/src/process-groups.d.mts +6 -1
  63. package/dist/src/process-groups.d.ts +2 -1
  64. package/dist/src/process-groups.js +1 -1
  65. package/dist/src/process-groups.mjs +82 -15
  66. package/dist/src/process-signal.d.mts +2 -2
  67. package/dist/src/process-signal.mjs +29 -9
  68. package/dist/src/project-inspect.d.ts +12 -0
  69. package/dist/src/project-inspect.js +41 -4
  70. package/dist/src/proxy-env.d.ts +34 -0
  71. package/dist/src/proxy-env.js +114 -0
  72. package/dist/src/redirect-proxy.d.ts +1 -31
  73. package/dist/src/redirect-proxy.js +3 -112
  74. package/dist/src/redis-backing.d.ts +6 -4
  75. package/dist/src/redis-backing.js +20 -168
  76. package/dist/src/runtime.d.ts +1 -0
  77. package/dist/src/runtime.js +503 -218
  78. package/dist/src/schema.d.ts +1 -0
  79. package/dist/src/schema.js +5 -3
  80. package/dist/src/served-world.js +3 -3
  81. package/dist/src/twin-backing.d.ts +47 -0
  82. package/dist/src/twin-backing.js +343 -0
  83. package/dist/src/up-cli.js +56 -3
  84. package/package.json +3 -3
  85. package/src/ancestry.ts +2 -2
  86. package/src/app-url.ts +9 -2
  87. package/src/branch.ts +26 -8
  88. package/src/browser-files.ts +15 -0
  89. package/src/browsers.ts +4 -4
  90. package/src/catalog.ts +4 -3
  91. package/src/changeset.ts +1 -1
  92. package/src/cli-commands.ts +85 -18
  93. package/src/cli-load-clock.ts +2 -0
  94. package/src/cli.ts +5 -1
  95. package/src/clickhouse-backing.ts +70 -0
  96. package/src/command-lifetime.d.mts +1 -1
  97. package/src/command-lifetime.mjs +5 -2
  98. package/src/configs.ts +1 -1
  99. package/src/consumers.ts +1 -1
  100. package/src/covers.ts +9 -4
  101. package/src/go-mysql-binding.ts +49 -0
  102. package/src/guest-processes.d.mts +1 -1
  103. package/src/guest-processes.mjs +22 -14
  104. package/src/host-cli.ts +7 -1
  105. package/src/host-ready.ts +62 -31
  106. package/src/host-worker.ts +6 -2
  107. package/src/host.ts +88 -6
  108. package/src/infra-definition.ts +135 -0
  109. package/src/infra.ts +250 -45
  110. package/src/init.ts +416 -17
  111. package/src/lifecycle-record.ts +18 -4
  112. package/src/local-branches.ts +1 -1
  113. package/src/mariadb-bootstrap.ts +107 -0
  114. package/src/mariadb-supplier.ts +199 -0
  115. package/src/mongodb-backing.ts +59 -1
  116. package/src/native-mariadb-backing.ts +418 -0
  117. package/src/native-postgres-backing.ts +353 -41
  118. package/src/native-server-inputs.ts +169 -0
  119. package/src/origin.ts +3 -3
  120. package/src/original-inputs.ts +72 -0
  121. package/src/pack-facts.ts +1 -1
  122. package/src/pglite-backing.ts +192 -80
  123. package/src/pglite-host.mjs +482 -10
  124. package/src/process-groups.d.mts +6 -1
  125. package/src/process-groups.mjs +82 -15
  126. package/src/process-groups.ts +2 -1
  127. package/src/process-signal.d.mts +2 -2
  128. package/src/process-signal.mjs +29 -9
  129. package/src/project-inspect.ts +37 -4
  130. package/src/proxy-env.ts +128 -0
  131. package/src/redirect-proxy.ts +3 -125
  132. package/src/redis-backing.ts +19 -136
  133. package/src/runtime.ts +342 -107
  134. package/src/schema.ts +5 -4
  135. package/src/served-world.ts +3 -3
  136. package/src/twin-backing.ts +300 -0
  137. package/src/up-cli.ts +52 -3
@@ -336,6 +336,7 @@ export type WorldDatabasePrepare = {
336
336
  command: string[];
337
337
  cwd: string;
338
338
  inputs: string[];
339
+ with?: 'world';
339
340
  };
340
341
  /**
341
342
  * SETTINGS FOR THE PERSON DEVELOPING THE APP AGAINST THIS WORLD (runtime.development). A World that sets any of
@@ -86,8 +86,8 @@ function assertExternalServiceConfig(service, path) {
86
86
  if (!mapping.as || typeof mapping.as !== 'string') {
87
87
  throw new Error(`External service "${id}" discover entry must define string "as" in ${path}`);
88
88
  }
89
- if (mapping.protocol !== undefined && !['postgres', 'mysql', 'mongodb', 'redis', 'http'].includes(mapping.protocol)) {
90
- throw new Error(`External service "${id}" discover "${mapping.as}" protocol must be postgres, mysql, mongodb, redis or http in ${path}`);
89
+ if (mapping.protocol !== undefined && !['postgres', 'mysql', 'mongodb', 'redis', 'http', 'clickhouse'].includes(mapping.protocol)) {
90
+ throw new Error(`External service "${id}" discover "${mapping.as}" protocol must be postgres, mysql, mongodb, redis, http or clickhouse in ${path}`);
91
91
  }
92
92
  const hasJson = typeof mapping.jsonPath === 'string';
93
93
  const hasPattern = typeof mapping.pattern === 'string';
@@ -505,7 +505,9 @@ function normalizeV2(value, path) {
505
505
  if (databasePrepare !== undefined) {
506
506
  if (!record(databasePrepare))
507
507
  throw new Error(`runtime.database.prepare must be an object in ${path}`);
508
- assertKeys(databasePrepare, ['command', 'cwd', 'inputs'], 'runtime.database.prepare', path);
508
+ assertKeys(databasePrepare, ['command', 'cwd', 'inputs', 'with'], 'runtime.database.prepare', path);
509
+ if (databasePrepare.with !== undefined && databasePrepare.with !== 'world')
510
+ throw new Error(`runtime.database.prepare.with must be "world" where it is given in ${path}`);
509
511
  const strings = (value) => Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'string' && item !== '');
510
512
  if (!strings(databasePrepare.command))
511
513
  throw new Error(`runtime.database.prepare.command must be a non-empty argv in ${path}`);
@@ -2075,7 +2075,7 @@ export class WorldDoors {
2075
2075
  const views = {};
2076
2076
  const vendors = Object.keys(this.up().services);
2077
2077
  withHistoryLocks(vendors.map((vendor) => worldPaths(this.stateOf(vendor), this.controlRoot(vendor)).dir), () => { for (const vendor of vendors)
2078
- views[vendor] = historyAtInstant(this.stateOf(vendor), instant, this.controlRoot(vendor)); });
2078
+ views[vendor] = historyAtInstant(this.stateOf(vendor), instant, this.controlRoot(vendor)); }, { operation: 'capture' });
2079
2079
  return Response.json({ world: this.served, at: new Date(Date.parse(instant)).toISOString(), views });
2080
2080
  }
2081
2081
  // THE WORLD'S MARKS (`GET marks`, `POST marks { note? }`): where its changes count from. A World seeded through its
@@ -2099,7 +2099,7 @@ export class WorldDoors {
2099
2099
  const passed = delta.actions.filter((a) => !received.has(a.action.id)).length;
2100
2100
  const mark = markWorld(this.name, { root: this.worldRoot, ...(typeof body.note === 'string' && body.note.trim() ? { note: body.note } : {}) });
2101
2101
  return { mark, passed };
2102
- });
2102
+ }, { operation: 'capture' });
2103
2103
  return Response.json({ world: this.served, mark, passed }, { status: 201 });
2104
2104
  }
2105
2105
  if (kind === 'map' && !name && request.method === 'GET')
@@ -3188,7 +3188,7 @@ export async function landChangeset(name, worldRoot, instance, changeset, expect
3188
3188
  }
3189
3189
  landedByTwin.set(twin, ids);
3190
3190
  }
3191
- });
3191
+ }, { operation: 'action append' });
3192
3192
  for (const [twin, landedIds] of landedByTwin) {
3193
3193
  const controlRoot = join(instance.dirs.data, twin);
3194
3194
  const state = stateOf(twin);
@@ -0,0 +1,47 @@
1
+ import { type PackFacts } from './pack-facts.js';
2
+ import { type HostLaunch, type InfraService } from './pglite-backing.js';
3
+ /** A managed-infrastructure kind a twin may serve (the kinds pack facts admit in `managedService`). */
4
+ export type ManagedKind = NonNullable<PackFacts['managedService']>['kind'];
5
+ /** What only a kind's protocol can answer, and the kind's name as a person reads it in a message. */
6
+ export interface ManagedProtocol {
7
+ name: string;
8
+ /** Ownership may use the kernel boot answer, bound to this child by a retained nonce. */
9
+ bootProof?: true;
10
+ /** Whether a server of this protocol answers on the port at all: one that does before this World's starts is another process's. */
11
+ answers(port: number): Promise<boolean>;
12
+ /** Whether the server answering on the port is the process `pid` (architecture D9). */
13
+ serves(port: number, pid: number, service?: InfraService, boot?: string): Promise<boolean>;
14
+ }
15
+ export interface TwinServedBacking extends ManagedProtocol {
16
+ /** The vendor id of the pack that declares the kind, and the word its messages use in lower case. */
17
+ id: string;
18
+ /** The pack's package. */
19
+ package: string;
20
+ /** The directory under the World's data that holds the service's state, and the stem of its pid file and log. */
21
+ directory: string;
22
+ facts: PackFacts;
23
+ }
24
+ /** The directory of a kind's state under the World's data. */
25
+ export declare const managedDirectory: (kind: ManagedKind) => string;
26
+ /** The twin that serves a kind: the one pack whose facts declare it (pack-facts refuses two for a kind), with the
27
+ * kind's protocol. Undefined where no pack declares the kind; the kind then keeps its own backing.
28
+ *
29
+ * Facts are read in two places. The kernel's own table holds the packs it was built with. A pack it was not built
30
+ * with carries its own facts (`generated/pack-facts.json`, architecture, "The catalog: where twins come from"), and
31
+ * the catalog overlays those for a World's SELECTED twins only; a pack that serves a managed service is selected by
32
+ * nobody (it is no service of world.json), so its declaration is read here from the package itself, installed
33
+ * above one of `from`. Only the declaration is read: nothing of that file is overlaid on the kernel's tables. */
34
+ export declare function managedTwin(kind: ManagedKind, protocol: ManagedProtocol, from?: readonly string[], originalInputs?: boolean): TwinServedBacking | undefined;
35
+ /** The twin's cli: installed beside this runtime (its node_modules walk), else beside the World's config. */
36
+ export declare function twinBackingCli(kind: TwinServedBacking, from: string[]): string | undefined;
37
+ /** Where the service's state lives: the World's tree for the service, under the World's data. */
38
+ export declare const twinBackingRoot: (kind: TwinServedBacking, dataDir: string) => string;
39
+ export declare function originalInputArguments(kind: TwinServedBacking, service: InfraService, config: string): string[];
40
+ export declare function twinBackingUp(kind: TwinServedBacking, services: InfraService[], dataDir: string, cli: string, launch?: HostLaunch): Promise<void>;
41
+ /** Whether these services stand now as their status would prove them, asked now: for each port, the pid recorded is
42
+ * the child this process started, that child has not closed and is alive, and the port is served by it (`serves`,
43
+ * asked again here, as status asks it). A service it did not start here, or any doubt (an error asking), is not held,
44
+ * and the caller's own status runs instead. */
45
+ export declare function twinBackingHeld(kind: TwinServedBacking, services: InfraService[], dataDir: string): Promise<boolean>;
46
+ export declare function twinBackingStatus(kind: TwinServedBacking, services: InfraService[], dataDir: string): Promise<number>;
47
+ export declare function twinBackingDown(kind: TwinServedBacking, services: InfraService[], dataDir: string): Promise<void>;
@@ -0,0 +1,343 @@
1
+ // A World's managed service SERVED BY A TWIN: the lifecycle every such service shares, whatever protocol the twin
2
+ // speaks. The twin's own cli is started detached on the loopback port the definition declares, its state under the
3
+ // World's data (so it branches, checkpoints and rolls back with the World's tree); the child is recorded by pid and
4
+ // birth, proven to be the one answering on the port, and retired by TERM then KILL with its receipts kept until
5
+ // retirement is confirmed.
6
+ //
7
+ // Which pack serves a kind is the pack's own declaration (`managedService` in its facts; architecture, "The
8
+ // descriptor"), found here by managedTwin: no pack and no vendor is named in the runtime. Every such pack is started
9
+ // the same way, by its bin: `serve --port <the definition's port> --root <the service's tree>`.
10
+ //
11
+ // What the runtime does hold per kind is its ManagedProtocol: the two questions only the kind's protocol can answer
12
+ // (does anything of this protocol answer on the port; is the one answering this child, architecture D9).
13
+ // redis-backing.ts holds Redis's.
14
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync } from 'node:fs';
15
+ import { randomUUID } from 'node:crypto';
16
+ import { connect } from 'node:net';
17
+ import { dirname, join } from 'node:path';
18
+ import { commandLifetime, COMMAND_CONFIRM_MS } from "./command-lifetime.js";
19
+ import { publishProcessReceipt, readProcessReceipt, readSpawnReceipt, spawnReceiptPath, signalOwnedProcess } from "./process-receipt.js";
20
+ import { spawn } from 'node:child_process';
21
+ import { survivingOwnedGroups, worldEvent } from "./process-groups.js";
22
+ import { errorMessage } from "./error-message.js";
23
+ import { findInstalledPackage, packCli, packFactsFile, twinPackageName } from "./catalog.js";
24
+ import { installedVendorPacks, vendorPackageDir } from '@volter/world-core/runtime';
25
+ import { packFacts } from "./pack-facts.js";
26
+ import { takePreparedStore } from "./pglite-backing.js";
27
+ import { bindOriginalInputState, preflightOriginalInputState, readOriginalInputSnapshot, verifyOriginalInputState } from "./original-inputs.js";
28
+ /** The directory of a kind's state under the World's data. */
29
+ export const managedDirectory = (kind) => `${kind}-twin`;
30
+ /** The twin that serves a kind: the one pack whose facts declare it (pack-facts refuses two for a kind), with the
31
+ * kind's protocol. Undefined where no pack declares the kind; the kind then keeps its own backing.
32
+ *
33
+ * Facts are read in two places. The kernel's own table holds the packs it was built with. A pack it was not built
34
+ * with carries its own facts (`generated/pack-facts.json`, architecture, "The catalog: where twins come from"), and
35
+ * the catalog overlays those for a World's SELECTED twins only; a pack that serves a managed service is selected by
36
+ * nobody (it is no service of world.json), so its declaration is read here from the package itself, installed
37
+ * above one of `from`. Only the declaration is read: nothing of that file is overlaid on the kernel's tables. */
38
+ export function managedTwin(kind, protocol, from = [], originalInputs = false) {
39
+ const built = Object.entries(packFacts()).find(([, facts]) => facts.managedService?.kind === kind)?.[0];
40
+ const installed = built && !originalInputs ? undefined : installedDeclaring(kind, from);
41
+ if (originalInputs && !installed)
42
+ return undefined;
43
+ if (built && installed && built !== installed.vendor)
44
+ throw new Error(`Installed managed ${kind} pack conflicts with the built-in declaration`);
45
+ const vendor = originalInputs ? installed?.vendor : built ?? installed?.vendor;
46
+ return vendor === undefined ? undefined : { ...protocol, id: vendor, package: twinPackageName(vendor), directory: managedDirectory(kind), facts: installed?.facts ?? packFacts()[built] };
47
+ }
48
+ /** The installed pack whose own facts declare the kind, by vendor id; two that do are refused by name, as the
49
+ * kernel's table refuses two. */
50
+ function installedDeclaring(kind, from) {
51
+ const found = new Map();
52
+ for (const dir of from) {
53
+ for (const [vendor, pack] of installedVendorPacks(dir)) {
54
+ const file = packFactsFile(pack);
55
+ if (file === undefined)
56
+ continue;
57
+ let own;
58
+ try {
59
+ own = JSON.parse(readFileSync(file, 'utf8')).packs?.[vendor];
60
+ }
61
+ catch {
62
+ continue;
63
+ }
64
+ if (own?.managedService?.kind === kind)
65
+ found.set(vendor, own);
66
+ }
67
+ }
68
+ if (found.size > 1)
69
+ throw new Error(`two installed packs declare the ${kind} kind (managedService): ${[...found.keys()].sort().join(', ')}`);
70
+ const entry = [...found.entries()][0];
71
+ return entry ? { vendor: entry[0], facts: entry[1] } : undefined;
72
+ }
73
+ const hosts = new Map();
74
+ const READY_TIMEOUT_MS = 60_000;
75
+ /** The twin's cli: installed beside this runtime (its node_modules walk), else beside the World's config. */
76
+ export function twinBackingCli(kind, from) {
77
+ for (const dir of from) {
78
+ const found = vendorPackageDir(dir, kind.id) ?? findInstalledPackage(dir, kind.package);
79
+ const cli = found === undefined ? undefined : packCli(found);
80
+ if (cli !== undefined && existsSync(cli))
81
+ return cli;
82
+ }
83
+ return undefined;
84
+ }
85
+ const pidPath = (kind, dataDir, service) => join(dataDir, `${kind.directory}-${service.hostPort}.pid`);
86
+ /** Where the service's state lives: the World's tree for the service, under the World's data. */
87
+ export const twinBackingRoot = (kind, dataDir) => join(dataDir, kind.directory);
88
+ function alive(pid) {
89
+ try {
90
+ process.kill(pid, 0);
91
+ return true;
92
+ }
93
+ catch (error) {
94
+ if (error.code === 'ESRCH')
95
+ return false;
96
+ throw error;
97
+ }
98
+ }
99
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
100
+ function portOccupied(port) {
101
+ return new Promise(resolve => {
102
+ const socket = connect({ host: '127.0.0.1', port, timeout: 1000 });
103
+ const end = (occupied) => { socket.destroy(); resolve(occupied); };
104
+ socket.once('connect', () => end(true));
105
+ socket.once('error', () => end(false));
106
+ socket.once('timeout', () => end(false));
107
+ socket.once('close', () => end(false));
108
+ });
109
+ }
110
+ function sideArguments(kind, service) {
111
+ const args = [];
112
+ for (const [name, side] of Object.entries(service.sides ?? {})) {
113
+ const declared = kind.facts.sidePorts?.find(port => port.name === name);
114
+ if (!declared || !/^--[a-z][a-z-]*$/.test(declared.flag))
115
+ throw new Error(`${kind.name} installed pack does not declare side listener ${name}`);
116
+ args.push(declared.flag, String(side.hostPort));
117
+ }
118
+ return args;
119
+ }
120
+ export function originalInputArguments(kind, service, config) {
121
+ if (!service.originalInputs)
122
+ return [];
123
+ const input = kind.facts.managedService?.originalInputs;
124
+ if (!input || input.version !== 1 || input.manifestArg !== '--original-inputs' || input.rootArg !== '--original-root' || input.digestArg !== '--original-inputs-sha256'
125
+ || !Array.isArray(input.paths) || !input.paths.length)
126
+ throw new Error(`${kind.name} installed pack has no original-input consumer`);
127
+ const read = readOriginalInputSnapshot(dirname(config), service.originalInputs);
128
+ if (read.snapshot.manifest.family !== service.kind || input.paths.some(path => !read.snapshot.manifest.roots.includes(path)))
129
+ throw new Error(`${kind.name} original-input source differs from its consumer`);
130
+ return [input.manifestArg, join(read.root, read.reference.manifest), input.rootArg, read.root, input.digestArg, read.reference.sha256];
131
+ }
132
+ export async function twinBackingUp(kind, services, dataDir, cli, launch = {}) {
133
+ const config = (launch.env ?? process.env).VOLTER_WORLD_CONFIG;
134
+ if (services.some(service => service.originalInputs) && !config)
135
+ throw new Error('Original-input services require their World configuration path');
136
+ // Authenticate every source before changing any existing host or persistent store.
137
+ const originals = new Map(services.map(service => [service, service.originalInputs ? originalInputArguments(kind, service, config) : []]));
138
+ for (const service of services)
139
+ if (service.originalInputs)
140
+ preflightOriginalInputState(twinBackingRoot(kind, dataDir), service.originalInputs);
141
+ for (const service of services) {
142
+ const pidFile = pidPath(kind, dataDir, service);
143
+ if (hosts.has(pidFile) || existsSync(pidFile) || existsSync(spawnReceiptPath(pidFile))) {
144
+ await twinBackingDown(kind, [service], dataDir);
145
+ }
146
+ // a port that already answers is another process's (another World's twin, the vendor's own server): serving there would
147
+ // hand this World's app someone else's state, so it is refused, never taken as ready
148
+ if (await kind.answers(service.hostPort))
149
+ throw new Error(`port ${service.hostPort} already serves ${kind.name} for another process`);
150
+ const sides = sideArguments(kind, service);
151
+ for (const side of Object.values(service.sides ?? {}))
152
+ if (await portOccupied(side.hostPort))
153
+ throw new Error(`${kind.name} side port ${side.hostPort} already belongs to another listener`);
154
+ if (service.configuration && kind.facts.managedService?.configurationArg !== '--configuration')
155
+ throw new Error(`${kind.name} installed pack does not declare operator configuration`);
156
+ const root = twinBackingRoot(kind, dataDir);
157
+ // What a prepare step run with the World up wrote to this service (a queue's registered jobs in Redis) is the
158
+ // World's prepared state as its cluster is (architecture, "A step may need the World"): the staged root is
159
+ // placed here before the twin first serves an empty one, whichever pack's twin it is.
160
+ const env = launch.env ?? process.env;
161
+ const taken = takePreparedStore(env.VOLTER_WORLD_CONFIG, env, kind.directory, root);
162
+ if (taken.note)
163
+ launch.note?.(taken.note);
164
+ mkdirSync(root, { recursive: true });
165
+ if (service.originalInputs)
166
+ bindOriginalInputState(root, service.originalInputs);
167
+ const configurationFile = service.configuration ? join(root, `operator-${service.hostPort}.json`) : undefined;
168
+ if (configurationFile)
169
+ publishProcessReceipt(configurationFile, JSON.stringify(service.configuration) + '\n');
170
+ const boot = kind.bootProof ? randomUUID() : undefined;
171
+ const log = join(dataDir, `${kind.directory}-${service.hostPort}.log`);
172
+ mkdirSync(dirname(log), { recursive: true });
173
+ // a real fd, not a pipe: the server outlives this process untethered
174
+ const logFd = openSync(log, 'a');
175
+ // the runtime this runs on: Bun runs the pack's TypeScript as it is; Node (volter-world-infra's shebang) needs
176
+ // type transformation, not only stripping (the kernel declares constructor parameter properties)
177
+ const runtime = typeof globalThis.Bun !== 'undefined' || !cli.endsWith('.ts') ? [] : ['--experimental-transform-types', '--no-warnings'];
178
+ let held;
179
+ try {
180
+ try {
181
+ const child = spawn(process.execPath, [...runtime, cli, 'serve', '--port', String(service.hostPort), '--root', root,
182
+ ...(service.replicaSet ? ['--replica-set', service.replicaSet] : []), ...sides, ...(configurationFile ? ['--configuration', configurationFile] : []), ...originals.get(service)], {
183
+ env: { ...(launch.env ?? process.env), ...(boot ? { VOLTER_WORLD_BOOT_ID: boot } : {}) }, detached: true, windowsHide: true, stdio: ['ignore', logFd, logFd],
184
+ });
185
+ // Retain BEFORE descriptor close, birth capture or either publication can fail.
186
+ held = { child, lifetime: commandLifetime(child, launch.signal, false, READY_TIMEOUT_MS), closed: false, ...(boot ? { boot } : {}) };
187
+ const observed = held;
188
+ child.once('close', () => { observed.closed = true; });
189
+ child.on('error', () => { }); // lifetime joins spawn failure and close
190
+ hosts.set(pidFile, held);
191
+ }
192
+ finally {
193
+ closeSync(logFd);
194
+ }
195
+ if (!held.child.pid)
196
+ throw new Error(`${kind.name} child did not start`);
197
+ const identity = await held.lifetime.identity;
198
+ if (!identity?.start)
199
+ throw new Error(`${kind.name} birth capture failed; retiring held child`);
200
+ publishProcessReceipt(spawnReceiptPath(pidFile), JSON.stringify({ ...identity, ...(boot ? { boot } : {}) }) + '\n');
201
+ publishProcessReceipt(pidFile, `${held.child.pid}\n`);
202
+ const deadline = Date.now() + READY_TIMEOUT_MS;
203
+ while (!alive(held.child.pid) || !(await kind.serves(service.hostPort, held.child.pid, service, boot))) {
204
+ if (!alive(held.child.pid))
205
+ throw new Error(`${kind.id} exited before serving — see ${log}`);
206
+ launch.signal?.throwIfAborted();
207
+ if (Date.now() > deadline)
208
+ throw new Error(`${kind.id} did not serve port ${service.hostPort} within ${READY_TIMEOUT_MS / 1000}s — see ${log}`);
209
+ await sleep(25);
210
+ }
211
+ launch.signal?.throwIfAborted();
212
+ held.child.unref();
213
+ }
214
+ catch (error) {
215
+ if (held) {
216
+ try {
217
+ await twinBackingDown(kind, [service], dataDir);
218
+ }
219
+ catch (cleanup) {
220
+ throw new AggregateError([error, cleanup], `${kind.name} startup and retirement failed; ownership retained`);
221
+ }
222
+ }
223
+ throw error;
224
+ }
225
+ }
226
+ }
227
+ /** Whether these services stand now as their status would prove them, asked now: for each port, the pid recorded is
228
+ * the child this process started, that child has not closed and is alive, and the port is served by it (`serves`,
229
+ * asked again here, as status asks it). A service it did not start here, or any doubt (an error asking), is not held,
230
+ * and the caller's own status runs instead. */
231
+ export async function twinBackingHeld(kind, services, dataDir) {
232
+ for (const service of services) {
233
+ try {
234
+ const pidFile = pidPath(kind, dataDir, service);
235
+ const held = hosts.get(pidFile);
236
+ if (!held?.child.pid || held.closed || !existsSync(pidFile))
237
+ return false;
238
+ const pid = Number(readFileSync(pidFile, 'utf8').trim());
239
+ if (pid !== held.child.pid || !alive(pid) || !(await kind.serves(service.hostPort, pid, service)))
240
+ return false;
241
+ }
242
+ catch {
243
+ return false;
244
+ }
245
+ }
246
+ return true;
247
+ }
248
+ export async function twinBackingStatus(kind, services, dataDir) {
249
+ let ready = 0;
250
+ for (const service of services) {
251
+ if (service.originalInputs) {
252
+ try {
253
+ verifyOriginalInputState(twinBackingRoot(kind, dataDir), service.originalInputs);
254
+ }
255
+ catch (error) {
256
+ process.stderr.write(`${errorMessage(error)}\n`);
257
+ continue;
258
+ }
259
+ }
260
+ const pidFile = pidPath(kind, dataDir, service);
261
+ if (!existsSync(pidFile))
262
+ continue;
263
+ const recorded = readProcessReceipt(pidFile, text => {
264
+ const pid = Number(text.trim());
265
+ if (!Number.isInteger(pid) || pid <= 0)
266
+ throw new Error(`Invalid ${kind.name} ownership PID`);
267
+ return pid;
268
+ });
269
+ const identity = kind.bootProof ? readSpawnReceipt(pidFile) : undefined;
270
+ if (recorded.error || identity?.error) {
271
+ process.stderr.write(`${errorMessage(recorded.error ?? identity.error)}\n`);
272
+ continue;
273
+ }
274
+ const pid = recorded.value;
275
+ if (!pid)
276
+ continue;
277
+ const boot = identity?.value?.boot;
278
+ if (kind.bootProof && (identity?.value?.pid !== pid || typeof boot !== 'string' || !boot))
279
+ continue;
280
+ if (alive(pid) && await kind.serves(service.hostPort, pid, service, typeof boot === 'string' ? boot : undefined))
281
+ ready += 1;
282
+ }
283
+ return ready;
284
+ }
285
+ export async function twinBackingDown(kind, services, dataDir) {
286
+ for (const service of services) {
287
+ const pidFile = pidPath(kind, dataDir, service);
288
+ const recorded = readProcessReceipt(pidFile, text => {
289
+ const pid = Number(text.trim());
290
+ if (!Number.isInteger(pid) || pid <= 0)
291
+ throw new Error(`Invalid ${kind.name} ownership PID`);
292
+ return pid;
293
+ });
294
+ const identity = readSpawnReceipt(pidFile);
295
+ const held = hosts.get(pidFile);
296
+ if (recorded.error || identity.error)
297
+ process.stderr.write(`${errorMessage(new AggregateError([recorded.error, identity.error].filter(Boolean), `${kind.name} ownership uncertain; retrying established ownership`))}\n`);
298
+ if (held) {
299
+ const retired = () => held.closed && (!held.child.pid || !survivingOwnedGroups([held.child.pid]).length);
300
+ if (!retired()) {
301
+ held.child.ref();
302
+ held.lifetime.cancel('SIGTERM');
303
+ try {
304
+ await held.lifetime.finish();
305
+ }
306
+ catch (error) {
307
+ if (!retired())
308
+ throw error;
309
+ }
310
+ }
311
+ }
312
+ else {
313
+ if (!recorded.present && !identity.present)
314
+ continue;
315
+ const pid = identity.value?.pid ?? recorded.value;
316
+ if (!pid)
317
+ throw new AggregateError([recorded.error, identity.error].filter(Boolean), `${kind.name} ownership uncertain; receipts retained`);
318
+ if (recorded.value && identity.value && recorded.value !== identity.value.pid)
319
+ throw new Error(`${kind.name} ownership receipts disagree; retirement refused; receipts retained`);
320
+ const live = () => survivingOwnedGroups([pid]).length > 0;
321
+ // A STORE IS AWAITED AT ITS STOP on the lifecycle's own budget, not a command's grace: between the signal and
322
+ // its exit it writes its state down, and a SIGKILL in the middle of that leaves a root nothing may take.
323
+ // Its stop, its exit and the time between are events in the World's log; a kill is said as a kill.
324
+ const stopAsked = Date.now();
325
+ for (const [signal, limit] of [['SIGTERM', READY_TIMEOUT_MS], ['SIGKILL', COMMAND_CONFIRM_MS]]) {
326
+ if (!live())
327
+ break;
328
+ worldEvent({ event: signal === 'SIGTERM' ? 'world.service.stop' : 'world.service.killed', service: kind.name, by: 'down', signal, to: 'group', pid, ...(signal === 'SIGKILL' ? { afterMs: Date.now() - stopAsked, doing: 'its stop had not ended within the lifecycle budget' } : {}) });
329
+ await signalOwnedProcess(identity.value, signal, true, 'down');
330
+ const deadline = Date.now() + limit;
331
+ while (live() && Date.now() < deadline)
332
+ await sleep(20);
333
+ }
334
+ if (!live())
335
+ worldEvent({ event: 'world.service.exited', service: kind.name, by: 'down', pid, shutdownMs: Date.now() - stopAsked });
336
+ if (live())
337
+ throw new Error(`${kind.name} retirement unconfirmed; receipts retained`);
338
+ }
339
+ rmSync(pidFile, { force: true });
340
+ rmSync(spawnReceiptPath(pidFile), { force: true });
341
+ hosts.delete(pidFile);
342
+ }
343
+ }
@@ -3,8 +3,16 @@
3
3
  // the process starts (a browser tab's engine compiles every module it loads), those modules were most of the
4
4
  // CLI's time before the owner process began.
5
5
  import { optionValue, ownerOption } from "./cli-args.js";
6
- import { superviseWorldUp } from "./up-task.js";
7
6
  export async function upCommand(subject, rest, root) {
7
+ const split = rest.indexOf('--');
8
+ const command = split < 0 ? [] : rest.slice(split + 1);
9
+ if (split >= 0)
10
+ rest = rest.slice(0, split);
11
+ const attachAfterLine = rest.includes('--attach-after-line');
12
+ if (attachAfterLine && !rest.includes('--in-process'))
13
+ throw new Error('volter-world up: --attach-after-line requires --in-process');
14
+ if (attachAfterLine && command.length === 0)
15
+ throw new Error('volter-world up: --attach-after-line requires a command after --');
8
16
  if (rest.some(arg => arg === '--env-file' || arg.startsWith('--env-file=')))
9
17
  throw new Error('--env-file is not an input for this command. Use --env-out=<new-output-path>; never pass credentials or custody as an output.');
10
18
  if (!subject)
@@ -35,19 +43,64 @@ export async function upCommand(subject, rest, root) {
35
43
  // where there is none, it costs a whole Node start before the boot and its own end after it (Rallly run 35: 611 ms
36
44
  // from the owner's "returning" to its reply reaching this process). The caller's SIGINT or SIGTERM still cancels the
37
45
  // boot, which rolls back.
38
- const instance = rest.includes('--in-process') ? await upInProcess(subject, options) : await superviseWorldUp(subject, options);
46
+ const instance = rest.includes('--in-process') ? await upInProcess(subject, options) : await (await import("./up-task.js")).superviseWorldUp(subject, options);
47
+ // An instrument: what this process does between the boot's return and its last line, each part timed. A second
48
+ // stood between "returning: the World is up" and the ready text with nothing naming it (LibreChat and Dub, in a
49
+ // browser tab); the parts are the app-url module's load, the World's status read, the record's locked write, and
50
+ // the ready text.
51
+ const returned = Date.now();
52
+ let tail = '';
39
53
  if (appUrl !== undefined) {
40
54
  const { setAppUrl } = await import("./app-url.js");
41
- setAppUrl(instance.name, appUrl, { root });
55
+ const loaded = Date.now();
56
+ const timings = {};
57
+ setAppUrl(instance.name, appUrl, { root, timings });
42
58
  process.stderr.write(`volter-world up ${instance.name}: app url recorded: ${appUrl}\n`);
59
+ tail = `app-url module loaded in ${loaded - returned} ms, status read in ${timings.statusMs ?? '?'} ms, record written in ${timings.writeMs ?? '?'} ms, `;
43
60
  }
61
+ const printing = Date.now();
44
62
  if (rest.includes('--json'))
45
63
  process.stdout.write(JSON.stringify(instance, null, 2) + '\n');
46
64
  else
47
65
  printReady(instance, !keepState);
66
+ process.stderr.write(`volter-world up ${instance.name}: after the boot returned: ${tail}ready text in ${Date.now() - printing} ms, ${Date.now() - returned} ms in all\n`);
67
+ if (attachAfterLine) {
68
+ await waitForAttachLine();
69
+ const { attachWorld } = await import("./runtime.js");
70
+ process.exit(await attachWorld(instance.name, command, root, { cwd: process.cwd(), owner: ownerOption(rest), verbose: rest.includes('--verbose'), instance }));
71
+ }
72
+ }
73
+ /** Read only the admission line; the command inherits the same stdin afterward. */
74
+ function waitForAttachLine() {
75
+ return new Promise((resolve, reject) => {
76
+ let line = '';
77
+ const cleanup = () => { process.stdin.off('readable', readable); process.stdin.off('end', ended); process.stdin.off('error', failed); };
78
+ const failed = (error) => { cleanup(); reject(error); };
79
+ const ended = () => failed(new Error('volter-world up: stdin ended before the attach line'));
80
+ const readable = () => {
81
+ let byte;
82
+ while ((byte = process.stdin.read(1)) !== null) {
83
+ if (byte[0] === 10) {
84
+ cleanup();
85
+ if (line.replace(/\r$/, '') !== 'attach')
86
+ reject(new Error('volter-world up: expected stdin line "attach"'));
87
+ else
88
+ resolve();
89
+ return;
90
+ }
91
+ line += byte.toString();
92
+ }
93
+ };
94
+ process.stdin.on('readable', readable);
95
+ process.stdin.once('end', ended);
96
+ process.stdin.once('error', failed);
97
+ readable();
98
+ });
48
99
  }
49
100
  async function upInProcess(configId, options) {
101
+ const loading = performance.now();
50
102
  const { upWorld } = await import("./runtime.js");
103
+ process.stderr.write(`volter-world up: runtime modules loaded in ${(performance.now() - loading).toFixed(1)} ms\n`);
51
104
  const abort = new AbortController();
52
105
  const cancel = (signal) => abort.abort(signal);
53
106
  process.on('SIGINT', cancel);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "3.0.163",
3
+ "version": "3.0.164",
4
4
  "description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
5
5
  "keywords": [
6
6
  "twin",
@@ -70,7 +70,7 @@
70
70
  "node": ">=22.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@volter/world-core": "3.0.155",
73
+ "@volter/world-core": "3.0.156",
74
74
  "@volter/world-console": "3.0.151"
75
75
  },
76
76
  "dependencies": {
@@ -80,7 +80,7 @@
80
80
  "@volter/browser-runtime": "0.1.104",
81
81
  "@volter/browser-sandboxes": "0.1.84",
82
82
  "@volter/world-access": "3.0.1",
83
- "@volter/world-core": "3.0.155",
83
+ "@volter/world-core": "3.0.156",
84
84
  "modern-screenshot": "4.7.0",
85
85
  "mongodb": "~6.20.0",
86
86
  "pg-gateway": "0.3.0-beta.4",
package/src/ancestry.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // carry parent-side pins, including clones across checkouts, without a global World registry.
3
3
  import { existsSync, readdirSync } from 'node:fs';
4
4
  import { join, relative, resolve, sep } from 'node:path';
5
- import { assertStateRemovable, readBranchMeta, stateDirName, withAncestryLock, withStateRemoval, getActiveWorldStore, worldPaths, worldStateRoot } from '@volter/world-core/runtime';
5
+ import { assertStateRemovable, readBranchMeta, stateDirName, withAncestryLock, withStateRemoval, getActiveWorldStore, worldPaths, worldStateRoot } from '@volter/world-core/runtime-state';
6
6
 
7
7
  function checkWorldAncestry(root: string, path: string): void {
8
8
  const base = join(root, stateDirName(), 'worlds');
@@ -28,7 +28,7 @@ function checkWorldAncestry(root: string, path: string): void {
28
28
  }
29
29
 
30
30
  export function assertWorldStateRemovable(root: string, path: string): void {
31
- withAncestryLock(() => { checkWorldAncestry(root, path); assertStateRemovable(path); });
31
+ withAncestryLock(() => { checkWorldAncestry(root, path); assertStateRemovable(path); }, { operation: 'removal preflight' });
32
32
  }
33
33
 
34
34
  export function removeWorldState<T>(root: string, path: string, remove: () => T): T {
package/src/app-url.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  // instance it described.
22
22
  import { errorMessage } from './error-message.ts';
23
23
  import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
24
- import { withFileLock } from '@volter/world-core/runtime';
24
+ import { withFileLock } from '@volter/world-core/runtime-state';
25
25
  import { join } from 'node:path';
26
26
  import { spawnSync } from 'node:child_process';
27
27
  import net from 'node:net';
@@ -113,11 +113,18 @@ function assertHttpUrl(url: string): URL {
113
113
  /** Record `url` as the app endpoint of the RUNNING/booted instance `name`. The instance must
114
114
  * exist (a record for a world that was never upped would describe nothing); the app itself is
115
115
  * deliberately NOT probed — the booter declaring "this is where I put the app" is the truth. */
116
- export function setAppUrl(name: string, url: string, options: { root?: string; via?: Exclude<AppUrlSource, 'service'>; detail?: string; app?: string } = {}): AppUrlRecord {
116
+ export function setAppUrl(name: string, url: string, options: { root?: string; via?: Exclude<AppUrlSource, 'service'>; detail?: string; app?: string; /** An instrument: filled with how long the World's status and the record's write each took. */ timings?: { statusMs?: number; writeMs?: number } } = {}): AppUrlRecord {
117
117
  const root = options.root ?? process.cwd();
118
118
  const app = appName(options.app);
119
+ const began = Date.now();
119
120
  const status = statusWorld(name, root); // throws "World instance not found" — the loud path
121
+ const statusRead = Date.now();
122
+ if (options.timings) options.timings.statusMs = statusRead - began;
120
123
  assertHttpUrl(url);
124
+ try { return setAppUrlEntry(root, name, app, url, status, options); }
125
+ finally { if (options.timings) options.timings.writeMs = Date.now() - statusRead; }
126
+ }
127
+ function setAppUrlEntry(root: string, name: string, app: string, url: string, status: ReturnType<typeof statusWorld>, options: { via?: Exclude<AppUrlSource, 'service'>; detail?: string }): AppUrlRecord {
121
128
  return writeEntry(root, name, app, (file) => {
122
129
  // a new URL keeps the hosts recorded for the app (they name the app, not where it listens)
123
130
  const hosts = entryOf(file, app)?.hosts;