@volter/world-runtime 2.0.49 → 2.0.51
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/changeset.d.ts +3 -1
- package/dist/src/changeset.js +6 -2
- package/dist/src/configs.d.ts +4 -1
- package/dist/src/configs.js +15 -2
- package/dist/src/fixture-env.js +4 -0
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.js +1 -1
- package/dist/src/root.js +3 -3
- package/dist/src/runtime.js +67 -50
- package/dist/src/schema.d.ts +12 -0
- package/dist/src/schema.js +18 -1
- package/dist/src/served-world.d.ts +12 -2
- package/dist/src/served-world.js +108 -21
- package/dist/src/world-view.d.ts +1 -0
- package/dist/src/world-view.js +2 -2
- package/package.json +5 -5
- package/src/changeset.ts +8 -4
- package/src/configs.ts +11 -3
- package/src/fixture-env.ts +4 -0
- package/src/index.ts +2 -1
- package/src/root.ts +3 -3
- package/src/runtime.ts +54 -38
- package/src/schema.ts +21 -1
- package/src/served-world.ts +90 -24
- package/src/world-view.ts +3 -3
package/dist/src/changeset.d.ts
CHANGED
|
@@ -48,7 +48,9 @@ export declare function createWorldChangeset(world: string, name: string, option
|
|
|
48
48
|
verifiers?: ChangesetVerifier[];
|
|
49
49
|
now?: Date;
|
|
50
50
|
overwrite?: boolean;
|
|
51
|
-
message?: string;
|
|
51
|
+
message?: string; /** who cuts it and the person it is owned by, from the cutting grant (a served World's door) */
|
|
52
|
+
cutBy?: string;
|
|
53
|
+
owner?: string;
|
|
52
54
|
}): Changeset;
|
|
53
55
|
/** Every changeset in every world under `root` (or one world with `world`), newest first. */
|
|
54
56
|
export declare function listWorldChangesets(options?: WorldRootOptions & {
|
package/dist/src/changeset.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { worldChangesetRules } from "./configs.js";
|
|
1
2
|
import { blobDigest, captureHistory, captureParentHistory, getActiveBlobStore, getActiveWorldStore, withAncestryLock, worldPaths } from '@volter/world-core';
|
|
2
3
|
// `volter-world mark` / `diff` / `changeset` — the WORLD-side verbs over the changeset
|
|
3
4
|
// primitive (docs/concepts/the-model.md v0). Thin, exactly like `covers` and `tail` are thin: all
|
|
@@ -183,6 +184,8 @@ export function createWorldChangeset(world, name, options = {}) {
|
|
|
183
184
|
...(options.verifiers ? { verifiers: options.verifiers } : {}),
|
|
184
185
|
...(options.now ? { createdAt: options.now.toISOString() } : {}),
|
|
185
186
|
...(options.message === undefined ? {} : { message: options.message }),
|
|
187
|
+
...(options.cutBy === undefined ? {} : { cutBy: options.cutBy }),
|
|
188
|
+
...(options.owner === undefined ? {} : { owner: options.owner }),
|
|
186
189
|
});
|
|
187
190
|
getActiveWorldStore().write(path, `${JSON.stringify(changeset, null, 2)}\n`);
|
|
188
191
|
// a changeset records where the log was cut (contract "The model"): the next one starts here
|
|
@@ -373,7 +376,7 @@ export function approveWorldChangeset(name, options) {
|
|
|
373
376
|
export function statusWorldChangeset(name, options = {}) {
|
|
374
377
|
const root = resolveRoot(options.root);
|
|
375
378
|
const located = findWorldChangeset(name, { root, ...(options.world ? { world: options.world } : {}) });
|
|
376
|
-
return changesetReadiness(located.changeset);
|
|
379
|
+
return changesetReadiness(located.changeset, worldChangesetRules(root));
|
|
377
380
|
}
|
|
378
381
|
export async function pushWorldChangeset(name, options) {
|
|
379
382
|
// PUSH (docs/concepts/the-model.md): the changeset goes to the served world's push door; its
|
|
@@ -541,7 +544,8 @@ export async function pushWorldChangeset(name, options) {
|
|
|
541
544
|
const receipts = body.receipts.map((r) => ({ actionId: r.actionId, service: r.twin, stateService: located.changeset.actions.find((e) => e.service === r.twin)?.stateService ?? r.twin, status: statusOf(r.status), ...(r.externalId ? { externalId: r.externalId } : {}), ...(r.reason ? { error: r.reason } : {}) }));
|
|
542
545
|
const outcome = receipts.every((r) => r.status !== 'failed') ? 'applied' : receipts.some((r) => r.status === 'confirmed') ? 'partial' : 'refused';
|
|
543
546
|
const application = { at, contentHash: located.changeset.contentHash, outcome, receipts, compensation: [], ...(outcome === 'refused' ? { refusal: receipts.filter((r) => r.error).map((r) => r.error) } : {}) };
|
|
544
|
-
|
|
547
|
+
// the remote's record of who pushed it and who owns it, kept on this copy too (outside the hash)
|
|
548
|
+
const changeset = { ...withApplication(located.changeset, application), ...(body.owner && !located.changeset.owner ? { owner: body.owner } : {}), ...(body.pushedBy ? { pushedBy: body.pushedBy } : {}) };
|
|
545
549
|
writeChangesetFile(located.path, changeset);
|
|
546
550
|
const unsent = namedFailure ? `records the landed entries name did not reach the origin (${namedFailure instanceof Error ? namedFailure.message : String(namedFailure)}); the next push from this branch that touches ${twins.map(([t]) => t).join(', ')} sends them` : undefined;
|
|
547
551
|
return { ...(unsent ? { unsent } : {}), application, changeset, world: located.world, path: located.path, remote: { url, namespace } };
|
package/dist/src/configs.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type WorldConfig } from './schema.js';
|
|
1
|
+
import { type WorldChangesetRules, type WorldConfig } from './schema.js';
|
|
2
2
|
export declare function resolveConfigPath(config: string, root?: string): string;
|
|
3
3
|
export declare function loadWorldConfig(config: string, root?: string): {
|
|
4
4
|
path: string;
|
|
@@ -16,3 +16,6 @@ export type WorldConfigMigration = {
|
|
|
16
16
|
};
|
|
17
17
|
/** Explicit, backed-up, atomic format-1 → format-2 migration. Ordinary reads never call this. */
|
|
18
18
|
export declare function migrateWorldConfig(config: string, root?: string): WorldConfigMigration;
|
|
19
|
+
/** The changeset rules of the World rooted at `root` (its committed `.volter/world.json`, `changesets`); none when it
|
|
20
|
+
* has no config or names none, which readiness reads as one approval and a required summary. */
|
|
21
|
+
export declare function worldChangesetRules(root: string): WorldChangesetRules;
|
package/dist/src/configs.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { copyFileSync, constants, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { basename, dirname, resolve } from 'node:path';
|
|
3
|
-
import { getActiveWorldStore } from '@volter/world-core';
|
|
2
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { getActiveWorldStore, stateDirName } from '@volter/world-core';
|
|
4
4
|
import { assertWorldConfig, LEGACY_WORLD_SELECTION, worldConfigDocument } from "./schema.js";
|
|
5
5
|
export function resolveConfigPath(config, root = process.cwd()) {
|
|
6
6
|
const candidates = config.includes('/') || config.endsWith('.json')
|
|
@@ -117,3 +117,16 @@ export function migrateWorldConfig(config, root = process.cwd()) {
|
|
|
117
117
|
dropped: normalized.resources === undefined ? [] : ['resources (deprecated; retained in backup)'],
|
|
118
118
|
};
|
|
119
119
|
}
|
|
120
|
+
/** The changeset rules of the World rooted at `root` (its committed `.volter/world.json`, `changesets`); none when it
|
|
121
|
+
* has no config or names none, which readiness reads as one approval and a required summary. */
|
|
122
|
+
export function worldChangesetRules(root) {
|
|
123
|
+
const path = join(root, stateDirName(), 'world.json');
|
|
124
|
+
if (!getActiveWorldStore().exists(path) && !existsSync(path))
|
|
125
|
+
return {};
|
|
126
|
+
try {
|
|
127
|
+
return loadWorldConfig(path, root).config.changesets ?? {};
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return {};
|
|
131
|
+
}
|
|
132
|
+
}
|
package/dist/src/fixture-env.js
CHANGED
|
@@ -194,6 +194,10 @@ const CREDENTIAL_SHAPES = [
|
|
|
194
194
|
{ match: /(^|_)STRIPE_[A-Z0-9_]*WEBHOOK_SECRET$/, value: () => 'whsec_twinfake0000000000000000000000000000', source: 'https://docs.stripe.com/webhooks#verify-events' },
|
|
195
195
|
{ match: /(^|_)STRIPE_[A-Z0-9_]*(SECRET|PRIVATE|API)(_API)?_KEY$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (sk_test_)' },
|
|
196
196
|
{ match: /(^|_)STRIPE_[A-Z0-9_]*(PUBLISHABLE|PUBLIC)(_API)?_KEY$/, value: () => 'pk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (pk_test_)' },
|
|
197
|
+
// The slack twin answers a bearer that is none of Slack's token kinds with `invalid_auth`, as Slack does; any `xoxb-`
|
|
198
|
+
// acts as the workspace bot, and an app-level token is `xapp-`.
|
|
199
|
+
{ match: /(^|_)SLACK_[A-Z0-9_]*APP_TOKEN$/, value: () => 'xapp-1-twinfake', source: 'packages/twin/slack/src/slack-server.ts (bearer /^(xox|xapp-)/)' },
|
|
200
|
+
{ match: /(^|_)SLACK_([A-Z0-9_]*_)?BOT_TOKEN$|(^|_)SLACK_TOKEN$/, value: () => 'xoxb-twinfake', source: 'packages/twin/slack/src/slack-server.ts (bearer /^(xox|xapp-)/); GET /twin identity (any other xoxb-* is the workspace bot)' },
|
|
197
201
|
// tavily-auth.ts and firecrawl-auth.ts refuse a key without the vendor's prefix with the vendor's 401
|
|
198
202
|
{ match: /(^|_)TAVILY_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'tvly-twinfake00000000000000000000', source: 'packages/twin/tavily/src/tavily-auth.ts' },
|
|
199
203
|
{ match: /(^|_)FIRECRAWL_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'fc-twinfake00000000000000000000000', source: 'packages/twin/firecrawl/src/firecrawl-auth.ts FIRECRAWL_KEY_PREFIX' },
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export type { WorldConfig, WorldInstance, WorldIsolation, WorldMode, WorldServiceConfig, WorldServiceType, WorldExternalServiceConfig, WorldExternalDiscover, WorldExternalDiscoverSource, WorldExternalReadyWhen, WorldRunOutcome, WorldRunRecord, WorldResourceRequirements, WorldManifestV2, WorldSelection, WorldSelectionSelector, WorldUsage, } from './schema.js';
|
|
2
2
|
export { DEFAULT_WORLD_SELECTION, LEGACY_WORLD_SELECTION, WORLD_SCHEMA_VERSION, WORLD_USAGES, worldConfigDocument, worldSelectionIncludes } from './schema.js';
|
|
3
|
-
export {
|
|
3
|
+
export type { WorldChangesetRules } from './schema.js';
|
|
4
|
+
export { loadWorldConfig, migrateWorldConfig, resolveConfigPath, worldChangesetRules, writeWorldConfig } from './configs.js';
|
|
4
5
|
export type { WorldConfigMigration } from './configs.js';
|
|
5
6
|
export { upWorld, downWorld, pruneWorlds, listWorlds, statusWorld, doctorWorld, runWithWorldEnv, attachWorld, retireWorldConsumers, activateScript, shellWorld, worldShellEnv, ensureWorldProxy, runProxyDaemon, runWorld, shareWorld, shareWorldServices, unshareWorld, urlsWorld, urlWorld, } from './runtime.js';
|
|
6
7
|
export type { WorldCleanupEntry } from './runtime.js';
|
package/dist/src/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { DEFAULT_WORLD_SELECTION, LEGACY_WORLD_SELECTION, WORLD_SCHEMA_VERSION, WORLD_USAGES, worldConfigDocument, worldSelectionIncludes } from "./schema.js";
|
|
2
|
-
export { loadWorldConfig, migrateWorldConfig, resolveConfigPath, writeWorldConfig } from "./configs.js";
|
|
2
|
+
export { loadWorldConfig, migrateWorldConfig, resolveConfigPath, worldChangesetRules, writeWorldConfig } from "./configs.js";
|
|
3
3
|
export { upWorld, downWorld, pruneWorlds, listWorlds, statusWorld, doctorWorld, runWithWorldEnv, attachWorld, retireWorldConsumers, activateScript, shellWorld, worldShellEnv, ensureWorldProxy, runProxyDaemon, runWorld, shareWorld, shareWorldServices, unshareWorld, urlsWorld, urlWorld, } from "./runtime.js";
|
|
4
4
|
export { worldResourceReport } from "./resources.js";
|
|
5
5
|
// `volter-world app-url` — the recorded, queryable app endpoint of an instance (see app-url.ts).
|
package/dist/src/root.js
CHANGED
|
@@ -12,7 +12,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
13
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
14
14
|
import { authStrategyFor, currentSealer, destinationOf, sealsThroughVault, openSealedAt, getActiveWorldStore, getPack, buildRemoteExecute, clearRoot, readRoot, registerStateSystem, stateDirName, stateSystemFor, validateRemoteOrigin, writeRoot, } from '@volter/world-core';
|
|
15
|
-
import { loadWorldConfig, writeWorldConfig } from "./configs.js";
|
|
15
|
+
import { loadWorldConfig, worldChangesetRules, writeWorldConfig } from "./configs.js";
|
|
16
16
|
import { changesetReadiness, collectObservations, foldObservations, loadChecks, openRootCredential, performEntries, rootCustody, protocolStanding } from '@volter/world-core';
|
|
17
17
|
import { findWorldChangeset, listWorldChangesets } from "./changeset.js";
|
|
18
18
|
import { importModule } from "./import-module.js";
|
|
@@ -577,7 +577,7 @@ export async function deployWorld(name, opts = {}) {
|
|
|
577
577
|
const out = [];
|
|
578
578
|
if (!opts.changeset) {
|
|
579
579
|
// no name: every ready changeset, by name, in order — so each outcome says which it was for
|
|
580
|
-
const ready = listWorldChangesets({ root: worldRoot, world: name }).filter((l) => changesetReadiness(l.changeset).ready && !l.changeset.actions.every((e) => { const r = rootForControlRoot(join(inst.dirs.data, e.service), e.service); return r === null; })).sort((a, b) => a.changeset.createdAt.localeCompare(b.changeset.createdAt));
|
|
580
|
+
const ready = listWorldChangesets({ root: worldRoot, world: name }).filter((l) => changesetReadiness(l.changeset, worldChangesetRules(worldRoot)).ready && !l.changeset.actions.every((e) => { const r = rootForControlRoot(join(inst.dirs.data, e.service), e.service); return r === null; })).sort((a, b) => a.changeset.createdAt.localeCompare(b.changeset.createdAt));
|
|
581
581
|
const anyGated = Object.keys(inst.services).some((s) => rootForControlRoot(join(inst.dirs.data, s), s)?.deploy === 'gated');
|
|
582
582
|
if (anyGated || ready.length) {
|
|
583
583
|
for (const l of ready)
|
|
@@ -589,7 +589,7 @@ export async function deployWorld(name, opts = {}) {
|
|
|
589
589
|
const located = opts.changeset ? [findWorldChangeset(opts.changeset, { root: worldRoot, world: name })] : listWorldChangesets({ root: worldRoot, world: name });
|
|
590
590
|
const ready = new Map();
|
|
591
591
|
for (const l of located) {
|
|
592
|
-
const readiness = changesetReadiness(l.changeset);
|
|
592
|
+
const readiness = changesetReadiness(l.changeset, worldChangesetRules(worldRoot));
|
|
593
593
|
if (!readiness.ready) {
|
|
594
594
|
const gated = [...new Set(l.changeset.actions.map((e) => e.service))].some((s) => rootForControlRoot(join(inst.dirs.data, s), s)?.deploy === 'gated');
|
|
595
595
|
if (opts.changeset && gated)
|
package/dist/src/runtime.js
CHANGED
|
@@ -1342,7 +1342,7 @@ function versionSatisfies(actual, pin) {
|
|
|
1342
1342
|
*/
|
|
1343
1343
|
async function startColocatedServices(config, colocated, isolation, worldEnv, paths, allocatedPorts, boot) {
|
|
1344
1344
|
if (colocated.length === 0)
|
|
1345
|
-
return [];
|
|
1345
|
+
return { services: [], ready: Promise.resolve() };
|
|
1346
1346
|
const hostCli = siblingScript(import.meta.url, 'host-cli');
|
|
1347
1347
|
const readyFile = join(paths.instance, `host-ready-${boot.bootId}.json`);
|
|
1348
1348
|
const args = [hostCli, '--isolation', isolation === 'colocated' ? 'shared' : 'worker', '--ready-file', readyFile];
|
|
@@ -1381,55 +1381,10 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
|
|
|
1381
1381
|
exitState.value = { code, signal };
|
|
1382
1382
|
});
|
|
1383
1383
|
const command = [process.execPath, ...args];
|
|
1384
|
-
const hostTimeoutMs = allocated.reduce((timeout, { service }) => Math.max(timeout, typeof service.ready === 'object' ? service.ready.timeoutMs ?? 15_000 : 15_000), 15_000);
|
|
1385
|
-
await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
|
|
1386
|
-
if (undeadPids([child.pid]).length)
|
|
1387
|
-
recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
|
|
1388
|
-
try {
|
|
1389
|
-
process.kill(-child.pid, 'SIGTERM');
|
|
1390
|
-
}
|
|
1391
|
-
catch { /* best effort cleanup */ }
|
|
1392
|
-
throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
|
|
1393
|
-
});
|
|
1394
|
-
await parallelStartup(allocated, async ({ service, port }) => {
|
|
1395
|
-
const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
|
|
1396
|
-
await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
|
|
1397
|
-
if (undeadPids([child.pid]).length)
|
|
1398
|
-
recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
|
|
1399
|
-
try {
|
|
1400
|
-
process.kill(-child.pid, 'SIGTERM');
|
|
1401
|
-
}
|
|
1402
|
-
catch { /* best effort cleanup */ }
|
|
1403
|
-
throw new Error(`Co-located twin "${service.id}" failed to start (host isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
|
|
1404
|
-
});
|
|
1405
|
-
});
|
|
1406
|
-
let hostSettleTimer;
|
|
1407
|
-
const hostSettle = new Promise((resolveAlive) => { hostSettleTimer = setTimeout(() => resolveAlive('alive'), 300); });
|
|
1408
|
-
try {
|
|
1409
|
-
await parallelStartup(allocated, async ({ service, port }) => {
|
|
1410
|
-
// The host has completed every start. Each matching boot answer can now
|
|
1411
|
-
// end its own settle; legacy twins share one full host guard.
|
|
1412
|
-
await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log, true, hostSettle).catch((error) => {
|
|
1413
|
-
if (undeadPids([child.pid]).length)
|
|
1414
|
-
recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
|
|
1415
|
-
try {
|
|
1416
|
-
process.kill(-child.pid, 'SIGTERM');
|
|
1417
|
-
}
|
|
1418
|
-
catch { /* best effort cleanup */ }
|
|
1419
|
-
throw error;
|
|
1420
|
-
});
|
|
1421
|
-
});
|
|
1422
|
-
}
|
|
1423
|
-
finally {
|
|
1424
|
-
if (hostSettleTimer !== undefined)
|
|
1425
|
-
clearTimeout(hostSettleTimer);
|
|
1426
|
-
}
|
|
1427
|
-
if (exitState.value !== null) {
|
|
1428
|
-
throw new Error(`The co-located host ended during startup even though its ports answer, so other/stale processes probably hold them: the host ${endedAccount(log)}\n`
|
|
1429
|
-
+ `Stop leftover services (e.g. \`volter-world down\`) and retry.\nLog: ${log}`);
|
|
1430
|
-
}
|
|
1431
1384
|
const host = '127.0.0.1';
|
|
1432
|
-
|
|
1385
|
+
// Every twin's address is known once its port is allocated: the records and
|
|
1386
|
+
// the environment they inject exist before the host reports its starts.
|
|
1387
|
+
const services = allocated.map(({ service, port, mounted }) => {
|
|
1433
1388
|
const url = `http://${host}:${port}`;
|
|
1434
1389
|
return {
|
|
1435
1390
|
id: service.id,
|
|
@@ -1448,6 +1403,55 @@ async function startColocatedServices(config, colocated, isolation, worldEnv, pa
|
|
|
1448
1403
|
},
|
|
1449
1404
|
};
|
|
1450
1405
|
});
|
|
1406
|
+
const ready = (async () => {
|
|
1407
|
+
const hostTimeoutMs = allocated.reduce((timeout, { service }) => Math.max(timeout, typeof service.ready === 'object' ? service.ready.timeoutMs ?? 15_000 : 15_000), 15_000);
|
|
1408
|
+
await untilReadyOrEnded((abort) => waitForHostReady(readyFile, boot.bootId, new Map(allocated.map(({ service, port }) => [service.id, port])), hostTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
|
|
1409
|
+
if (undeadPids([child.pid]).length)
|
|
1410
|
+
recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
|
|
1411
|
+
try {
|
|
1412
|
+
process.kill(-child.pid, 'SIGTERM');
|
|
1413
|
+
}
|
|
1414
|
+
catch { /* best effort cleanup */ }
|
|
1415
|
+
throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
|
|
1416
|
+
});
|
|
1417
|
+
await parallelStartup(allocated, async ({ service, port }) => {
|
|
1418
|
+
const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
|
|
1419
|
+
await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
|
|
1420
|
+
if (undeadPids([child.pid]).length)
|
|
1421
|
+
recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
|
|
1422
|
+
try {
|
|
1423
|
+
process.kill(-child.pid, 'SIGTERM');
|
|
1424
|
+
}
|
|
1425
|
+
catch { /* best effort cleanup */ }
|
|
1426
|
+
throw new Error(`Co-located twin "${service.id}" failed to start (host isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
|
|
1427
|
+
});
|
|
1428
|
+
});
|
|
1429
|
+
// The host's receipt (this boot, every requested port) is published only
|
|
1430
|
+
// after every factory bound its own port; a port another process held fails
|
|
1431
|
+
// its bind and leaves no receipt. The receipt is the host's ownership proof
|
|
1432
|
+
// for each port, so no twin waits out a settle (ADR-0008). A port that
|
|
1433
|
+
// answers with another boot's identity is still refused.
|
|
1434
|
+
const hostSettle = Promise.resolve('alive');
|
|
1435
|
+
await parallelStartup(allocated, async ({ service, port }) => {
|
|
1436
|
+
await confirmOwnPort(port, boot, ended, true, `Co-located twin "${service.id}"`, log, true, hostSettle).catch((error) => {
|
|
1437
|
+
if (undeadPids([child.pid]).length)
|
|
1438
|
+
recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
|
|
1439
|
+
try {
|
|
1440
|
+
process.kill(-child.pid, 'SIGTERM');
|
|
1441
|
+
}
|
|
1442
|
+
catch { /* best effort cleanup */ }
|
|
1443
|
+
throw error;
|
|
1444
|
+
});
|
|
1445
|
+
});
|
|
1446
|
+
if (exitState.value !== null) {
|
|
1447
|
+
throw new Error(`The co-located host ended during startup even though its ports answer, so other/stale processes probably hold them: the host ${endedAccount(log)}\n`
|
|
1448
|
+
+ `Stop leftover services (e.g. \`volter-world down\`) and retry.\nLog: ${log}`);
|
|
1449
|
+
}
|
|
1450
|
+
})();
|
|
1451
|
+
// Observed here so a failure before the caller awaits it is not unhandled;
|
|
1452
|
+
// the caller awaits `ready` before the World is up.
|
|
1453
|
+
ready.catch(() => undefined);
|
|
1454
|
+
return { services, ready };
|
|
1451
1455
|
}
|
|
1452
1456
|
export async function upWorld(configId, options = {}, abort) {
|
|
1453
1457
|
abort?.throwIfAborted();
|
|
@@ -1698,12 +1702,19 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1698
1702
|
const allocatedPorts = new Set(loaded.config.services.flatMap((service) => typeof service.port === 'number' ? [service.port] : []));
|
|
1699
1703
|
// Non-'process' isolation: boot every colocate-declaring service inside ONE host child
|
|
1700
1704
|
// first; anything without `colocate` (and externals) still goes through the spawn path.
|
|
1705
|
+
// The co-located host's twins are known by address once their ports are
|
|
1706
|
+
// allocated. A twin serves and calls no other service while it starts,
|
|
1707
|
+
// so a later twin declaration starts beside the host's startup; any other
|
|
1708
|
+
// service waits until every earlier one is ready, as declared (ADR-0009).
|
|
1709
|
+
let colocatedReady;
|
|
1701
1710
|
if (isolation !== 'process') {
|
|
1702
1711
|
const colocated = loaded.config.services.filter((service) => service.colocate && service.type !== 'external');
|
|
1703
|
-
|
|
1712
|
+
const host = await startColocatedServices(loaded.config, colocated, isolation, env, { root, instance, logs, data }, allocatedPorts, boot);
|
|
1713
|
+
for (const started of host.services) {
|
|
1704
1714
|
services[started.id] = started;
|
|
1705
1715
|
Object.assign(env, started.env);
|
|
1706
1716
|
}
|
|
1717
|
+
colocatedReady = host.ready;
|
|
1707
1718
|
recordPidsSoFar();
|
|
1708
1719
|
writeProxyEnv(root, name, env);
|
|
1709
1720
|
}
|
|
@@ -1736,6 +1747,10 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1736
1747
|
for (const service of loaded.config.services) {
|
|
1737
1748
|
if (services[service.id])
|
|
1738
1749
|
continue; // already up co-located
|
|
1750
|
+
if (colocatedReady && (service.type ?? 'twin') !== 'twin') {
|
|
1751
|
+
await colocatedReady;
|
|
1752
|
+
colocatedReady = undefined;
|
|
1753
|
+
}
|
|
1739
1754
|
attemptProxy();
|
|
1740
1755
|
boot.signal.throwIfAborted();
|
|
1741
1756
|
const started = await startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot);
|
|
@@ -1744,6 +1759,8 @@ export async function upWorld(configId, options = {}, abort) {
|
|
|
1744
1759
|
Object.assign(env, started.env);
|
|
1745
1760
|
writeProxyEnv(root, name, env);
|
|
1746
1761
|
}
|
|
1762
|
+
if (colocatedReady)
|
|
1763
|
+
await colocatedReady;
|
|
1747
1764
|
detachBootSignals();
|
|
1748
1765
|
// Re-evaluate once more after the LAST service: a single/last-twin world's OWN vendor twin
|
|
1749
1766
|
// only lands in `env` once IT has started, so a check that only runs "before starting the
|
package/dist/src/schema.d.ts
CHANGED
|
@@ -51,6 +51,7 @@ export type WorldManifestV2 = {
|
|
|
51
51
|
share?: WorldShareConfig;
|
|
52
52
|
};
|
|
53
53
|
remotes?: Record<string, string>;
|
|
54
|
+
changesets?: WorldChangesetRules;
|
|
54
55
|
scenario?: {
|
|
55
56
|
actors?: Record<string, unknown>;
|
|
56
57
|
fixtures?: Record<string, unknown>;
|
|
@@ -268,8 +269,17 @@ export type WorldServiceConfig = {
|
|
|
268
269
|
* catalog the world was born against. Exact version or `^major.minor` range. */
|
|
269
270
|
version?: string;
|
|
270
271
|
};
|
|
272
|
+
/** How this World's changesets are made and readied (docs/reference/config.md, `changesets`): how many people must
|
|
273
|
+
* approve one's current contents before it deploys, and whether a cut needs its author's summary or takes the one
|
|
274
|
+
* generated from its changes. A team's rules, committed with the World, as a repository's are. */
|
|
275
|
+
export type WorldChangesetRules = {
|
|
276
|
+
approvals?: number;
|
|
277
|
+
summary?: 'required' | 'generated';
|
|
278
|
+
};
|
|
271
279
|
export type WorldConfig = {
|
|
272
280
|
id: string;
|
|
281
|
+
/** How changesets are made and readied here; omitted: one approval, a summary required. */
|
|
282
|
+
changesets?: WorldChangesetRules;
|
|
273
283
|
/** Real external origins; omitted preserves legacy routing, [] explicitly closes egress. */
|
|
274
284
|
network?: WorldNetwork;
|
|
275
285
|
/** Internal normalized source format and saved discovery selection. */
|
|
@@ -452,3 +462,5 @@ export declare function assertWorldConfig(value: unknown, path: string): WorldCo
|
|
|
452
462
|
export declare function worldSelectionIncludes(selection: WorldSelection, vendor: string, usage: WorldUsage): boolean;
|
|
453
463
|
/** Serialize normalized intent as the committed format-2 manifest. */
|
|
454
464
|
export declare function worldConfigDocument(config: WorldConfig, selection?: WorldSelection): WorldManifestV2 & Record<string, unknown>;
|
|
465
|
+
/** A World's changeset rules, strictly: `approvals` a whole number 0–10, `summary` `required` or `generated`, nothing else. */
|
|
466
|
+
export declare function assertChangesetRules(rules: unknown, path: string): asserts rules is WorldChangesetRules;
|
package/dist/src/schema.js
CHANGED
|
@@ -132,6 +132,8 @@ function assertLegacyWorldConfig(value, path) {
|
|
|
132
132
|
if (typeof target !== 'string' || target === '' || !/^[A-Za-z0-9._-]+$/.test(name))
|
|
133
133
|
throw new Error(`World config remote "${name}" must be a non-empty url or path in ${path}`);
|
|
134
134
|
}
|
|
135
|
+
if (config.changesets !== undefined)
|
|
136
|
+
assertChangesetRules(config.changesets, path);
|
|
135
137
|
if (config.isolation !== undefined && config.isolation !== 'process' && config.isolation !== 'colocated' && config.isolation !== 'worker') {
|
|
136
138
|
throw new Error(`World config isolation must be "process", "colocated", or "worker" in ${path}`);
|
|
137
139
|
}
|
|
@@ -458,7 +460,7 @@ function serviceV2ToLegacy(value, path) {
|
|
|
458
460
|
return legacy;
|
|
459
461
|
}
|
|
460
462
|
function normalizeV2(value, path) {
|
|
461
|
-
assertKeys(value, ['schemaVersion', 'metadata', 'discovery', 'services', 'runtime', 'serving', 'remotes', 'scenario', 'provenance'], 'World manifest', path, true);
|
|
463
|
+
assertKeys(value, ['schemaVersion', 'metadata', 'discovery', 'services', 'runtime', 'serving', 'remotes', 'changesets', 'scenario', 'provenance'], 'World manifest', path, true);
|
|
462
464
|
for (const name of ['metadata', 'discovery', 'runtime', 'serving', 'scenario', 'provenance'])
|
|
463
465
|
if (value[name] !== undefined && !record(value[name]))
|
|
464
466
|
throw new Error(`${name} must be an object in ${path}`);
|
|
@@ -515,6 +517,7 @@ function normalizeV2(value, path) {
|
|
|
515
517
|
...(mode === 'bare' ? { bare: { name: serving.name } } : {}),
|
|
516
518
|
...(serving.share === undefined ? {} : { share: serving.share }),
|
|
517
519
|
...(value.remotes === undefined ? {} : { remotes: value.remotes }),
|
|
520
|
+
...(value.changesets === undefined ? {} : { changesets: value.changesets }),
|
|
518
521
|
...(scenario.actors === undefined ? {} : { actors: scenario.actors }),
|
|
519
522
|
...(scenario.fixtures === undefined ? {} : { fixtures: scenario.fixtures }),
|
|
520
523
|
...(provenance.catalog === undefined ? {} : { catalog: provenance.catalog }),
|
|
@@ -604,6 +607,7 @@ export function worldConfigDocument(config, selection = config.selection ?? DEFA
|
|
|
604
607
|
...(config.share === undefined ? {} : { share: config.share }),
|
|
605
608
|
} }),
|
|
606
609
|
...(config.remotes === undefined ? {} : { remotes: config.remotes }),
|
|
610
|
+
...(config.changesets === undefined ? {} : { changesets: config.changesets }),
|
|
607
611
|
...(config.actors === undefined && config.fixtures === undefined ? {} : { scenario: {
|
|
608
612
|
...(config.actors === undefined ? {} : { actors: config.actors }),
|
|
609
613
|
...(config.fixtures === undefined ? {} : { fixtures: config.fixtures }),
|
|
@@ -612,3 +616,16 @@ export function worldConfigDocument(config, selection = config.selection ?? DEFA
|
|
|
612
616
|
...Object.fromEntries(Object.entries(config).filter(([key]) => key.startsWith('//'))),
|
|
613
617
|
};
|
|
614
618
|
}
|
|
619
|
+
/** A World's changeset rules, strictly: `approvals` a whole number 0–10, `summary` `required` or `generated`, nothing else. */
|
|
620
|
+
export function assertChangesetRules(rules, path) {
|
|
621
|
+
if (typeof rules !== 'object' || rules === null || Array.isArray(rules))
|
|
622
|
+
throw new Error(`World config changesets must be an object in ${path}`);
|
|
623
|
+
for (const key of Object.keys(rules))
|
|
624
|
+
if (key !== 'approvals' && key !== 'summary')
|
|
625
|
+
throw new Error(`World config changesets has no "${key}" (approvals, summary) in ${path}`);
|
|
626
|
+
const { approvals, summary } = rules;
|
|
627
|
+
if (approvals !== undefined && !(Number.isInteger(approvals) && approvals >= 0 && approvals <= 10))
|
|
628
|
+
throw new Error(`World config changesets.approvals must be a whole number from 0 to 10 in ${path}`);
|
|
629
|
+
if (summary !== undefined && summary !== 'required' && summary !== 'generated')
|
|
630
|
+
throw new Error(`World config changesets.summary must be "required" or "generated" in ${path}`);
|
|
631
|
+
}
|
|
@@ -3,7 +3,7 @@ import { type TwinStreamConnection, type TwinStreamSink } from '@volter/world-co
|
|
|
3
3
|
import { type TrustedIssuer } from '@volter/world-access';
|
|
4
4
|
import { type Changeset, type Receipt } from '@volter/world-core';
|
|
5
5
|
import { rootForControlRoot } from './root.js';
|
|
6
|
-
import type
|
|
6
|
+
import { type WorldInstance } from './schema.js';
|
|
7
7
|
export declare const TOKEN_HEADER = "x-volter-token";
|
|
8
8
|
/** The header the injector carries a hosted World's token in (inject.cjs, VOLTER_TWINS_KEY). */
|
|
9
9
|
export declare const TWINS_KEY_HEADER = "x-twins-key";
|
|
@@ -63,7 +63,8 @@ export declare function mountWorld(name: string, opts?: {
|
|
|
63
63
|
root?: string; /** the host's branches of this World, by its served name */
|
|
64
64
|
branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */
|
|
65
65
|
browserOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */
|
|
66
|
-
localTrust?: boolean; /** DoorHost.
|
|
66
|
+
localTrust?: boolean; /** the person signed in on this machine: its own page acts as them (DoorHost.localPerson) */
|
|
67
|
+
localPerson?: string; /** DoorHost.serveBranch */
|
|
67
68
|
serveBranch?: (branch: string) => Promise<void>;
|
|
68
69
|
}): Promise<MountedWorld>;
|
|
69
70
|
/** Boot the world (state kept) and serve it on its own port. Announces and returns after boot; `stop` downs the world. */
|
|
@@ -138,6 +139,8 @@ export type DoorHost = {
|
|
|
138
139
|
* loopback name, is given the World's session without a token (`localSession`). A remote host leaves it absent,
|
|
139
140
|
* and its pages sign in with a token. */
|
|
140
141
|
localTrust?: () => boolean;
|
|
142
|
+
/** whom this machine's own page acts as: the person `volter login` signed in here, if any ("Who acted is recorded") */
|
|
143
|
+
localPerson?: () => string | undefined;
|
|
141
144
|
/** The platforms whose passes open this World (docs/contributing/architecture.md, "The hosted product"): a person a
|
|
142
145
|
* trusted platform signed a pass for is given a session of the pass's scope. Absent, or empty, no pass opens it. */
|
|
143
146
|
passIssuers?: () => TrustedIssuer[];
|
|
@@ -220,6 +223,9 @@ export declare class WorldDoors {
|
|
|
220
223
|
* revoke every key made by or for a person (their leaving the org). */
|
|
221
224
|
/** The person a key is for: whom it was made for, else the person whose session made it. */
|
|
222
225
|
private keyPerson;
|
|
226
|
+
/** The person a key acts for, as attribution names them ("Who acted is recorded"): the name it was made for, else the
|
|
227
|
+
* person whose session made it, as their session names them (a pass's email, this machine's person). */
|
|
228
|
+
private keyName;
|
|
223
229
|
/** Revoke the keys `pick` names, and what they reach: the branches whose parent key goes, and the branches (with their
|
|
224
230
|
* keys) a revoked key made. Answers the revoked keys' ids. */
|
|
225
231
|
private revokeKeys;
|
|
@@ -276,6 +282,10 @@ export declare class WorldDoors {
|
|
|
276
282
|
* (`person` when it names no one), `token` / `token:read` for the World's own tokens; null when it presented nothing
|
|
277
283
|
* this World issued. */
|
|
278
284
|
private callerOf;
|
|
285
|
+
/** The person a request acts for: a session's person, or the person a key is for; none for this machine's page or a
|
|
286
|
+
* World's own token. What lands in a World others read must name one. */
|
|
287
|
+
private personOf;
|
|
288
|
+
private keyOf;
|
|
279
289
|
/** The session cookie's name where `url` reached this World, or null where a session opens nothing:
|
|
280
290
|
* on the World's own origin a host-only cookie (`__Host-` over https); without an origin of its own,
|
|
281
291
|
* the path-scoped cookie a shared origin has always carried. */
|