@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/served-world.js
CHANGED
|
@@ -31,13 +31,14 @@ import { APP_READ_ENDPOINT_ENV } from "./init.js";
|
|
|
31
31
|
import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass } from '@volter/world-access';
|
|
32
32
|
import { createHash } from 'node:crypto';
|
|
33
33
|
import { dirname, join, resolve } from 'node:path';
|
|
34
|
-
import { appendActionIfAbsent, branchEntries, branchLogPath, branchMetaPath, changesetContentHash, confirmAction, parentLogPath, readTree, stateDirName, wholeLog, worldNow, parentEntries, readBranchMeta, rebaseBranch, } from '@volter/world-core';
|
|
34
|
+
import { appendActionIfAbsent, branchEntries, branchLogPath, branchMetaPath, changesetContentHash, changesetReadiness, confirmAction, parentLogPath, readTree, stateDirName, wholeLog, worldNow, parentEntries, readBranchMeta, rebaseBranch, } from '@volter/world-core';
|
|
35
35
|
import { approveWorldChangeset, createWorldChangeset, diffWorld, findWorldChangeset, listWorldChangesets, pushWorldChangeset, verifyWorldChangeset, worldChangesetsDir } from "./changeset.js";
|
|
36
36
|
import { fetchFromOrigin } from "./origin.js";
|
|
37
37
|
import { CONSOLE_BASE, consoleRedirect, serveConsoleApart } from "./console-apart.js";
|
|
38
|
-
import { loadWorldConfig } from "./configs.js";
|
|
38
|
+
import { loadWorldConfig, worldChangesetRules, writeWorldConfig } from "./configs.js";
|
|
39
39
|
import { adaptersFor, credentialPath, packMirror, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, rewrapTwinCredentials, sealTwinCredential, sealedCredentialInfo, bindLegacyCredentials, setTwinRoot, spanMs } from "./root.js";
|
|
40
40
|
import { clockFile, downWorld, listWorlds, statusWorld, upWorld } from "./runtime.js";
|
|
41
|
+
import { assertChangesetRules } from "./schema.js";
|
|
41
42
|
export const TOKEN_HEADER = 'x-volter-token';
|
|
42
43
|
/** The header the injector carries a hosted World's token in (inject.cjs, VOLTER_TWINS_KEY). */
|
|
43
44
|
export const TWINS_KEY_HEADER = 'x-twins-key';
|
|
@@ -142,7 +143,7 @@ export async function mountWorld(name, opts = {}) {
|
|
|
142
143
|
clockPath: () => clockFile(worldRoot, name),
|
|
143
144
|
...(opts.branches?.(served) ? { branches: opts.branches(served) } : {}),
|
|
144
145
|
...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin(served), originOf: (other) => opts.browserOrigin(other) } : {}),
|
|
145
|
-
...(opts.localTrust ? { localTrust: () => true } : {}),
|
|
146
|
+
...(opts.localTrust ? { localTrust: () => true, ...(opts.localPerson ? { localPerson: () => opts.localPerson } : {}) } : {}),
|
|
146
147
|
...(opts.serveBranch ? { serveBranch: opts.serveBranch } : {}),
|
|
147
148
|
...(opts.passIssuers?.length ? { passIssuers: () => opts.passIssuers } : {}),
|
|
148
149
|
// a local twin is its own process: the wire forwards to the port it listens on
|
|
@@ -423,7 +424,7 @@ export class WorldDoors {
|
|
|
423
424
|
}
|
|
424
425
|
/** A new session of `scope`: its id, the cookie's value. A token's lasts 30 days; a person's from a pass, `who`,
|
|
425
426
|
* lasts as long as the pass's platform says (twelve hours), so a person removed there loses it within the day. */
|
|
426
|
-
openSession(scope, person, key) {
|
|
427
|
+
openSession(scope, person, key, local) {
|
|
427
428
|
const id = mintToken('ses_');
|
|
428
429
|
let held = this.sessions();
|
|
429
430
|
// a person holds at most five sessions here: passes (anyone's with one) never crowd out everyone else's
|
|
@@ -435,7 +436,7 @@ export class WorldDoors {
|
|
|
435
436
|
}
|
|
436
437
|
}
|
|
437
438
|
const until = Math.min(Date.now() + (person?.ttlMs ?? 30 * 86_400_000), key?.until ?? Infinity);
|
|
438
|
-
this.keepSessions({ ...held, [id]: { scope, until, ...(person ? { who: person.who, issuer: person.issuer, ...(person.sub ? { sub: person.sub } : {}) } : {}), ...(key ? { key: key.id } : {}) } });
|
|
439
|
+
this.keepSessions({ ...held, [id]: { scope, until, ...(local ? { local: true } : {}), ...(person ? { who: person.who, ...(person.issuer ? { issuer: person.issuer } : {}), ...(person.sub ? { sub: person.sub } : {}) } : {}), ...(key ? { key: key.id } : {}) } });
|
|
439
440
|
return id;
|
|
440
441
|
}
|
|
441
442
|
/** NAMED KEYS: what an app or a script holds (docs/contributing/architecture.md, "The hosted product"), each named
|
|
@@ -466,6 +467,9 @@ export class WorldDoors {
|
|
|
466
467
|
* revoke every key made by or for a person (their leaving the org). */
|
|
467
468
|
/** The person a key is for: whom it was made for, else the person whose session made it. */
|
|
468
469
|
keyPerson(k) { return k.for ?? (k.createdBy?.via === 'session' ? k.createdBy.sub : undefined); }
|
|
470
|
+
/** The person a key acts for, as attribution names them ("Who acted is recorded"): the name it was made for, else the
|
|
471
|
+
* person whose session made it, as their session names them (a pass's email, this machine's person). */
|
|
472
|
+
keyName(k) { return k.forName ?? k.for ?? (k.createdBy?.via === 'session' ? k.createdBy.who ?? k.createdBy.sub : undefined); }
|
|
469
473
|
/** Revoke the keys `pick` names, and what they reach: the branches whose parent key goes, and the branches (with their
|
|
470
474
|
* keys) a revoked key made. Answers the revoked keys' ids. */
|
|
471
475
|
async revokeKeys(pick) {
|
|
@@ -491,7 +495,7 @@ export class WorldDoors {
|
|
|
491
495
|
return Response.json({ error: 'a key manages no keys: use the World\'s token, or its page' }, { status: 403 });
|
|
492
496
|
const now = Date.now();
|
|
493
497
|
const live = () => this.keys().filter((k) => !k.expiresAt || Date.parse(k.expiresAt) > now);
|
|
494
|
-
const view = (k) => ({ id: k.id, name: k.name, scope: k.scope, createdAt: k.createdAt, lastUsedAt: k.lastUsedAt ?? null, expiresAt: k.expiresAt ?? null, for: k.for ?? null, person: this.
|
|
498
|
+
const view = (k) => ({ id: k.id, name: k.name, scope: k.scope, createdAt: k.createdAt, lastUsedAt: k.lastUsedAt ?? null, expiresAt: k.expiresAt ?? null, for: k.for ?? null, person: this.keyName(k) ?? null, branch: k.branch ?? null, createdBy: k.createdBy?.via === 'session' ? (k.createdBy.who ?? k.createdBy.sub ?? 'a person') : k.createdBy?.via === 'key' ? `the key ${k.createdBy.keyId}` : k.createdBy ? 'the World\'s token' : null });
|
|
495
499
|
const person = (k) => this.keyPerson(k);
|
|
496
500
|
if (!id && request.method === 'GET')
|
|
497
501
|
return Response.json({ keys: live().map(view) });
|
|
@@ -500,6 +504,9 @@ export class WorldDoors {
|
|
|
500
504
|
const name = typeof body.name === 'string' ? body.name.trim().slice(0, 80) : '';
|
|
501
505
|
if (!name)
|
|
502
506
|
return Response.json({ error: 'a key needs a name: what holds it (an app, a CI job, a laptop)' }, { status: 400 });
|
|
507
|
+
// a key's name is what holds it, never whom it is for: the caller a World records reads `key:<name> for:<person>`
|
|
508
|
+
if (/\sfor:/i.test(name))
|
|
509
|
+
return Response.json({ error: 'a key\'s name says what holds it; whom it is for is its own field' }, { status: 400 });
|
|
503
510
|
const keyScope = body.scope === 'read' ? 'read' : 'write';
|
|
504
511
|
// a session a platform's pass opened makes no keys: a key outlives the session, and a script on this origin (a
|
|
505
512
|
// stored page) could make one in the person's name. Keys for a platform's Worlds are made on the platform.
|
|
@@ -507,9 +514,10 @@ export class WorldDoors {
|
|
|
507
514
|
if (opener?.issuer)
|
|
508
515
|
return Response.json({ error: 'a key for this World is made on the platform it was opened from, not from a browser session here', platform: opener.issuer }, { status: 403 });
|
|
509
516
|
// `for`, `replace` and an end are the token's to give (a platform making a command's key for a person)
|
|
510
|
-
if ((body.for !== undefined || body.replace !== undefined) && via !== 'token')
|
|
517
|
+
if ((body.for !== undefined || body.forName !== undefined || body.replace !== undefined) && via !== 'token')
|
|
511
518
|
return Response.json({ error: 'for and replace are the World token\'s' }, { status: 403 });
|
|
512
519
|
const forWhom = typeof body.for === 'string' && /^[A-Za-z0-9:._@-]{1,160}$/.test(body.for) ? body.for : undefined;
|
|
520
|
+
const forName = forWhom && typeof body.forName === 'string' && /^[A-Za-z0-9:._@+ -]{1,160}$/.test(body.forName) ? body.forName : undefined;
|
|
513
521
|
let expiresAt;
|
|
514
522
|
if (body.expiresAt !== undefined) {
|
|
515
523
|
const t = typeof body.expiresAt === 'string' ? Date.parse(body.expiresAt) : NaN;
|
|
@@ -526,7 +534,7 @@ export class WorldDoors {
|
|
|
526
534
|
const opened = via === 'session' && session ? this.sessions()[session] : undefined;
|
|
527
535
|
const createdBy = via === 'session' ? { via: 'session', ...(opened?.sub ? { sub: opened.sub } : {}), ...(opened?.who ? { who: opened.who } : {}), ...(opened?.issuer ? { issuer: opened.issuer } : {}) } : { via: 'token' };
|
|
528
536
|
const key = mintToken('tok_k_');
|
|
529
|
-
const held = { id: mintToken('key_').slice(0, 20), name, scope: keyScope, hash: keyHash(key), createdAt: new Date(now).toISOString(), createdBy, ...(forWhom ? { for: forWhom } : {}), ...(expiresAt ? { expiresAt } : {}) };
|
|
537
|
+
const held = { id: mintToken('key_').slice(0, 20), name, scope: keyScope, hash: keyHash(key), createdAt: new Date(now).toISOString(), createdBy, ...(forWhom ? { for: forWhom } : {}), ...(forName ? { forName } : {}), ...(expiresAt ? { expiresAt } : {}) };
|
|
530
538
|
this.keepKeys([...kept, held]);
|
|
531
539
|
return Response.json({ ...view(held), key }, { status: 201, headers: { 'cache-control': 'no-store' } });
|
|
532
540
|
}
|
|
@@ -692,15 +700,32 @@ export class WorldDoors {
|
|
|
692
700
|
const cred = this.credential(request);
|
|
693
701
|
if (cred.scope === null)
|
|
694
702
|
return null;
|
|
703
|
+
// this machine's own page with no one signed in here is this machine; another session with no name is just that
|
|
695
704
|
if (cred.via === 'session')
|
|
696
|
-
return cred.who ? `person:${cred.who}` : 'person';
|
|
705
|
+
return cred.who ? `person:${cred.who}` : cred.session && this.sessions()[cred.session]?.local ? 'machine' : 'person';
|
|
706
|
+
// a key acts as itself, for its person (docs/contributing/architecture.md, "Who acted is recorded")
|
|
697
707
|
if (cred.via === 'key') {
|
|
698
|
-
const
|
|
699
|
-
const
|
|
700
|
-
return held ? `key:${held.name}` : 'key';
|
|
708
|
+
const held = this.keyOf(cred.token);
|
|
709
|
+
const person = held ? this.keyName(held) : undefined;
|
|
710
|
+
return held ? `key:${held.name}${person ? ` for:${person}` : ''}` : 'key';
|
|
701
711
|
}
|
|
702
712
|
return cred.scope === 'read' ? 'token:read' : 'token';
|
|
703
713
|
}
|
|
714
|
+
/** 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
|
|
715
|
+
* World's own token. What lands in a World others read must name one. */
|
|
716
|
+
personOf(request) {
|
|
717
|
+
const cred = this.credential(request);
|
|
718
|
+
if (cred.scope === null)
|
|
719
|
+
return undefined;
|
|
720
|
+
if (cred.via === 'session')
|
|
721
|
+
return cred.who;
|
|
722
|
+
if (cred.via === 'key') {
|
|
723
|
+
const held = this.keyOf(cred.token);
|
|
724
|
+
return held ? this.keyName(held) : undefined;
|
|
725
|
+
}
|
|
726
|
+
return undefined;
|
|
727
|
+
}
|
|
728
|
+
keyOf(token) { return token ? this.keys().find((k) => sameSecret(keyHash(token), k.hash)) : undefined; }
|
|
704
729
|
/** The session cookie's name where `url` reached this World, or null where a session opens nothing:
|
|
705
730
|
* on the World's own origin a host-only cookie (`__Host-` over https); without an origin of its own,
|
|
706
731
|
* the path-scoped cookie a shared origin has always carried. */
|
|
@@ -1225,11 +1250,13 @@ export class WorldDoors {
|
|
|
1225
1250
|
// with the body's hash as it stands, so a reader sees an approval or a verification made on other bytes as stale
|
|
1226
1251
|
if (name) {
|
|
1227
1252
|
const l = findWorldChangeset(name, { root: this.worldRoot, world: this.name });
|
|
1228
|
-
return Response.json({ ...l.changeset, currentHash: changesetContentHash(l.changeset), deployed: this.deployedOf([l.changeset])[0] });
|
|
1253
|
+
return Response.json({ ...l.changeset, currentHash: changesetContentHash(l.changeset), deployed: this.deployedOf([l.changeset])[0], readiness: changesetReadiness(l.changeset, worldChangesetRules(this.worldRoot)) });
|
|
1229
1254
|
}
|
|
1230
1255
|
const all = listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => l.changeset);
|
|
1231
1256
|
const deployed = this.deployedOf(all);
|
|
1232
|
-
|
|
1257
|
+
const rules = worldChangesetRules(this.worldRoot);
|
|
1258
|
+
// readiness under this World's rules, as deploy reads it: the console says what the kernel decides
|
|
1259
|
+
return Response.json({ changesets: all.map((c, i) => ({ ...c, currentHash: changesetContentHash(c), deployed: deployed[i], readiness: changesetReadiness(c, rules) })) });
|
|
1233
1260
|
}
|
|
1234
1261
|
if (kind === 'changesets' && name && verb === 'verify' && request.method === 'POST') {
|
|
1235
1262
|
const outcome = await this.serialized('changesets', () => verifyWorldChangeset(name, { root: this.worldRoot, world: this.name, ephemeral: true }));
|
|
@@ -1243,7 +1270,8 @@ export class WorldDoors {
|
|
|
1243
1270
|
const held = via === 'session' ? this.sessions()[this.credential(request).session ?? ''] : undefined;
|
|
1244
1271
|
const named = typeof body.as === 'string' && body.as.trim() ? body.as.trim().slice(0, 80) : undefined;
|
|
1245
1272
|
const tokenSession = held !== undefined && !held.who && held.key === undefined && held.scope === 'write';
|
|
1246
|
-
|
|
1273
|
+
// a key approves as itself, for its person, whatever it says: it cannot be several approvers by naming several
|
|
1274
|
+
const principal = via === 'session' ? held?.who ?? (this.host.localTrust?.() ? 'this machine' : tokenSession ? named : undefined) : via === 'key' ? this.callerOf(request) ?? undefined : named;
|
|
1247
1275
|
if (!principal)
|
|
1248
1276
|
return Response.json({ error: via === 'session' && !tokenSession ? 'this session names no person to sign as: open the World from its platform, or approve with its token' : 'approve: { as, note? } — name who approves' }, { status: via === 'session' && !tokenSession ? 403 : 400 });
|
|
1249
1277
|
return Response.json(approveWorldChangeset(name, { root: this.worldRoot, world: this.name, principal, ...(body.note ? { note: body.note } : {}) }));
|
|
@@ -1381,11 +1409,53 @@ export class WorldDoors {
|
|
|
1381
1409
|
}));
|
|
1382
1410
|
}
|
|
1383
1411
|
}
|
|
1412
|
+
// THE RULES DOOR: how this World's changesets are made and readied (`changesets` in its committed config). Read with any
|
|
1413
|
+
// access; changed by a person, this machine or the World's token, never by a key: a key's holder (an app, an agent)
|
|
1414
|
+
// does not relax the review its own changes go through
|
|
1415
|
+
if (kind === 'rules' && !name) {
|
|
1416
|
+
const path = join(this.worldRoot, stateDirName(), 'world.json');
|
|
1417
|
+
if (request.method === 'GET')
|
|
1418
|
+
return Response.json({ world: this.served, changesets: worldChangesetRules(this.worldRoot) });
|
|
1419
|
+
if (request.method === 'PUT') {
|
|
1420
|
+
if (scope !== 'write')
|
|
1421
|
+
return Response.json({ error: 'the rules are changed with write access' }, { status: 403 });
|
|
1422
|
+
if (via === 'key')
|
|
1423
|
+
return Response.json({ error: 'a key changes no rules: a person sets them, or the World\'s token' }, { status: 403 });
|
|
1424
|
+
const body = (await request.json().catch(() => null));
|
|
1425
|
+
try {
|
|
1426
|
+
assertChangesetRules(body?.changesets, path);
|
|
1427
|
+
}
|
|
1428
|
+
catch (error) {
|
|
1429
|
+
return Response.json({ error: error instanceof Error ? error.message.replace(` in ${path}`, '') : String(error) }, { status: 400 });
|
|
1430
|
+
}
|
|
1431
|
+
const { config } = loadWorldConfig(path, this.worldRoot);
|
|
1432
|
+
const rules = body.changesets;
|
|
1433
|
+
const next = { ...config, changesets: { ...(rules.approvals === undefined ? {} : { approvals: rules.approvals }), ...(rules.summary === undefined ? {} : { summary: rules.summary }) } };
|
|
1434
|
+
writeWorldConfig(path, next);
|
|
1435
|
+
return Response.json({ world: this.served, changesets: next.changesets });
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1384
1438
|
if (kind === 'changesets' && !name && request.method === 'POST') {
|
|
1385
1439
|
const body = (await request.json().catch(() => ({})));
|
|
1386
1440
|
const slug = body.message ? body.message.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) : '';
|
|
1387
|
-
|
|
1388
|
-
|
|
1441
|
+
// with no summary to name it, the next free `changeset-<n>`, as the command names one
|
|
1442
|
+
const taken = new Set(listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => l.changeset.name));
|
|
1443
|
+
let n = taken.size + 1;
|
|
1444
|
+
while (taken.has(`changeset-${n}`))
|
|
1445
|
+
n += 1;
|
|
1446
|
+
const cut = body.name ?? (slug || `changeset-${n}`);
|
|
1447
|
+
// a cut says what it does and why, unless this World's rules take the summary generated from its changes
|
|
1448
|
+
if (!body.message?.trim() && worldChangesetRules(this.worldRoot).summary !== 'generated')
|
|
1449
|
+
return Response.json({ error: 'a cut needs its summary: what it does and why' }, { status: 400 });
|
|
1450
|
+
const cutBy = this.callerOf(request) ?? undefined;
|
|
1451
|
+
const owner = this.personOf(request);
|
|
1452
|
+
return await this.serialized('push', async () => {
|
|
1453
|
+
// with no summary, a cut must have something to say: an empty one is a slip, refused as the command refuses it
|
|
1454
|
+
// (inside the lock, so two cuts at once never both take the same changes)
|
|
1455
|
+
if (!body.message?.trim() && diffWorld(this.name, { root: this.worldRoot }).actions.length === 0)
|
|
1456
|
+
return Response.json({ error: 'nothing to cut: every change is already in a changeset' }, { status: 400 });
|
|
1457
|
+
return Response.json(createWorldChangeset(this.name, cut, { root: this.worldRoot, ...(body.message?.trim() ? { message: body.message.trim() } : {}), ...(cutBy ? { cutBy } : {}), ...(owner ? { owner } : {}) }));
|
|
1458
|
+
});
|
|
1389
1459
|
}
|
|
1390
1460
|
// THE BLOBS DOOR: a twin's resource payloads (uploaded media, file bytes) by their key under its
|
|
1391
1461
|
// resources directory. A push sends the ones its branch holds and this World lacks before the
|
|
@@ -1573,7 +1643,9 @@ export class WorldDoors {
|
|
|
1573
1643
|
const own = this.host.browserOrigin?.() ?? null;
|
|
1574
1644
|
if (this.sessionCookieName(url) === null)
|
|
1575
1645
|
return Response.json({ error: `${this.served}'s browser session lives at its own origin`, origin: own }, { status: 409 });
|
|
1576
|
-
|
|
1646
|
+
// this machine's page acts as the person signed in here, when one is (`volter login`); else as this machine
|
|
1647
|
+
const person = this.host.localPerson?.();
|
|
1648
|
+
return Response.json({ world: this.served, scope: 'write', local: true, ...(person ? { who: person } : {}), ...(own ? { origin: own } : {}) }, { headers: { 'set-cookie': this.sessionCookie(url, this.openSession('write', person ? { who: person, ttlMs: 30 * 86_400_000 } : undefined, undefined, true)) } });
|
|
1577
1649
|
}
|
|
1578
1650
|
session(request, url, scope, key) {
|
|
1579
1651
|
const own = this.host.browserOrigin?.() ?? null;
|
|
@@ -1859,10 +1931,18 @@ export class WorldDoors {
|
|
|
1859
1931
|
}
|
|
1860
1932
|
/** PUSH: see `landChangeset` — the door hands the body to it. */
|
|
1861
1933
|
async push(request) {
|
|
1934
|
+
// who pushed it is recorded, a person or a service (a key, the World's token): an automation pushes as itself
|
|
1935
|
+
// (docs/contributing/architecture.md, "Who acted is recorded")
|
|
1936
|
+
const person = this.personOf(request);
|
|
1862
1937
|
const body = (await request.json());
|
|
1863
|
-
const
|
|
1864
|
-
if (!
|
|
1938
|
+
const sent = 'changeset' in body && !('actions' in body) ? body.changeset : body;
|
|
1939
|
+
if (!sent || !Array.isArray(sent.actions))
|
|
1865
1940
|
return Response.json({ error: 'push: a changeset object' }, { status: 400 });
|
|
1941
|
+
// who pushed it, and its owner when it came with none and the pusher names a person: outside its hash, so nothing an
|
|
1942
|
+
// approval bound to moves
|
|
1943
|
+
const pushedBy = this.callerOf(request) ?? undefined;
|
|
1944
|
+
const owner = sent.owner ?? person;
|
|
1945
|
+
const changeset = { ...sent, ...(owner ? { owner } : {}), ...(pushedBy ? { pushedBy } : {}) };
|
|
1866
1946
|
this.refresh();
|
|
1867
1947
|
// fast-forward only: the client says where it last saw each twin's log (its fetch cursor); a
|
|
1868
1948
|
// twin that moved past it refuses with the drift — fetch, rebase, push again
|
|
@@ -1872,7 +1952,14 @@ export class WorldDoors {
|
|
|
1872
1952
|
const receipts = await landChangeset(this.name, this.worldRoot, this.up(), changeset, expect);
|
|
1873
1953
|
const position = Object.fromEntries([...new Set(changeset.actions.map((a) => a.service))].map((twin) => [twin, positionOf(twin)]));
|
|
1874
1954
|
const snapshots = Object.fromEntries(Object.keys(position).map(twin => { const head = captureHistory(this.stateOf(twin), this.controlRoot(twin)); return [twin, { view: head.view, position: head.position }]; }));
|
|
1875
|
-
|
|
1955
|
+
// who owns and pushed it, as this World keeps it: a changeset landed before keeps its first record
|
|
1956
|
+
const kept = (() => { try {
|
|
1957
|
+
return JSON.parse(getActiveWorldStore().read(join(worldChangesetsDir(this.name, this.worldRoot), `${changeset.name}.json`)) ?? 'null');
|
|
1958
|
+
}
|
|
1959
|
+
catch {
|
|
1960
|
+
return null;
|
|
1961
|
+
} })();
|
|
1962
|
+
return Response.json({ world: this.served, changeset: changeset.name, receipts, position, snapshots, ...(kept?.owner ? { owner: kept.owner } : {}), ...(kept?.pushedBy ? { pushedBy: kept.pushedBy } : {}) });
|
|
1876
1963
|
}
|
|
1877
1964
|
catch (error) {
|
|
1878
1965
|
return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 });
|
package/dist/src/world-view.d.ts
CHANGED
package/dist/src/world-view.js
CHANGED
|
@@ -48,7 +48,7 @@ export async function serveWorldView(name, opts = {}) {
|
|
|
48
48
|
// (the first World mounted is the World; every later one is a branch of it)
|
|
49
49
|
let base = null;
|
|
50
50
|
const browserOrigin = origins ? (served) => (proxied && (base === null || served === base) ? proxied.origin : port ? localWorldOrigin(served, port) : null) : undefined;
|
|
51
|
-
const mountAt = (at) => mountWorld(loadWorldConfig(join(at, stateDirName(), 'world.json'), at).config.id, { root: at, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}) });
|
|
51
|
+
const mountAt = (at) => mountWorld(loadWorldConfig(join(at, stateDirName(), 'world.json'), at).config.id, { root: at, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}) });
|
|
52
52
|
const branches = new LocalBranches({ dir: join(root, stateDirName(), 'branches'), origin: () => loopback, worlds, mount: mountAt, ...(opts.announce ? { announce: opts.announce } : {}) });
|
|
53
53
|
// THE WORLD'S OWN BRANCHES, one served at a time: serving another stops this one's twins and starts that one's under the
|
|
54
54
|
// same served name (its tokens and sessions are the World's, so the page stays open); `.volter/current` follows, as
|
|
@@ -90,7 +90,7 @@ export async function serveWorldView(name, opts = {}) {
|
|
|
90
90
|
switching = false;
|
|
91
91
|
}
|
|
92
92
|
};
|
|
93
|
-
const mountOpts = { root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}), serveBranch };
|
|
93
|
+
const mountOpts = { root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}), serveBranch };
|
|
94
94
|
let world = await mountWorld(name, mountOpts);
|
|
95
95
|
base = world.served;
|
|
96
96
|
worlds.set(world.served, world);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.51",
|
|
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",
|
|
@@ -67,14 +67,14 @@
|
|
|
67
67
|
"node": ">=22.3"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"@volter/world-core": "2.0.
|
|
71
|
-
"@volter/world-console": "2.0.
|
|
70
|
+
"@volter/world-core": "2.0.33",
|
|
71
|
+
"@volter/world-console": "2.0.14"
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
74
|
"@electric-sql/pglite": "0.5.8",
|
|
75
75
|
"@electric-sql/pglite-pgvector": "0.0.9",
|
|
76
76
|
"@volter/world-access": "2.0.2",
|
|
77
|
-
"@volter/world-core": "2.0.
|
|
77
|
+
"@volter/world-core": "2.0.33",
|
|
78
78
|
"pg-gateway": "0.3.0-beta.4",
|
|
79
79
|
"smol-toml": "^1.8.0"
|
|
80
80
|
},
|
|
@@ -84,6 +84,6 @@
|
|
|
84
84
|
}
|
|
85
85
|
},
|
|
86
86
|
"optionalDependencies": {
|
|
87
|
-
"@volter/twin-mongodb": "0.1.
|
|
87
|
+
"@volter/twin-mongodb": "0.1.33"
|
|
88
88
|
}
|
|
89
89
|
}
|
package/src/changeset.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { worldChangesetRules } from './configs.ts';
|
|
1
2
|
import { blobDigest, captureHistory, captureParentHistory, getActiveBlobStore, getActiveWorldStore, originHead, withAncestryLock, worldPaths, type HistoryReference } 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
|
|
@@ -202,7 +203,7 @@ export type ChangesetLocation = { changeset: Changeset; world: string; path: str
|
|
|
202
203
|
export function createWorldChangeset(
|
|
203
204
|
world: string,
|
|
204
205
|
name: string,
|
|
205
|
-
options: WorldRootOptions & { base?: string; verifiers?: ChangesetVerifier[]; now?: Date; overwrite?: boolean; message?: string } = {},
|
|
206
|
+
options: WorldRootOptions & { base?: string; verifiers?: ChangesetVerifier[]; now?: Date; overwrite?: boolean; message?: string; /** who cuts it and the person it is owned by, from the cutting grant (a served World's door) */ cutBy?: string; owner?: string } = {},
|
|
206
207
|
): Changeset {
|
|
207
208
|
const root = resolveRoot(options.root);
|
|
208
209
|
assertSafeChangesetName(name);
|
|
@@ -227,6 +228,8 @@ export function createWorldChangeset(
|
|
|
227
228
|
...(options.verifiers ? { verifiers: options.verifiers } : {}),
|
|
228
229
|
...(options.now ? { createdAt: options.now.toISOString() } : {}),
|
|
229
230
|
...(options.message === undefined ? {} : { message: options.message }),
|
|
231
|
+
...(options.cutBy === undefined ? {} : { cutBy: options.cutBy }),
|
|
232
|
+
...(options.owner === undefined ? {} : { owner: options.owner }),
|
|
230
233
|
});
|
|
231
234
|
getActiveWorldStore().write(path, `${JSON.stringify(changeset, null, 2)}\n`);
|
|
232
235
|
// a changeset records where the log was cut (contract "The model"): the next one starts here
|
|
@@ -447,7 +450,7 @@ export function statusWorldChangeset(
|
|
|
447
450
|
): ChangesetReadiness {
|
|
448
451
|
const root = resolveRoot(options.root);
|
|
449
452
|
const located = findWorldChangeset(name, { root, ...(options.world ? { world: options.world } : {}) });
|
|
450
|
-
return changesetReadiness(located.changeset);
|
|
453
|
+
return changesetReadiness(located.changeset, worldChangesetRules(root));
|
|
451
454
|
}
|
|
452
455
|
|
|
453
456
|
// ── governed push: `volter-world changeset push` ─────────────────────────────────────────
|
|
@@ -481,7 +484,7 @@ export async function pushWorldChangeset(name: string, options: PushWorldOptions
|
|
|
481
484
|
const namespace = options.namespace ?? instance.origin?.namespace;
|
|
482
485
|
if (!url || !namespace) throw new Error(`World "${located.world}" has no origin to push to — \`volter remote add origin <url> --token <token>\` names one`);
|
|
483
486
|
// over http to a served world's push door, or straight into a world at a path (git's file transport)
|
|
484
|
-
let res: { ok: boolean; status: number }; let body: { receipts?: Array<{ actionId: string; twin: string } & Receipt>; error?: string; refusal?: string[]; position?: Record<string, number>; snapshots?: Record<string, HistoryReference
|
|
487
|
+
let res: { ok: boolean; status: number }; let body: { receipts?: Array<{ actionId: string; twin: string } & Receipt>; error?: string; refusal?: string[]; position?: Record<string, number>; snapshots?: Record<string, HistoryReference>; /** whom the remote records as its owner and as who pushed it */ owner?: string; pushedBy?: string };
|
|
485
488
|
const expect: Record<string, HistoryReference> = {};
|
|
486
489
|
for (const a of located.changeset.actions) {
|
|
487
490
|
const meta = readBranchMeta(a.stateService ?? a.service, join(instance.dirs.data, a.service));
|
|
@@ -597,7 +600,8 @@ export async function pushWorldChangeset(name: string, options: PushWorldOptions
|
|
|
597
600
|
const receipts: ApplyReceipt[] = 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 } : {}) }));
|
|
598
601
|
const outcome: ChangesetApplication['outcome'] = receipts.every((r) => r.status !== 'failed') ? 'applied' : receipts.some((r) => r.status === 'confirmed') ? 'partial' : 'refused';
|
|
599
602
|
const application: ChangesetApplication = { at, contentHash: located.changeset.contentHash, outcome, receipts, compensation: [], ...(outcome === 'refused' ? { refusal: receipts.filter((r) => r.error).map((r) => r.error!) } : {}) };
|
|
600
|
-
|
|
603
|
+
// the remote's record of who pushed it and who owns it, kept on this copy too (outside the hash)
|
|
604
|
+
const changeset = { ...withApplication(located.changeset, application), ...(body.owner && !located.changeset.owner ? { owner: body.owner } : {}), ...(body.pushedBy ? { pushedBy: body.pushedBy } : {}) };
|
|
601
605
|
writeChangesetFile(located.path, changeset);
|
|
602
606
|
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;
|
|
603
607
|
return { ...(unsent ? { unsent } : {}), application, changeset, world: located.world, path: located.path, remote: { url, namespace } };
|
package/src/configs.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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';
|
|
4
|
-
import { assertWorldConfig, LEGACY_WORLD_SELECTION, worldConfigDocument, type WorldConfig } from './schema.ts';
|
|
2
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { getActiveWorldStore, stateDirName } from '@volter/world-core';
|
|
4
|
+
import { assertWorldConfig, LEGACY_WORLD_SELECTION, worldConfigDocument, type WorldChangesetRules, type WorldConfig } from './schema.ts';
|
|
5
5
|
|
|
6
6
|
export function resolveConfigPath(config: string, root = process.cwd()): string {
|
|
7
7
|
const candidates = config.includes('/') || config.endsWith('.json')
|
|
@@ -108,3 +108,11 @@ export function migrateWorldConfig(config: string, root = process.cwd()): WorldC
|
|
|
108
108
|
dropped: normalized.resources === undefined ? [] : ['resources (deprecated; retained in backup)'],
|
|
109
109
|
};
|
|
110
110
|
}
|
|
111
|
+
|
|
112
|
+
/** The changeset rules of the World rooted at `root` (its committed `.volter/world.json`, `changesets`); none when it
|
|
113
|
+
* has no config or names none, which readiness reads as one approval and a required summary. */
|
|
114
|
+
export function worldChangesetRules(root: string): WorldChangesetRules {
|
|
115
|
+
const path = join(root, stateDirName(), 'world.json');
|
|
116
|
+
if (!getActiveWorldStore().exists(path) && !existsSync(path)) return {};
|
|
117
|
+
try { return loadWorldConfig(path, root).config.changesets ?? {}; } catch { return {}; }
|
|
118
|
+
}
|
package/src/fixture-env.ts
CHANGED
|
@@ -210,6 +210,10 @@ const CREDENTIAL_SHAPES: Array<{ match: RegExp; value: (name: string) => string;
|
|
|
210
210
|
{ match: /(^|_)STRIPE_[A-Z0-9_]*WEBHOOK_SECRET$/, value: () => 'whsec_twinfake0000000000000000000000000000', source: 'https://docs.stripe.com/webhooks#verify-events' },
|
|
211
211
|
{ match: /(^|_)STRIPE_[A-Z0-9_]*(SECRET|PRIVATE|API)(_API)?_KEY$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (sk_test_)' },
|
|
212
212
|
{ match: /(^|_)STRIPE_[A-Z0-9_]*(PUBLISHABLE|PUBLIC)(_API)?_KEY$/, value: () => 'pk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (pk_test_)' },
|
|
213
|
+
// The slack twin answers a bearer that is none of Slack's token kinds with `invalid_auth`, as Slack does; any `xoxb-`
|
|
214
|
+
// acts as the workspace bot, and an app-level token is `xapp-`.
|
|
215
|
+
{ match: /(^|_)SLACK_[A-Z0-9_]*APP_TOKEN$/, value: () => 'xapp-1-twinfake', source: 'packages/twin/slack/src/slack-server.ts (bearer /^(xox|xapp-)/)' },
|
|
216
|
+
{ 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)' },
|
|
213
217
|
// tavily-auth.ts and firecrawl-auth.ts refuse a key without the vendor's prefix with the vendor's 401
|
|
214
218
|
{ match: /(^|_)TAVILY_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'tvly-twinfake00000000000000000000', source: 'packages/twin/tavily/src/tavily-auth.ts' },
|
|
215
219
|
{ match: /(^|_)FIRECRAWL_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'fc-twinfake00000000000000000000000', source: 'packages/twin/firecrawl/src/firecrawl-auth.ts FIRECRAWL_KEY_PREFIX' },
|
package/src/index.ts
CHANGED
|
@@ -18,7 +18,8 @@ export type {
|
|
|
18
18
|
WorldUsage,
|
|
19
19
|
} from './schema.ts';
|
|
20
20
|
export { DEFAULT_WORLD_SELECTION, LEGACY_WORLD_SELECTION, WORLD_SCHEMA_VERSION, WORLD_USAGES, worldConfigDocument, worldSelectionIncludes } from './schema.ts';
|
|
21
|
-
export {
|
|
21
|
+
export type { WorldChangesetRules } from './schema.ts';
|
|
22
|
+
export { loadWorldConfig, migrateWorldConfig, resolveConfigPath, worldChangesetRules, writeWorldConfig } from './configs.ts';
|
|
22
23
|
export type { WorldConfigMigration } from './configs.ts';
|
|
23
24
|
export {
|
|
24
25
|
upWorld,
|
package/src/root.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
|
14
14
|
import {
|
|
15
15
|
NO_SECRETS_CHECK, authStrategyFor, currentSealer, destinationOf, sealsThroughVault, openSealedAt, getActiveWorldStore, getPack, buildRemoteExecute, clearRoot, observeAppends, readRoot, registerStateSystem, stateDirName, stateSystemFor, type Check, type CredentialPayload, type PerformAction, type PushReport, type RemoteExecute, type RootConfig, type SealedCredential, type TwinAction, validateRemoteOrigin, writeRoot,
|
|
16
16
|
} from '@volter/world-core';
|
|
17
|
-
import { loadWorldConfig, writeWorldConfig } from './configs.ts';
|
|
17
|
+
import { loadWorldConfig, worldChangesetRules, writeWorldConfig } from './configs.ts';
|
|
18
18
|
import { changesetReadiness, collectObservations, type Observation, foldObservations, loadChecks, openRootCredential, performEntries, rootCustody, protocolStanding, userKek, type DeployReport, type ObserveReport } from '@volter/world-core';
|
|
19
19
|
import { findWorldChangeset, listWorldChangesets } from './changeset.ts';
|
|
20
20
|
import { importModule } from './import-module.ts';
|
|
@@ -501,7 +501,7 @@ export async function deployWorld(name: string, opts: { root?: string; changeset
|
|
|
501
501
|
const out: DeployTwinOutcome[] = [];
|
|
502
502
|
if (!opts.changeset) {
|
|
503
503
|
// no name: every ready changeset, by name, in order — so each outcome says which it was for
|
|
504
|
-
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));
|
|
504
|
+
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));
|
|
505
505
|
const anyGated = Object.keys(inst.services).some((s) => rootForControlRoot(join(inst.dirs.data, s), s)?.deploy === 'gated');
|
|
506
506
|
if (anyGated || ready.length) {
|
|
507
507
|
for (const l of ready) out.push(...(await deployWorld(name, { ...opts, changeset: l.changeset.name })).map((o) => ({ ...o, changeset: l.changeset.name })));
|
|
@@ -511,7 +511,7 @@ export async function deployWorld(name: string, opts: { root?: string; changeset
|
|
|
511
511
|
const located = opts.changeset ? [findWorldChangeset(opts.changeset, { root: worldRoot, world: name })] : listWorldChangesets({ root: worldRoot, world: name });
|
|
512
512
|
const ready = new Map<string, string[]>();
|
|
513
513
|
for (const l of located) {
|
|
514
|
-
const readiness = changesetReadiness(l.changeset);
|
|
514
|
+
const readiness = changesetReadiness(l.changeset, worldChangesetRules(worldRoot));
|
|
515
515
|
if (!readiness.ready) {
|
|
516
516
|
const gated = [...new Set(l.changeset.actions.map((e) => e.service))].some((s) => rootForControlRoot(join(inst.dirs.data, s), s)?.deploy === 'gated');
|
|
517
517
|
if (opts.changeset && gated) throw new Error(`changeset ${opts.changeset} is not ready: ${readiness.reasons.join('; ')}`);
|
package/src/runtime.ts
CHANGED
|
@@ -1347,8 +1347,8 @@ async function startColocatedServices(
|
|
|
1347
1347
|
paths: { root: string; instance: string; logs: string; data: string },
|
|
1348
1348
|
allocatedPorts: Set<number>,
|
|
1349
1349
|
boot: BootContext,
|
|
1350
|
-
): Promise<WorldServiceInstance[]> {
|
|
1351
|
-
if (colocated.length === 0) return [];
|
|
1350
|
+
): Promise<{ services: WorldServiceInstance[]; ready: Promise<void> }> {
|
|
1351
|
+
if (colocated.length === 0) return { services: [], ready: Promise.resolve() };
|
|
1352
1352
|
const hostCli = siblingScript(import.meta.url, 'host-cli');
|
|
1353
1353
|
const readyFile = join(paths.instance, `host-ready-${boot.bootId}.json`);
|
|
1354
1354
|
const args: string[] = [hostCli, '--isolation', isolation === 'colocated' ? 'shared' : 'worker', '--ready-file', readyFile];
|
|
@@ -1389,42 +1389,10 @@ async function startColocatedServices(
|
|
|
1389
1389
|
});
|
|
1390
1390
|
|
|
1391
1391
|
const command = [process.execPath, ...args];
|
|
1392
|
-
const hostTimeoutMs = allocated.reduce((timeout, { service }) => Math.max(timeout, typeof service.ready === 'object' ? service.ready.timeoutMs ?? 15_000 : 15_000), 15_000);
|
|
1393
|
-
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) => {
|
|
1394
|
-
if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
|
|
1395
|
-
try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
|
|
1396
|
-
throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
|
|
1397
|
-
});
|
|
1398
|
-
await parallelStartup(allocated, async ({ service, port }) => {
|
|
1399
|
-
const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
|
|
1400
|
-
await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
|
|
1401
|
-
if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
|
|
1402
|
-
try { process.kill(-child.pid!, 'SIGTERM'); } 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: ReturnType<typeof setTimeout> | undefined;
|
|
1407
|
-
const hostSettle = new Promise<'alive'>((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) recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
|
|
1414
|
-
try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
|
|
1415
|
-
throw error;
|
|
1416
|
-
});
|
|
1417
|
-
});
|
|
1418
|
-
} finally { if (hostSettleTimer !== undefined) clearTimeout(hostSettleTimer); }
|
|
1419
|
-
if (exitState.value !== null) {
|
|
1420
|
-
throw new Error(
|
|
1421
|
-
`The co-located host ended during startup even though its ports answer, so other/stale processes probably hold them: the host ${endedAccount(log)}\n`
|
|
1422
|
-
+ `Stop leftover services (e.g. \`volter-world down\`) and retry.\nLog: ${log}`,
|
|
1423
|
-
);
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
1392
|
const host = '127.0.0.1';
|
|
1427
|
-
|
|
1393
|
+
// Every twin's address is known once its port is allocated: the records and
|
|
1394
|
+
// the environment they inject exist before the host reports its starts.
|
|
1395
|
+
const services = allocated.map(({ service, port, mounted }) => {
|
|
1428
1396
|
const url = `http://${host}:${port}`;
|
|
1429
1397
|
return {
|
|
1430
1398
|
id: service.id,
|
|
@@ -1443,6 +1411,45 @@ async function startColocatedServices(
|
|
|
1443
1411
|
},
|
|
1444
1412
|
};
|
|
1445
1413
|
});
|
|
1414
|
+
const ready = (async (): Promise<void> => {
|
|
1415
|
+
const hostTimeoutMs = allocated.reduce((timeout, { service }) => Math.max(timeout, typeof service.ready === 'object' ? service.ready.timeoutMs ?? 15_000 : 15_000), 15_000);
|
|
1416
|
+
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) => {
|
|
1417
|
+
if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (host startup incomplete)', 'SIGTERM');
|
|
1418
|
+
try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
|
|
1419
|
+
throw new Error(`Co-located host failed to complete startup (isolation=${isolation}): ${error instanceof Error ? error.message : String(error)}\nLog: ${log}`);
|
|
1420
|
+
});
|
|
1421
|
+
await parallelStartup(allocated, async ({ service, port }) => {
|
|
1422
|
+
const bindTimeoutMs = typeof service.ready === 'object' && service.ready.timeoutMs !== undefined ? Math.max(service.ready.timeoutMs, 15_000) : 15_000;
|
|
1423
|
+
await untilReadyOrEnded((abort) => waitForTcp(port, bindTimeoutMs, abort), ended, boot, 'the co-located host', log).catch((error) => {
|
|
1424
|
+
if (undeadPids([child.pid!]).length) recordWorldStop([log], 'world.service.stop', 'up (startup failed)', 'SIGTERM');
|
|
1425
|
+
try { process.kill(-child.pid!, 'SIGTERM'); } 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' as const);
|
|
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) recordWorldStop([log], 'world.service.stop', 'up (port not its own)', 'SIGTERM');
|
|
1438
|
+
try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* best effort cleanup */ }
|
|
1439
|
+
throw error;
|
|
1440
|
+
});
|
|
1441
|
+
});
|
|
1442
|
+
if (exitState.value !== null) {
|
|
1443
|
+
throw new Error(
|
|
1444
|
+
`The co-located host ended during startup even though its ports answer, so other/stale processes probably hold them: the host ${endedAccount(log)}\n`
|
|
1445
|
+
+ `Stop leftover services (e.g. \`volter-world down\`) and retry.\nLog: ${log}`,
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
})();
|
|
1449
|
+
// Observed here so a failure before the caller awaits it is not unhandled;
|
|
1450
|
+
// the caller awaits `ready` before the World is up.
|
|
1451
|
+
ready.catch(() => undefined);
|
|
1452
|
+
return { services, ready };
|
|
1446
1453
|
}
|
|
1447
1454
|
|
|
1448
1455
|
export async function upWorld(configId: string, options: UpWorldOptions = {}, abort?: AbortSignal): Promise<WorldInstance> {
|
|
@@ -1669,12 +1676,19 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1669
1676
|
const allocatedPorts = new Set(loaded.config.services.flatMap((service) => typeof service.port === 'number' ? [service.port] : []));
|
|
1670
1677
|
// Non-'process' isolation: boot every colocate-declaring service inside ONE host child
|
|
1671
1678
|
// first; anything without `colocate` (and externals) still goes through the spawn path.
|
|
1679
|
+
// The co-located host's twins are known by address once their ports are
|
|
1680
|
+
// allocated. A twin serves and calls no other service while it starts,
|
|
1681
|
+
// so a later twin declaration starts beside the host's startup; any other
|
|
1682
|
+
// service waits until every earlier one is ready, as declared (ADR-0009).
|
|
1683
|
+
let colocatedReady: Promise<void> | undefined;
|
|
1672
1684
|
if (isolation !== 'process') {
|
|
1673
1685
|
const colocated = loaded.config.services.filter((service) => service.colocate && service.type !== 'external');
|
|
1674
|
-
|
|
1686
|
+
const host = await startColocatedServices(loaded.config, colocated, isolation, env, { root, instance, logs, data }, allocatedPorts, boot);
|
|
1687
|
+
for (const started of host.services) {
|
|
1675
1688
|
services[started.id] = started;
|
|
1676
1689
|
Object.assign(env, started.env);
|
|
1677
1690
|
}
|
|
1691
|
+
colocatedReady = host.ready;
|
|
1678
1692
|
recordPidsSoFar();
|
|
1679
1693
|
writeProxyEnv(root, name, env);
|
|
1680
1694
|
}
|
|
@@ -1706,6 +1720,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1706
1720
|
};
|
|
1707
1721
|
for (const service of loaded.config.services) {
|
|
1708
1722
|
if (services[service.id]) continue; // already up co-located
|
|
1723
|
+
if (colocatedReady && (service.type ?? 'twin') !== 'twin') { await colocatedReady; colocatedReady = undefined; }
|
|
1709
1724
|
attemptProxy();
|
|
1710
1725
|
boot.signal.throwIfAborted();
|
|
1711
1726
|
const started = await startService(loaded.config, service, env, { root, instance, logs, data }, allocatedPorts, boot);
|
|
@@ -1714,6 +1729,7 @@ export async function upWorld(configId: string, options: UpWorldOptions = {}, ab
|
|
|
1714
1729
|
Object.assign(env, started.env);
|
|
1715
1730
|
writeProxyEnv(root, name, env);
|
|
1716
1731
|
}
|
|
1732
|
+
if (colocatedReady) await colocatedReady;
|
|
1717
1733
|
detachBootSignals();
|
|
1718
1734
|
// Re-evaluate once more after the LAST service: a single/last-twin world's OWN vendor twin
|
|
1719
1735
|
// only lands in `env` once IT has started, so a check that only runs "before starting the
|