@volter/world-runtime 3.0.137 → 3.0.139
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 +2 -0
- package/dist/src/changeset.js +33 -8
- package/dist/src/covers.js +8 -2
- package/dist/src/runtime.d.ts +6 -0
- package/dist/src/runtime.js +10 -1
- package/dist/src/served-world.d.ts +1 -0
- package/dist/src/served-world.js +32 -11
- package/package.json +4 -4
- package/src/changeset.ts +30 -8
- package/src/covers.ts +8 -2
- package/src/runtime.ts +10 -1
- package/src/served-world.ts +32 -13
package/dist/src/changeset.d.ts
CHANGED
|
@@ -115,6 +115,8 @@ export type PushWorldOptions = WorldRootOptions & {
|
|
|
115
115
|
/** the remote's twins url and namespace; default to the world's recorded origin */
|
|
116
116
|
to?: string;
|
|
117
117
|
namespace?: string;
|
|
118
|
+
/** Only the tracked origin may advance this branch's origin cursor or receive its landed receipts. */
|
|
119
|
+
trackOrigin?: boolean;
|
|
118
120
|
/** the namespace key (a push is a write; the read key is refused) */
|
|
119
121
|
key: string;
|
|
120
122
|
force?: boolean;
|
package/dist/src/changeset.js
CHANGED
|
@@ -26,7 +26,7 @@ import { blobDigest, captureHistory, captureParentHistory, getActiveBlobStore, g
|
|
|
26
26
|
import { tmpdir } from 'node:os';
|
|
27
27
|
import { join, relative, resolve } from 'node:path';
|
|
28
28
|
import { approveChangeset, rebaseChangeset, buildChangeset, captureMarker, changesetHashMatches, changesetReadiness, diffLedgers, normalizeChangeset, replayChangeset, runChangesetVerifiers, stateDirName, withApplication, withVerification, worldBootMarker, assertSafeChangesetName, assertMarkerBelongsTo, CHANGESET_KIND, MARKER_KIND, WORLD_BOOT_MARKER_ID, } from '@volter/world-core/runtime';
|
|
29
|
-
import { instanceDir, listWorlds, statusWorld } from "./runtime.js";
|
|
29
|
+
import { instanceDir, isWorldOrigin, listWorlds, statusWorld } from "./runtime.js";
|
|
30
30
|
import { fetchFromOrigin } from "./origin.js";
|
|
31
31
|
import { fetchReady, isPathRemote, landChangeset, landReceipts, localWorld, TOKEN_HEADER } from "./served-world.js";
|
|
32
32
|
import { loadWorldChecks } from "./root.js";
|
|
@@ -391,19 +391,42 @@ export async function pushWorldChangeset(name, options) {
|
|
|
391
391
|
const namespace = options.namespace ?? instance.origin?.namespace;
|
|
392
392
|
if (!url || !namespace)
|
|
393
393
|
throw new Error(`World "${located.world}" has no origin to push to — \`volter remote add origin <url> --token <token>\` names one`);
|
|
394
|
+
const trackOrigin = options.trackOrigin !== false && isWorldOrigin(instance, { url, namespace }, root);
|
|
394
395
|
// over http to a served world's push door, or straight into a world at a path (git's file transport)
|
|
395
396
|
let res;
|
|
396
397
|
let body;
|
|
397
398
|
const expect = {};
|
|
398
399
|
for (const a of located.changeset.actions) {
|
|
399
400
|
const meta = readBranchMeta(a.stateService ?? a.service, join(instance.dirs.data, a.service));
|
|
400
|
-
const pointer = meta?.origin ?? meta?.parent;
|
|
401
|
-
if (meta?.origin?.incomplete)
|
|
401
|
+
const pointer = trackOrigin ? meta?.origin ?? meta?.parent : undefined;
|
|
402
|
+
if (trackOrigin && meta?.origin?.incomplete)
|
|
402
403
|
throw new Error('The branch cuts through its tracked origin; pull and rebase before pushing');
|
|
403
404
|
const view = isPathRemote(url) ? pointer?.view : pointer?.remoteView;
|
|
404
405
|
if (pointer && view)
|
|
405
406
|
expect[a.service] = { view, position: pointer.position };
|
|
406
407
|
}
|
|
408
|
+
if (!trackOrigin) {
|
|
409
|
+
// A named destination has no origin cursor here. Read its head without installing it as
|
|
410
|
+
// origin, and bind this delivery to that head so a concurrent destination move is refused.
|
|
411
|
+
for (const a of located.changeset.actions) {
|
|
412
|
+
if (expect[a.service])
|
|
413
|
+
continue;
|
|
414
|
+
if (isPathRemote(url)) {
|
|
415
|
+
const target = localWorld(url);
|
|
416
|
+
const head = captureHistory(a.stateService ?? a.service, join(target.instance.dirs.data, a.service));
|
|
417
|
+
expect[a.service] = { view: head.view, position: head.position };
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
const answer = await fetchReady(`${url}/-/${namespace}/checkpoint/${encodeURIComponent(a.service)}`, { headers: { [TOKEN_HEADER]: options.key } });
|
|
421
|
+
if (!answer.ok)
|
|
422
|
+
throw new Error(`Cannot read destination head for ${a.service}: ${answer.status}`);
|
|
423
|
+
const head = await answer.json();
|
|
424
|
+
if (typeof head.view !== 'string' || !Number.isSafeInteger(head.position))
|
|
425
|
+
throw new Error(`Destination did not return a history head for ${a.service}`);
|
|
426
|
+
expect[a.service] = { view: head.view, position: head.position };
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
407
430
|
// THE RESOURCE PAYLOADS the entries name (uploaded media, file bytes), in two phases. Content-addressed
|
|
408
431
|
// blobs (a key segment that IS a sha256) never change, so they go before the entries: a refused push
|
|
409
432
|
// leaves only bytes nothing names yet. Records kept by name (an upload's status, a media record) are the
|
|
@@ -468,7 +491,7 @@ export async function pushWorldChangeset(name, options) {
|
|
|
468
491
|
continue;
|
|
469
492
|
const put = await fetch(at, { method: 'PUT', headers: { [TOKEN_HEADER]: options.key, 'content-type': 'application/octet-stream' }, body: new Blob([bytes]) });
|
|
470
493
|
if (!put.ok)
|
|
471
|
-
throw new Error(`push: the
|
|
494
|
+
throw new Error(`push: the destination refused ${twin}'s ${key} (${put.status} ${(await put.text()).slice(0, 200)})`);
|
|
472
495
|
}
|
|
473
496
|
}
|
|
474
497
|
};
|
|
@@ -496,7 +519,7 @@ export async function pushWorldChangeset(name, options) {
|
|
|
496
519
|
const at = new Date().toISOString();
|
|
497
520
|
if (!res.ok || !body.receipts) {
|
|
498
521
|
const refusal = body.refusal ?? [body.error ?? `the remote answered ${res.status}`];
|
|
499
|
-
const application = { at, contentHash: located.changeset.contentHash, outcome: 'refused', refusal, receipts: [], compensation: [] };
|
|
522
|
+
const application = { at, contentHash: located.changeset.contentHash, destination: { url, namespace }, outcome: 'refused', refusal, receipts: [], compensation: [] };
|
|
500
523
|
writeChangesetFile(located.path, withApplication(located.changeset, application));
|
|
501
524
|
return { application, changeset: withApplication(located.changeset, application), world: located.world, path: located.path, remote: { url, namespace } };
|
|
502
525
|
}
|
|
@@ -515,7 +538,7 @@ export async function pushWorldChangeset(name, options) {
|
|
|
515
538
|
}
|
|
516
539
|
// origin advanced by what landed (and what it performed): the cursor moves with it, so the next
|
|
517
540
|
// fetch and the next push start from where origin now stands
|
|
518
|
-
if (body.snapshots && instance.origin) {
|
|
541
|
+
if (trackOrigin && body.snapshots && instance.origin) {
|
|
519
542
|
const twins = Object.keys(body.snapshots);
|
|
520
543
|
const fetched = await fetchFromOrigin(located.world, { root, url, namespace, key: options.key, services: twins, views: body.snapshots });
|
|
521
544
|
for (const twin of twins) {
|
|
@@ -537,6 +560,8 @@ export async function pushWorldChangeset(name, options) {
|
|
|
537
560
|
for (const r of body.receipts)
|
|
538
561
|
byTwin.set(r.twin, [...(byTwin.get(r.twin) ?? []), r]);
|
|
539
562
|
for (const [twin, receipts] of byTwin) {
|
|
563
|
+
if (!trackOrigin)
|
|
564
|
+
continue;
|
|
540
565
|
const entry = located.changeset.actions.find((e) => e.service === twin);
|
|
541
566
|
const state = entry?.stateService ?? twin;
|
|
542
567
|
landReceipts(join(instance.dirs.data, twin), state, receipts, at);
|
|
@@ -544,11 +569,11 @@ export async function pushWorldChangeset(name, options) {
|
|
|
544
569
|
const statusOf = (s) => (s === 'deployed' ? 'confirmed' : s === 'landed' ? 'replayed' : s === 'refused' || s === 'failed' ? 'failed' : 'skipped');
|
|
545
570
|
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 } : {}) }));
|
|
546
571
|
const outcome = receipts.every((r) => r.status !== 'failed') ? 'applied' : receipts.some((r) => r.status === 'confirmed') ? 'partial' : 'refused';
|
|
547
|
-
const application = { at, contentHash: located.changeset.contentHash, outcome, receipts, compensation: [], ...(outcome === 'refused' ? { refusal: receipts.filter((r) => r.error).map((r) => r.error) } : {}) };
|
|
572
|
+
const application = { at, contentHash: located.changeset.contentHash, destination: { url, namespace }, outcome, receipts, compensation: [], ...(outcome === 'refused' ? { refusal: receipts.filter((r) => r.error).map((r) => r.error) } : {}) };
|
|
548
573
|
// the remote's record of who pushed it and who owns it, kept on this copy too (outside the hash)
|
|
549
574
|
const changeset = { ...withApplication(located.changeset, application), ...(body.owner && !located.changeset.owner ? { owner: body.owner } : {}), ...(body.pushedBy ? { pushedBy: body.pushedBy } : {}) };
|
|
550
575
|
writeChangesetFile(located.path, changeset);
|
|
551
|
-
const unsent = namedFailure ? `records the landed entries name did not reach the
|
|
576
|
+
const unsent = namedFailure ? `records the landed entries name did not reach the destination (${errorMessage(namedFailure)}); the next push from this branch that touches ${twins.map(([t]) => t).join(', ')} sends them` : undefined;
|
|
552
577
|
return { ...(unsent ? { unsent } : {}), application, changeset, world: located.world, path: located.path, remote: { url, namespace } };
|
|
553
578
|
}
|
|
554
579
|
/** `volter-world changeset rebase <name>`: the merge onto the authoring world's moved mirror. */
|
package/dist/src/covers.js
CHANGED
|
@@ -747,7 +747,9 @@ export function coverWorld(world, repoPath, options = {}) {
|
|
|
747
747
|
const uninterceptable = [];
|
|
748
748
|
const excluded = [];
|
|
749
749
|
for (const [vendor, sources] of [...detected.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
750
|
-
const
|
|
750
|
+
const managedWire = [...inventory.connections.values()].find(binding => binding.protocol === vendor);
|
|
751
|
+
const explicitlyExcluded = inventory.selection?.exclude.some(s => s.vendor === vendor && (s.usage === undefined || s.usage === 'application'));
|
|
752
|
+
const selected = (!explicitlyExcluded && managedWire !== undefined) || inventory.selection === undefined || (uses.get(vendor) ?? []).some((use) => worldSelectionIncludes(inventory.selection, vendor, use.usage));
|
|
751
753
|
if (!selected) {
|
|
752
754
|
excluded.push(vendor);
|
|
753
755
|
rows.push({ vendor, detectedVia: sources, twinInWorld: null, status: 'excluded' });
|
|
@@ -763,7 +765,7 @@ export function coverWorld(world, repoPath, options = {}) {
|
|
|
763
765
|
? [...inventory.connections.values()].find(binding => binding.protocol === managed.scheme)
|
|
764
766
|
: undefined;
|
|
765
767
|
const match = matchId === undefined
|
|
766
|
-
? managedBinding ? { label: managedBinding.label, interceptable: true } : null
|
|
768
|
+
? managedBinding || managedWire ? { label: (managedBinding ?? managedWire).label, interceptable: true } : null
|
|
767
769
|
: inventory.ids.get(matchId);
|
|
768
770
|
let status = match === null ? 'missing' : match.interceptable ? 'covered' : 'uninterceptable';
|
|
769
771
|
let ackRefused;
|
|
@@ -846,7 +848,11 @@ export function coverWorld(world, repoPath, options = {}) {
|
|
|
846
848
|
const connectionNames = [vendor, ...(requirement.protocol ? [requirement.protocol] : [])];
|
|
847
849
|
const selectorMatches = (selector) => (selector.usage === undefined || selector.usage === 'application') &&
|
|
848
850
|
(selector.vendor === undefined || connectionNames.includes(selector.vendor));
|
|
851
|
+
const ownedBinding = requirement.env === undefined ? undefined : inventory.connections.get(requirement.env);
|
|
852
|
+
// A declared managed wire is in the World even when --twins selected only SaaS names.
|
|
853
|
+
// Keep an explicit exclusion authoritative; otherwise describe the actual owned connection.
|
|
849
854
|
const connectionSelected = inventory.selection === undefined ||
|
|
855
|
+
(ownedBinding !== undefined && !inventory.selection.exclude.some(selectorMatches)) ||
|
|
850
856
|
(inventory.selection.include.some(selectorMatches) && !inventory.selection.exclude.some(selectorMatches));
|
|
851
857
|
if (!connectionSelected) {
|
|
852
858
|
excluded.push(vendor);
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -92,6 +92,12 @@ export declare function admittedEnv(strip: string[] | undefined, env?: NodeJS.Pr
|
|
|
92
92
|
/** The names a twin of this World was to issue and did not (its door failed): kept out of every command's env, so a
|
|
93
93
|
* caller's own value (a real key in their shell) never stands in for the credential the World meant to issue. */
|
|
94
94
|
export declare const UNISSUED_ENV = "VOLTER_WORLD_UNISSUED";
|
|
95
|
+
/** Whether a delivery names the branch's tracked origin. Config owns the root World's
|
|
96
|
+
* remote; a local branch keeps its own recorded parent instead of adopting that config. */
|
|
97
|
+
export declare function isWorldOrigin(instance: WorldInstance, destination: {
|
|
98
|
+
url: string;
|
|
99
|
+
namespace: string;
|
|
100
|
+
}, root: string): boolean;
|
|
95
101
|
export declare function saveWorldInstance(instance: WorldInstance): void;
|
|
96
102
|
/** The environment an external service and its replacement infrastructure hosts run in. Shared with branching,
|
|
97
103
|
* so both use the boot's admission, service overrides, lifecycle context and control-plane egress rules. */
|
package/dist/src/runtime.js
CHANGED
|
@@ -930,6 +930,15 @@ function namesOrigin(remote, origin, worldRoot) {
|
|
|
930
930
|
return false;
|
|
931
931
|
}
|
|
932
932
|
}
|
|
933
|
+
/** Whether a delivery names the branch's tracked origin. Config owns the root World's
|
|
934
|
+
* remote; a local branch keeps its own recorded parent instead of adopting that config. */
|
|
935
|
+
export function isWorldOrigin(instance, destination, root) {
|
|
936
|
+
const sameRecorded = instance.origin?.url.replace(/\/+$/, '') === destination.url.replace(/\/+$/, '') && instance.origin?.namespace === destination.namespace;
|
|
937
|
+
if (instance.name !== instance.config)
|
|
938
|
+
return sameRecorded;
|
|
939
|
+
const configured = loadWorldConfig(instance.configPath, root).config.remotes?.origin;
|
|
940
|
+
return configured ? namesOrigin(configured, destination, root) : sameRecorded;
|
|
941
|
+
}
|
|
933
942
|
export function saveWorldInstance(instance) {
|
|
934
943
|
writeTextAtomic(instanceFile(instance.root, instance.name), `${JSON.stringify(instance, null, 2)}\n`);
|
|
935
944
|
}
|
|
@@ -1637,7 +1646,7 @@ async function issueCredentials(service, started, env, pending, unissued, contro
|
|
|
1637
1646
|
beginPlaceholderPull(service.id, controlRoot);
|
|
1638
1647
|
let answer;
|
|
1639
1648
|
try {
|
|
1640
|
-
const res = await fetch(new URL(door.path, started.url), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(door.body), signal: AbortSignal.timeout(ISSUE_TIMEOUT_MS) });
|
|
1649
|
+
const res = await fetch(new URL(door.path, started.url), { method: 'POST', headers: { 'content-type': 'application/json', 'x-volter-world-activity': 'provisioning' }, body: JSON.stringify(door.body), signal: AbortSignal.timeout(ISSUE_TIMEOUT_MS) });
|
|
1641
1650
|
if (!res.ok)
|
|
1642
1651
|
throw new Error(`answered ${res.status}`);
|
|
1643
1652
|
answer = (await res.json());
|
package/dist/src/served-world.js
CHANGED
|
@@ -46,7 +46,9 @@ import { fetchFromOrigin } from "./origin.js";
|
|
|
46
46
|
import { CONSOLE_BASE, consoleRedirect, serveConsoleApart } from "./console-apart.js";
|
|
47
47
|
import { loadWorldConfig, worldChangesetRules, writeWorldConfig } from "./configs.js";
|
|
48
48
|
import { adaptersFor, credentialPath, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, rewrapTwinCredentials, sealTwinCredential, sealedCredentialInfo, bindLegacyCredentials, setTwinRoot, spanMs } from "./root.js";
|
|
49
|
-
import {
|
|
49
|
+
import { describeActionFields } from '@volter/world-core/runtime';
|
|
50
|
+
import { worldLedgers } from "./changeset.js";
|
|
51
|
+
import { clockFile, downWorld, isWorldOrigin, listWorlds, readWorldBootRecord, statusWorld, upWorld } from "./runtime.js";
|
|
50
52
|
import { assertChangesetRules } from "./schema.js";
|
|
51
53
|
export const TOKEN_HEADER = 'x-volter-token';
|
|
52
54
|
/** The header the injector carries a hosted World's token in (inject.cjs, VOLTER_TWINS_KEY). */
|
|
@@ -244,7 +246,10 @@ export async function mountWorld(name, opts = {}) {
|
|
|
244
246
|
// refuse as "already served"), and no twins it started; twins another owner runs were never ours to stop
|
|
245
247
|
let booted = null;
|
|
246
248
|
try {
|
|
247
|
-
|
|
249
|
+
// A view resumes the same app, including the output it owns. Switching to the operator's
|
|
250
|
+
// default output would strand the app output without an ownership record on the next up.
|
|
251
|
+
const retained = listWorlds(worldRoot).some(w => w.name === name) ? readWorldBootRecord(name, worldRoot) : null;
|
|
252
|
+
booted = await upWorld(configRef, { name, root: worldRoot, keepState: true, ...(retained ? { envFile: retained.envFile } : {}) });
|
|
248
253
|
// a root set in the config while the world was stopped reaches the twins' state now, where the deploy reads it
|
|
249
254
|
materializeRoots(loadWorldConfig(configRef, worldRoot).config, booted.dirs.data, worldRoot);
|
|
250
255
|
// credentials sealed before bindings are bound to where they go before any request is let in (instance unset,
|
|
@@ -1378,17 +1383,26 @@ export class WorldDoors {
|
|
|
1378
1383
|
if (known !== undefined && Date.now() - known.at < 60_000)
|
|
1379
1384
|
return known.screens;
|
|
1380
1385
|
let screens = [];
|
|
1386
|
+
let resources = {};
|
|
1381
1387
|
const twin = this.host.twinFetch(vendor);
|
|
1382
1388
|
try {
|
|
1383
1389
|
const answer = twin ? await twin(new Request('http://world.local/twin', { headers: { [READ_ONLY_HEADER]: '1' } })) : null;
|
|
1384
|
-
const
|
|
1390
|
+
const manifest = answer?.ok ? await answer.json() : null;
|
|
1391
|
+
const listed = manifest?.screens;
|
|
1392
|
+
const described = manifest?.resources;
|
|
1393
|
+
if (described && typeof described === 'object' && !Array.isArray(described)) {
|
|
1394
|
+
for (const [type, metadata] of Object.entries(described)) {
|
|
1395
|
+
if (metadata && Array.isArray(metadata.relations))
|
|
1396
|
+
resources[type] = { relations: metadata.relations.filter(r => r && typeof r.field === 'string' && typeof r.to === 'string') };
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1385
1399
|
if (Array.isArray(listed))
|
|
1386
1400
|
screens = listed.filter((x) => typeof x?.id === 'string' && typeof x?.path === 'string').map(({ id, path, host }) => ({ id, path, ...(typeof host === 'string' ? { host } : {}) }));
|
|
1387
1401
|
}
|
|
1388
1402
|
catch {
|
|
1389
1403
|
screens = [];
|
|
1390
1404
|
}
|
|
1391
|
-
this.workspaces.set(vendor, { screens, at: Date.now() });
|
|
1405
|
+
this.workspaces.set(vendor, { screens, resources, at: Date.now() });
|
|
1392
1406
|
return screens;
|
|
1393
1407
|
}
|
|
1394
1408
|
/** Whether a twin enforces a read-scope request itself (its manifest names `requestScopes: ['read']`):
|
|
@@ -1968,7 +1982,7 @@ export class WorldDoors {
|
|
|
1968
1982
|
const delta = diffWorld(this.name, { root: this.worldRoot });
|
|
1969
1983
|
// what another World pushed here is in its changeset already: never this World's uncut changes
|
|
1970
1984
|
const received = new Set(listWorldChangesets({ root: this.worldRoot, world: this.name }).filter((l) => l.changeset.pushedBy).flatMap((l) => l.changeset.actions.map((a) => a.action.id)));
|
|
1971
|
-
return Response.json({ world: this.served, base: { id: delta.base.id, at: delta.base.createdAt ?? null }, changes: delta.actions.filter((a) => !received.has(a.action.id)).map((a) => ({ twin: a.service, entry: a.action })) });
|
|
1985
|
+
return Response.json({ world: this.served, base: { id: delta.base.id, at: delta.base.createdAt ?? null }, changes: delta.actions.filter((a) => !received.has(a.action.id)).map((a) => ({ twin: a.service, entry: a.action, fields: delta.fieldChanges?.[JSON.stringify([a.service, a.stateService, a.action.id])] })) });
|
|
1972
1986
|
}
|
|
1973
1987
|
if (kind === 'log' && name && request.method === 'GET') {
|
|
1974
1988
|
const state = this.stateOf(name);
|
|
@@ -2001,16 +2015,18 @@ export class WorldDoors {
|
|
|
2001
2015
|
return Response.json({ twin: name, state, view: cut.view, position: cut.position, tree: readTree(state, controlRoot, { view: cut.view }) });
|
|
2002
2016
|
}
|
|
2003
2017
|
if (kind === 'changesets' && request.method === 'GET') {
|
|
2018
|
+
const instance = statusWorld(this.name, this.worldRoot);
|
|
2019
|
+
const originApplied = (c) => Boolean(c.applied && c.applied.outcome !== 'refused' && (!c.applied.destination || isWorldOrigin(instance, c.applied.destination, this.worldRoot)));
|
|
2004
2020
|
// with the body's hash as it stands, so a reader sees an approval or a verification made on other bytes as stale
|
|
2005
2021
|
if (name) {
|
|
2006
2022
|
const l = findWorldChangeset(name, { root: this.worldRoot, world: this.name });
|
|
2007
|
-
return Response.json({ ...l.changeset, currentHash: changesetContentHash(l.changeset), deployed: this.deployedOf([l.changeset])[0], readiness: changesetReadiness(l.changeset, worldChangesetRules(this.worldRoot)) });
|
|
2023
|
+
return Response.json({ ...l.changeset, originApplied: originApplied(l.changeset), fieldChanges: describeActionFields(worldLedgers(this.name, this.worldRoot), l.changeset.actions), currentHash: changesetContentHash(l.changeset), deployed: this.deployedOf([l.changeset])[0], readiness: changesetReadiness(l.changeset, worldChangesetRules(this.worldRoot)) });
|
|
2008
2024
|
}
|
|
2009
2025
|
const all = listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => l.changeset);
|
|
2010
2026
|
const deployed = this.deployedOf(all);
|
|
2011
2027
|
const rules = worldChangesetRules(this.worldRoot);
|
|
2012
2028
|
// readiness under this World's rules, as deploy reads it: the console says what the kernel decides
|
|
2013
|
-
return Response.json({ changesets: all.map((c, i) => ({ ...c, currentHash: changesetContentHash(c), deployed: deployed[i], readiness: changesetReadiness(c, rules) })) });
|
|
2029
|
+
return Response.json({ changesets: all.map((c, i) => ({ ...c, originApplied: originApplied(c), fieldChanges: describeActionFields(worldLedgers(this.name, this.worldRoot), c.actions), currentHash: changesetContentHash(c), deployed: deployed[i], readiness: changesetReadiness(c, rules) })) });
|
|
2014
2030
|
}
|
|
2015
2031
|
if (kind === 'changesets' && name && verb === 'verify' && request.method === 'POST') {
|
|
2016
2032
|
const outcome = await this.serialized('changesets', () => verifyWorldChangeset(name, { root: this.worldRoot, world: this.name, ephemeral: true }));
|
|
@@ -2151,7 +2167,8 @@ export class WorldDoors {
|
|
|
2151
2167
|
}
|
|
2152
2168
|
if (name === 'push' && request.method === 'POST') {
|
|
2153
2169
|
return Response.json(await this.serialized('push', async () => {
|
|
2154
|
-
const
|
|
2170
|
+
const instance = statusWorld(this.name, this.worldRoot);
|
|
2171
|
+
const queue = listWorldChangesets({ root: this.worldRoot, world: this.name }).filter((l) => { const a = l.changeset.applied; return !a || a.outcome === 'refused' || (a.destination && !isWorldOrigin(instance, a.destination, this.worldRoot)); }).sort((a, b) => a.changeset.createdAt.localeCompare(b.changeset.createdAt));
|
|
2155
2172
|
const pushed = [];
|
|
2156
2173
|
for (const l of queue) {
|
|
2157
2174
|
const outcome = await pushWorldChangeset(l.changeset.name, { root: this.worldRoot, world: this.name, key: key() });
|
|
@@ -2283,7 +2300,9 @@ export class WorldDoors {
|
|
|
2283
2300
|
// every twin of the world as a consumer sees it — the status door mapped over the world; what a
|
|
2284
2301
|
// console renders first, and what a holder of a world token (never the admin's) can ask
|
|
2285
2302
|
const twins = [];
|
|
2286
|
-
for (const vendor of Object.
|
|
2303
|
+
for (const [vendor, service] of Object.entries(this.up().services)) {
|
|
2304
|
+
if (service.type !== undefined && service.type !== 'twin')
|
|
2305
|
+
continue;
|
|
2287
2306
|
const res = await this.handle(new Request(`${url.origin}/-/${this.served}/twins/${vendor}/status`, { headers: request.headers }));
|
|
2288
2307
|
if (res.ok)
|
|
2289
2308
|
twins.push(await res.json());
|
|
@@ -2364,7 +2383,7 @@ export class WorldDoors {
|
|
|
2364
2383
|
const origin = readBranchMeta(state, this.controlRoot(name))?.origin;
|
|
2365
2384
|
const identity = { mounted: mounted?.resolvedPackage ? { package: mounted.resolvedPackage, version: mounted.resolvedVersion ?? null } : null, pinned: mounted?.pinnedSource ?? null };
|
|
2366
2385
|
const dataOrigin = origin ? { kind: 'shared-history', url: origin.at } : refresh ? { kind: 'vendor-refresh', url: root?.url ?? null } : { kind: 'unrecorded', url: null };
|
|
2367
|
-
return Response.json({ twin: name, state, identity, dataOrigin, execution: { rootConnected: Boolean(root), deploy: root?.deploy ?? null }, protocol: mounted?.protocol ?? null, screens: await this.screensOf(name), root: root ?? null, credential: sealedCredentialInfo(this.worldRoot, name), refresh, position: wholeLog(state, this.controlRoot(name)).length, lastReceipt: last?.receipt ?? null });
|
|
2386
|
+
return Response.json({ twin: name, state, identity, dataOrigin, execution: { rootConnected: Boolean(root), deploy: root?.deploy ?? null }, protocol: mounted?.protocol ?? null, screens: await this.screensOf(name), resources: this.workspaces.get(name)?.resources ?? {}, root: root ?? null, credential: sealedCredentialInfo(this.worldRoot, name), refresh, position: wholeLog(state, this.controlRoot(name)).length, lastReceipt: last?.receipt ?? null });
|
|
2368
2387
|
}
|
|
2369
2388
|
if (verb === 'refresh' && request.method === 'POST') {
|
|
2370
2389
|
const root = rootForControlRoot(this.controlRoot(name), name);
|
|
@@ -2800,7 +2819,9 @@ export class WorldDoors {
|
|
|
2800
2819
|
/** THE MAP: every twin and what it holds, by resource type, and the workspaces it shows. */
|
|
2801
2820
|
async map() {
|
|
2802
2821
|
const twins = [];
|
|
2803
|
-
for (const vendor of Object.
|
|
2822
|
+
for (const [vendor, service] of Object.entries(this.up().services)) {
|
|
2823
|
+
if (service.type !== undefined && service.type !== 'twin')
|
|
2824
|
+
continue;
|
|
2804
2825
|
const state = this.stateOf(vendor);
|
|
2805
2826
|
const controlRoot = this.controlRoot(vendor);
|
|
2806
2827
|
const counts = new Map();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.139",
|
|
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,14 +70,14 @@
|
|
|
70
70
|
"node": ">=22.3"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
|
-
"@volter/world-core": "3.0.
|
|
74
|
-
"@volter/world-console": "3.0.
|
|
73
|
+
"@volter/world-core": "3.0.139",
|
|
74
|
+
"@volter/world-console": "3.0.134"
|
|
75
75
|
},
|
|
76
76
|
"dependencies": {
|
|
77
77
|
"@electric-sql/pglite": "0.5.8",
|
|
78
78
|
"@electric-sql/pglite-pgvector": "0.0.9",
|
|
79
79
|
"@volter/world-access": "3.0.1",
|
|
80
|
-
"@volter/world-core": "3.0.
|
|
80
|
+
"@volter/world-core": "3.0.139",
|
|
81
81
|
"mongodb": "~6.20.0",
|
|
82
82
|
"pg-gateway": "0.3.0-beta.4",
|
|
83
83
|
"smol-toml": "^1.8.0",
|
package/src/changeset.ts
CHANGED
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
WORLD_BOOT_MARKER_ID,
|
|
48
48
|
} from '@volter/world-core/runtime';
|
|
49
49
|
import type { ApplyReceipt, Receipt, ChangesetApplication, RebaseReport, RebaseTarget, Changeset, ChangesetApproval, ChangesetReadiness, ChangesetVerification, ChangesetVerifier, LedgerDelta, LedgerRef, ReplayReport, ReplayTarget, WorldMarker } from '@volter/world-core/runtime';
|
|
50
|
-
import { instanceDir, listWorlds, saveWorldInstance, statusWorld } from './runtime.ts';
|
|
50
|
+
import { instanceDir, isWorldOrigin, listWorlds, saveWorldInstance, statusWorld } from './runtime.ts';
|
|
51
51
|
import { fetchFromOrigin } from './origin.ts';
|
|
52
52
|
import type { WorldInstance } from './schema.ts';
|
|
53
53
|
import { fetchReady, isPathRemote, landChangeset, landReceipts, localWorld, TOKEN_HEADER } from './served-world.ts';
|
|
@@ -467,6 +467,8 @@ export type PushWorldOptions = WorldRootOptions & {
|
|
|
467
467
|
/** the remote's twins url and namespace; default to the world's recorded origin */
|
|
468
468
|
to?: string;
|
|
469
469
|
namespace?: string;
|
|
470
|
+
/** Only the tracked origin may advance this branch's origin cursor or receive its landed receipts. */
|
|
471
|
+
trackOrigin?: boolean;
|
|
470
472
|
/** the namespace key (a push is a write; the read key is refused) */
|
|
471
473
|
key: string;
|
|
472
474
|
force?: boolean;
|
|
@@ -484,16 +486,35 @@ export async function pushWorldChangeset(name: string, options: PushWorldOptions
|
|
|
484
486
|
const url = (options.to ?? instance.origin?.url)?.replace(/\/+$/, '');
|
|
485
487
|
const namespace = options.namespace ?? instance.origin?.namespace;
|
|
486
488
|
if (!url || !namespace) throw new Error(`World "${located.world}" has no origin to push to — \`volter remote add origin <url> --token <token>\` names one`);
|
|
489
|
+
const trackOrigin = options.trackOrigin !== false && isWorldOrigin(instance, { url, namespace }, root);
|
|
487
490
|
// over http to a served world's push door, or straight into a world at a path (git's file transport)
|
|
488
491
|
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 };
|
|
489
492
|
const expect: Record<string, HistoryReference> = {};
|
|
490
493
|
for (const a of located.changeset.actions) {
|
|
491
494
|
const meta = readBranchMeta(a.stateService ?? a.service, join(instance.dirs.data, a.service));
|
|
492
|
-
const pointer = meta?.origin ?? meta?.parent;
|
|
493
|
-
if (meta?.origin?.incomplete) throw new Error('The branch cuts through its tracked origin; pull and rebase before pushing');
|
|
495
|
+
const pointer = trackOrigin ? meta?.origin ?? meta?.parent : undefined;
|
|
496
|
+
if (trackOrigin && meta?.origin?.incomplete) throw new Error('The branch cuts through its tracked origin; pull and rebase before pushing');
|
|
494
497
|
const view = isPathRemote(url) ? pointer?.view : pointer?.remoteView;
|
|
495
498
|
if (pointer && view) expect[a.service] = { view, position: pointer.position };
|
|
496
499
|
}
|
|
500
|
+
if (!trackOrigin) {
|
|
501
|
+
// A named destination has no origin cursor here. Read its head without installing it as
|
|
502
|
+
// origin, and bind this delivery to that head so a concurrent destination move is refused.
|
|
503
|
+
for (const a of located.changeset.actions) {
|
|
504
|
+
if (expect[a.service]) continue;
|
|
505
|
+
if (isPathRemote(url)) {
|
|
506
|
+
const target = localWorld(url);
|
|
507
|
+
const head = captureHistory(a.stateService ?? a.service, join(target.instance.dirs.data, a.service));
|
|
508
|
+
expect[a.service] = { view: head.view, position: head.position };
|
|
509
|
+
} else {
|
|
510
|
+
const answer = await fetchReady(`${url}/-/${namespace}/checkpoint/${encodeURIComponent(a.service)}`, { headers: { [TOKEN_HEADER]: options.key } });
|
|
511
|
+
if (!answer.ok) throw new Error(`Cannot read destination head for ${a.service}: ${answer.status}`);
|
|
512
|
+
const head = await answer.json() as HistoryReference;
|
|
513
|
+
if (typeof head.view !== 'string' || !Number.isSafeInteger(head.position)) throw new Error(`Destination did not return a history head for ${a.service}`);
|
|
514
|
+
expect[a.service] = { view: head.view, position: head.position };
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
497
518
|
// THE RESOURCE PAYLOADS the entries name (uploaded media, file bytes), in two phases. Content-addressed
|
|
498
519
|
// blobs (a key segment that IS a sha256) never change, so they go before the entries: a refused push
|
|
499
520
|
// leaves only bytes nothing names yet. Records kept by name (an upload's status, a media record) are the
|
|
@@ -546,7 +567,7 @@ export async function pushWorldChangeset(name: string, options: PushWorldOptions
|
|
|
546
567
|
const bytes = await blobs.get(path);
|
|
547
568
|
if (!bytes) continue;
|
|
548
569
|
const put = await fetch(at, { method: 'PUT', headers: { [TOKEN_HEADER]: options.key, 'content-type': 'application/octet-stream' }, body: new Blob([bytes as Uint8Array<ArrayBuffer>]) });
|
|
549
|
-
if (!put.ok) throw new Error(`push: the
|
|
570
|
+
if (!put.ok) throw new Error(`push: the destination refused ${twin}'s ${key} (${put.status} ${(await put.text()).slice(0, 200)})`);
|
|
550
571
|
}
|
|
551
572
|
}
|
|
552
573
|
};
|
|
@@ -563,7 +584,7 @@ export async function pushWorldChangeset(name: string, options: PushWorldOptions
|
|
|
563
584
|
const at = new Date().toISOString();
|
|
564
585
|
if (!res.ok || !body.receipts) {
|
|
565
586
|
const refusal = body.refusal ?? [body.error ?? `the remote answered ${res.status}`];
|
|
566
|
-
const application: ChangesetApplication = { at, contentHash: located.changeset.contentHash, outcome: 'refused', refusal, receipts: [], compensation: [] };
|
|
587
|
+
const application: ChangesetApplication = { at, contentHash: located.changeset.contentHash, destination: { url, namespace }, outcome: 'refused', refusal, receipts: [], compensation: [] };
|
|
567
588
|
writeChangesetFile(located.path, withApplication(located.changeset, application));
|
|
568
589
|
return { application, changeset: withApplication(located.changeset, application), world: located.world, path: located.path, remote: { url, namespace } };
|
|
569
590
|
}
|
|
@@ -576,7 +597,7 @@ export async function pushWorldChangeset(name: string, options: PushWorldOptions
|
|
|
576
597
|
}
|
|
577
598
|
// origin advanced by what landed (and what it performed): the cursor moves with it, so the next
|
|
578
599
|
// fetch and the next push start from where origin now stands
|
|
579
|
-
if (body.snapshots && instance.origin) {
|
|
600
|
+
if (trackOrigin && body.snapshots && instance.origin) {
|
|
580
601
|
const twins = Object.keys(body.snapshots);
|
|
581
602
|
const fetched = await fetchFromOrigin(located.world, { root, url, namespace, key: options.key, services: twins, views: body.snapshots });
|
|
582
603
|
for (const twin of twins) {
|
|
@@ -593,6 +614,7 @@ export async function pushWorldChangeset(name: string, options: PushWorldOptions
|
|
|
593
614
|
const byTwin = new Map<string, Array<{ actionId: string } & Receipt>>();
|
|
594
615
|
for (const r of body.receipts) byTwin.set(r.twin, [...(byTwin.get(r.twin) ?? []), r]);
|
|
595
616
|
for (const [twin, receipts] of byTwin) {
|
|
617
|
+
if (!trackOrigin) continue;
|
|
596
618
|
const entry = located.changeset.actions.find((e) => e.service === twin);
|
|
597
619
|
const state = entry?.stateService ?? twin;
|
|
598
620
|
landReceipts(join(instance.dirs.data, twin), state, receipts, at);
|
|
@@ -600,11 +622,11 @@ export async function pushWorldChangeset(name: string, options: PushWorldOptions
|
|
|
600
622
|
const statusOf = (s: Receipt['status']): ApplyReceipt['status'] => (s === 'deployed' ? 'confirmed' : s === 'landed' ? 'replayed' : s === 'refused' || s === 'failed' ? 'failed' : 'skipped');
|
|
601
623
|
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 } : {}) }));
|
|
602
624
|
const outcome: ChangesetApplication['outcome'] = receipts.every((r) => r.status !== 'failed') ? 'applied' : receipts.some((r) => r.status === 'confirmed') ? 'partial' : 'refused';
|
|
603
|
-
const application: ChangesetApplication = { at, contentHash: located.changeset.contentHash, outcome, receipts, compensation: [], ...(outcome === 'refused' ? { refusal: receipts.filter((r) => r.error).map((r) => r.error!) } : {}) };
|
|
625
|
+
const application: ChangesetApplication = { at, contentHash: located.changeset.contentHash, destination: { url, namespace }, outcome, receipts, compensation: [], ...(outcome === 'refused' ? { refusal: receipts.filter((r) => r.error).map((r) => r.error!) } : {}) };
|
|
604
626
|
// the remote's record of who pushed it and who owns it, kept on this copy too (outside the hash)
|
|
605
627
|
const changeset = { ...withApplication(located.changeset, application), ...(body.owner && !located.changeset.owner ? { owner: body.owner } : {}), ...(body.pushedBy ? { pushedBy: body.pushedBy } : {}) };
|
|
606
628
|
writeChangesetFile(located.path, changeset);
|
|
607
|
-
const unsent = namedFailure ? `records the landed entries name did not reach the
|
|
629
|
+
const unsent = namedFailure ? `records the landed entries name did not reach the destination (${errorMessage(namedFailure)}); the next push from this branch that touches ${twins.map(([t]) => t).join(', ')} sends them` : undefined;
|
|
608
630
|
return { ...(unsent ? { unsent } : {}), application, changeset, world: located.world, path: located.path, remote: { url, namespace } };
|
|
609
631
|
}
|
|
610
632
|
|
package/src/covers.ts
CHANGED
|
@@ -881,7 +881,9 @@ export function coverWorld(world: string, repoPath: string, options: CoverageOpt
|
|
|
881
881
|
const uninterceptable: string[] = [];
|
|
882
882
|
const excluded: string[] = [];
|
|
883
883
|
for (const [vendor, sources] of [...detected.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
884
|
-
const
|
|
884
|
+
const managedWire = [...inventory.connections.values()].find(binding => binding.protocol === vendor);
|
|
885
|
+
const explicitlyExcluded = inventory.selection?.exclude.some(s => s.vendor === vendor && (s.usage === undefined || s.usage === 'application'));
|
|
886
|
+
const selected = (!explicitlyExcluded && managedWire !== undefined) || inventory.selection === undefined || (uses.get(vendor) ?? []).some((use) => worldSelectionIncludes(inventory.selection!, vendor, use.usage));
|
|
885
887
|
if (!selected) {
|
|
886
888
|
excluded.push(vendor);
|
|
887
889
|
rows.push({ vendor, detectedVia: sources, twinInWorld: null, status: 'excluded' });
|
|
@@ -897,7 +899,7 @@ export function coverWorld(world: string, repoPath: string, options: CoverageOpt
|
|
|
897
899
|
? [...inventory.connections.values()].find(binding => binding.protocol === managed.scheme)
|
|
898
900
|
: undefined;
|
|
899
901
|
const match = matchId === undefined
|
|
900
|
-
? managedBinding ? { label: managedBinding
|
|
902
|
+
? managedBinding || managedWire ? { label: (managedBinding ?? managedWire)!.label, interceptable: true } : null
|
|
901
903
|
: inventory.ids.get(matchId)!;
|
|
902
904
|
let status: CoverageStatus = match === null ? 'missing' : match.interceptable ? 'covered' : 'uninterceptable';
|
|
903
905
|
let ackRefused: string | undefined;
|
|
@@ -977,7 +979,11 @@ export function coverWorld(world: string, repoPath: string, options: CoverageOpt
|
|
|
977
979
|
const selectorMatches = (selector: WorldSelection['include'][number]) =>
|
|
978
980
|
(selector.usage === undefined || selector.usage === 'application') &&
|
|
979
981
|
(selector.vendor === undefined || connectionNames.includes(selector.vendor));
|
|
982
|
+
const ownedBinding = requirement.env === undefined ? undefined : inventory.connections.get(requirement.env);
|
|
983
|
+
// A declared managed wire is in the World even when --twins selected only SaaS names.
|
|
984
|
+
// Keep an explicit exclusion authoritative; otherwise describe the actual owned connection.
|
|
980
985
|
const connectionSelected = inventory.selection === undefined ||
|
|
986
|
+
(ownedBinding !== undefined && !inventory.selection.exclude.some(selectorMatches)) ||
|
|
981
987
|
(inventory.selection.include.some(selectorMatches) && !inventory.selection.exclude.some(selectorMatches));
|
|
982
988
|
if (!connectionSelected) {
|
|
983
989
|
excluded.push(vendor);
|
package/src/runtime.ts
CHANGED
|
@@ -927,6 +927,15 @@ function namesOrigin(remote: string | undefined, origin: { url: string; namespac
|
|
|
927
927
|
} catch { return false; }
|
|
928
928
|
}
|
|
929
929
|
|
|
930
|
+
/** Whether a delivery names the branch's tracked origin. Config owns the root World's
|
|
931
|
+
* remote; a local branch keeps its own recorded parent instead of adopting that config. */
|
|
932
|
+
export function isWorldOrigin(instance: WorldInstance, destination: { url: string; namespace: string }, root: string): boolean {
|
|
933
|
+
const sameRecorded = instance.origin?.url.replace(/\/+$/, '') === destination.url.replace(/\/+$/, '') && instance.origin?.namespace === destination.namespace;
|
|
934
|
+
if (instance.name !== instance.config) return sameRecorded;
|
|
935
|
+
const configured = loadWorldConfig(instance.configPath, root).config.remotes?.origin;
|
|
936
|
+
return configured ? namesOrigin(configured, destination, root) : sameRecorded;
|
|
937
|
+
}
|
|
938
|
+
|
|
930
939
|
export function saveWorldInstance(instance: WorldInstance): void {
|
|
931
940
|
writeTextAtomic(instanceFile(instance.root, instance.name), `${JSON.stringify(instance, null, 2)}\n`);
|
|
932
941
|
}
|
|
@@ -1603,7 +1612,7 @@ async function issueCredentials(service: WorldServiceConfig, started: WorldServi
|
|
|
1603
1612
|
if (!enclosingSeed) beginPlaceholderPull(service.id, controlRoot);
|
|
1604
1613
|
let answer: Record<string, unknown>;
|
|
1605
1614
|
try {
|
|
1606
|
-
const res = await fetch(new URL(door.path, started.url), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(door.body), signal: AbortSignal.timeout(ISSUE_TIMEOUT_MS) });
|
|
1615
|
+
const res = await fetch(new URL(door.path, started.url), { method: 'POST', headers: { 'content-type': 'application/json', 'x-volter-world-activity': 'provisioning' }, body: JSON.stringify(door.body), signal: AbortSignal.timeout(ISSUE_TIMEOUT_MS) });
|
|
1607
1616
|
if (!res.ok) throw new Error(`answered ${res.status}`);
|
|
1608
1617
|
answer = (await res.json()) as Record<string, unknown>;
|
|
1609
1618
|
} finally {
|
package/src/served-world.ts
CHANGED
|
@@ -49,7 +49,9 @@ import { fetchFromOrigin } from './origin.ts';
|
|
|
49
49
|
import { CONSOLE_BASE, consoleRedirect, serveConsoleApart, type ConsoleMount } from './console-apart.ts';
|
|
50
50
|
import { loadWorldConfig, worldChangesetRules, writeWorldConfig } from './configs.ts';
|
|
51
51
|
import { adaptersFor, credentialPath, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, rewrapTwinCredentials, sealTwinCredential, sealedCredentialInfo, bindLegacyCredentials, setTwinRoot, spanMs } from './root.ts';
|
|
52
|
-
import {
|
|
52
|
+
import { describeActionFields } from '@volter/world-core/runtime';
|
|
53
|
+
import { worldLedgers } from './changeset.ts';
|
|
54
|
+
import { clockFile, downWorld, isWorldOrigin, listWorlds, readWorldBootRecord, statusWorld, upWorld } from './runtime.ts';
|
|
53
55
|
import { assertChangesetRules, type WorldChangesetRules, type WorldInstance } from './schema.ts';
|
|
54
56
|
|
|
55
57
|
export const TOKEN_HEADER = 'x-volter-token';
|
|
@@ -252,7 +254,10 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
|
|
|
252
254
|
// refuse as "already served"), and no twins it started; twins another owner runs were never ours to stop
|
|
253
255
|
let booted: WorldInstance | null = null;
|
|
254
256
|
try {
|
|
255
|
-
|
|
257
|
+
// A view resumes the same app, including the output it owns. Switching to the operator's
|
|
258
|
+
// default output would strand the app output without an ownership record on the next up.
|
|
259
|
+
const retained = listWorlds(worldRoot).some(w => w.name === name) ? readWorldBootRecord(name, worldRoot) : null;
|
|
260
|
+
booted = await upWorld(configRef, { name, root: worldRoot, keepState: true, ...(retained ? { envFile: retained.envFile } : {}) });
|
|
256
261
|
// a root set in the config while the world was stopped reaches the twins' state now, where the deploy reads it
|
|
257
262
|
materializeRoots(loadWorldConfig(configRef, worldRoot).config, booted.dirs.data, worldRoot);
|
|
258
263
|
// credentials sealed before bindings are bound to where they go before any request is let in (instance unset,
|
|
@@ -351,7 +356,7 @@ function streamEnv(id: string, protocol: string): Record<string, string> {
|
|
|
351
356
|
|
|
352
357
|
/** What the doors need of a world: where each twin's state lives and which twins it has. A booted
|
|
353
358
|
* local instance is one; a hosted World describes itself. */
|
|
354
|
-
export type WorldLayout = { dirs: { data: string }; envFile?: string; env?: Record<string, string>; services: Record<string, { url?: string; protocol?: unknown; resolvedPackage?: string; resolvedVersion?: string; pinnedSource?: { package?: string; version?: string } }> };
|
|
359
|
+
export type WorldLayout = { dirs: { data: string }; envFile?: string; env?: Record<string, string>; services: Record<string, { /** Runtime receipts identify non-twin services; older hosts supply a twins-only layout. */ type?: string; url?: string; protocol?: unknown; resolvedPackage?: string; resolvedVersion?: string; pinnedSource?: { package?: string; version?: string } }> };
|
|
355
360
|
/** The host a world's doors run in: its layout, and how a request reaches a twin's wire — a local
|
|
356
361
|
* host forwards to the twin's own port, a hosted World calls the pack in-process. */
|
|
357
362
|
export type DoorHost = {
|
|
@@ -1217,18 +1222,26 @@ export class WorldDoors {
|
|
|
1217
1222
|
|
|
1218
1223
|
/** A twin's workspaces as its discovery door lists them (`GET /twin`'s `screens`: each built workspace screen's id and
|
|
1219
1224
|
* path), which a person is shown at `/<served>/<vendor><path>`. Asked again after a minute, as `enforcesRead` is. */
|
|
1220
|
-
private readonly workspaces = new Map<string, { screens: Array<{ id: string; path: string; host?: string }>; at: number }>();
|
|
1225
|
+
private readonly workspaces = new Map<string, { screens: Array<{ id: string; path: string; host?: string }>; resources: Record<string, { relations: Array<{ field: string; to: string }> }>; at: number }>();
|
|
1221
1226
|
private async screensOf(vendor: string): Promise<Array<{ id: string; path: string; host?: string }>> {
|
|
1222
1227
|
const known = this.workspaces.get(vendor);
|
|
1223
1228
|
if (known !== undefined && Date.now() - known.at < 60_000) return known.screens;
|
|
1224
1229
|
let screens: Array<{ id: string; path: string; host?: string }> = [];
|
|
1230
|
+
let resources: Record<string, { relations: Array<{ field: string; to: string }> }> = {};
|
|
1225
1231
|
const twin = this.host.twinFetch(vendor);
|
|
1226
1232
|
try {
|
|
1227
1233
|
const answer = twin ? await twin(new Request('http://world.local/twin', { headers: { [READ_ONLY_HEADER]: '1' } })) : null;
|
|
1228
|
-
const
|
|
1234
|
+
const manifest = answer?.ok ? await answer.json() as { screens?: unknown; resources?: typeof resources } : null;
|
|
1235
|
+
const listed = manifest?.screens;
|
|
1236
|
+
const described = manifest?.resources;
|
|
1237
|
+
if (described && typeof described === 'object' && !Array.isArray(described)) {
|
|
1238
|
+
for (const [type, metadata] of Object.entries(described)) {
|
|
1239
|
+
if (metadata && Array.isArray(metadata.relations)) resources[type] = { relations: metadata.relations.filter(r => r && typeof r.field === 'string' && typeof r.to === 'string') };
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1229
1242
|
if (Array.isArray(listed)) screens = listed.filter((x): x is { id: string; path: string; host?: string } => typeof x?.id === 'string' && typeof x?.path === 'string').map(({ id, path, host }) => ({ id, path, ...(typeof host === 'string' ? { host } : {}) }));
|
|
1230
1243
|
} catch { screens = []; }
|
|
1231
|
-
this.workspaces.set(vendor, { screens, at: Date.now() });
|
|
1244
|
+
this.workspaces.set(vendor, { screens, resources, at: Date.now() });
|
|
1232
1245
|
return screens;
|
|
1233
1246
|
}
|
|
1234
1247
|
|
|
@@ -1670,7 +1683,7 @@ export class WorldDoors {
|
|
|
1670
1683
|
const delta = diffWorld(this.name, { root: this.worldRoot });
|
|
1671
1684
|
// what another World pushed here is in its changeset already: never this World's uncut changes
|
|
1672
1685
|
const received = new Set(listWorldChangesets({ root: this.worldRoot, world: this.name }).filter((l) => l.changeset.pushedBy).flatMap((l) => l.changeset.actions.map((a) => a.action.id)));
|
|
1673
|
-
return Response.json({ world: this.served, base: { id: delta.base.id, at: delta.base.createdAt ?? null }, changes: delta.actions.filter((a) => !received.has(a.action.id)).map((a) => ({ twin: a.service, entry: a.action })) });
|
|
1686
|
+
return Response.json({ world: this.served, base: { id: delta.base.id, at: delta.base.createdAt ?? null }, changes: delta.actions.filter((a) => !received.has(a.action.id)).map((a) => ({ twin: a.service, entry: a.action, fields: delta.fieldChanges?.[JSON.stringify([a.service, a.stateService, a.action.id])] })) });
|
|
1674
1687
|
}
|
|
1675
1688
|
|
|
1676
1689
|
if (kind === 'log' && name && request.method === 'GET') {
|
|
@@ -1699,13 +1712,16 @@ export class WorldDoors {
|
|
|
1699
1712
|
return Response.json({ twin: name, state, view: cut.view, position: cut.position, tree: readTree(state, controlRoot, { view: cut.view }) });
|
|
1700
1713
|
}
|
|
1701
1714
|
if (kind === 'changesets' && request.method === 'GET') {
|
|
1715
|
+
const instance = statusWorld(this.name, this.worldRoot);
|
|
1716
|
+
const originApplied = (c: Changeset): boolean => Boolean(c.applied && c.applied.outcome !== 'refused' && (!c.applied.destination || isWorldOrigin(instance, c.applied.destination, this.worldRoot)));
|
|
1717
|
+
|
|
1702
1718
|
// with the body's hash as it stands, so a reader sees an approval or a verification made on other bytes as stale
|
|
1703
|
-
if (name) { const l = findWorldChangeset(name, { root: this.worldRoot, world: this.name }); return Response.json({ ...l.changeset, currentHash: changesetContentHash(l.changeset), deployed: this.deployedOf([l.changeset])[0], readiness: changesetReadiness(l.changeset, worldChangesetRules(this.worldRoot)) }); }
|
|
1719
|
+
if (name) { const l = findWorldChangeset(name, { root: this.worldRoot, world: this.name }); return Response.json({ ...l.changeset, originApplied: originApplied(l.changeset), fieldChanges: describeActionFields(worldLedgers(this.name, this.worldRoot), l.changeset.actions), currentHash: changesetContentHash(l.changeset), deployed: this.deployedOf([l.changeset])[0], readiness: changesetReadiness(l.changeset, worldChangesetRules(this.worldRoot)) }); }
|
|
1704
1720
|
const all = listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => l.changeset);
|
|
1705
1721
|
const deployed = this.deployedOf(all);
|
|
1706
1722
|
const rules = worldChangesetRules(this.worldRoot);
|
|
1707
1723
|
// readiness under this World's rules, as deploy reads it: the console says what the kernel decides
|
|
1708
|
-
return Response.json({ changesets: all.map((c, i) => ({ ...c, currentHash: changesetContentHash(c), deployed: deployed[i], readiness: changesetReadiness(c, rules) })) });
|
|
1724
|
+
return Response.json({ changesets: all.map((c, i) => ({ ...c, originApplied: originApplied(c), fieldChanges: describeActionFields(worldLedgers(this.name, this.worldRoot), c.actions), currentHash: changesetContentHash(c), deployed: deployed[i], readiness: changesetReadiness(c, rules) })) });
|
|
1709
1725
|
}
|
|
1710
1726
|
if (kind === 'changesets' && name && verb === 'verify' && request.method === 'POST') {
|
|
1711
1727
|
const outcome = await this.serialized('changesets', () => verifyWorldChangeset(name, { root: this.worldRoot, world: this.name, ephemeral: true }));
|
|
@@ -1815,7 +1831,8 @@ export class WorldDoors {
|
|
|
1815
1831
|
}
|
|
1816
1832
|
if (name === 'push' && request.method === 'POST') {
|
|
1817
1833
|
return Response.json(await this.serialized('push', async () => {
|
|
1818
|
-
const
|
|
1834
|
+
const instance = statusWorld(this.name, this.worldRoot);
|
|
1835
|
+
const queue = listWorldChangesets({ root: this.worldRoot, world: this.name }).filter((l) => { const a = l.changeset.applied; return !a || a.outcome === 'refused' || (a.destination && !isWorldOrigin(instance, a.destination, this.worldRoot)); }).sort((a, b) => a.changeset.createdAt.localeCompare(b.changeset.createdAt));
|
|
1819
1836
|
const pushed = [];
|
|
1820
1837
|
for (const l of queue) {
|
|
1821
1838
|
const outcome = await pushWorldChangeset(l.changeset.name, { root: this.worldRoot, world: this.name, key: key() });
|
|
@@ -1916,7 +1933,8 @@ export class WorldDoors {
|
|
|
1916
1933
|
// every twin of the world as a consumer sees it — the status door mapped over the world; what a
|
|
1917
1934
|
// console renders first, and what a holder of a world token (never the admin's) can ask
|
|
1918
1935
|
const twins: unknown[] = [];
|
|
1919
|
-
for (const vendor of Object.
|
|
1936
|
+
for (const [vendor, service] of Object.entries(this.up().services)) {
|
|
1937
|
+
if (service.type !== undefined && service.type !== 'twin') continue;
|
|
1920
1938
|
const res = await this.handle(new Request(`${url.origin}/-/${this.served}/twins/${vendor}/status`, { headers: request.headers }));
|
|
1921
1939
|
if (res.ok) twins.push(await res.json());
|
|
1922
1940
|
}
|
|
@@ -1974,7 +1992,7 @@ export class WorldDoors {
|
|
|
1974
1992
|
const origin = readBranchMeta(state, this.controlRoot(name))?.origin;
|
|
1975
1993
|
const identity = { mounted: mounted?.resolvedPackage ? { package: mounted.resolvedPackage, version: mounted.resolvedVersion ?? null } : null, pinned: mounted?.pinnedSource ?? null };
|
|
1976
1994
|
const dataOrigin = origin ? { kind: 'shared-history', url: origin.at } : refresh ? { kind: 'vendor-refresh', url: root?.url ?? null } : { kind: 'unrecorded', url: null };
|
|
1977
|
-
return Response.json({ twin: name, state, identity, dataOrigin, execution: { rootConnected: Boolean(root), deploy: root?.deploy ?? null }, protocol: mounted?.protocol ?? null, screens: await this.screensOf(name), root: root ?? null, credential: sealedCredentialInfo(this.worldRoot, name), refresh, position: wholeLog(state, this.controlRoot(name)).length, lastReceipt: last?.receipt ?? null });
|
|
1995
|
+
return Response.json({ twin: name, state, identity, dataOrigin, execution: { rootConnected: Boolean(root), deploy: root?.deploy ?? null }, protocol: mounted?.protocol ?? null, screens: await this.screensOf(name), resources: this.workspaces.get(name)?.resources ?? {}, root: root ?? null, credential: sealedCredentialInfo(this.worldRoot, name), refresh, position: wholeLog(state, this.controlRoot(name)).length, lastReceipt: last?.receipt ?? null });
|
|
1978
1996
|
}
|
|
1979
1997
|
if (verb === 'refresh' && request.method === 'POST') {
|
|
1980
1998
|
const root = rootForControlRoot(this.controlRoot(name), name);
|
|
@@ -2323,7 +2341,8 @@ export class WorldDoors {
|
|
|
2323
2341
|
/** THE MAP: every twin and what it holds, by resource type, and the workspaces it shows. */
|
|
2324
2342
|
private async map(): Promise<unknown> {
|
|
2325
2343
|
const twins: unknown[] = [];
|
|
2326
|
-
for (const vendor of Object.
|
|
2344
|
+
for (const [vendor, service] of Object.entries(this.up().services)) {
|
|
2345
|
+
if (service.type !== undefined && service.type !== 'twin') continue;
|
|
2327
2346
|
const state = this.stateOf(vendor); const controlRoot = this.controlRoot(vendor);
|
|
2328
2347
|
const counts = new Map<string, number>();
|
|
2329
2348
|
// what the World holds as the vendor's own resources: the twin's bookkeeping (a `_` type, as a screens sign-in's token)
|