@volter/world-runtime 3.0.138 → 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.
@@ -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;
@@ -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 origin refused ${twin}'s ${key} (${put.status} ${(await put.text()).slice(0, 200)})`);
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 origin (${errorMessage(namedFailure)}); the next push from this branch that touches ${twins.map(([t]) => t).join(', ')} sends them` : undefined;
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. */
@@ -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 selected = inventory.selection === undefined || (uses.get(vendor) ?? []).some((use) => worldSelectionIncludes(inventory.selection, vendor, use.usage));
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);
@@ -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. */
@@ -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());
@@ -119,6 +119,7 @@ export type WorldLayout = {
119
119
  envFile?: string;
120
120
  env?: Record<string, string>;
121
121
  services: Record<string, {
122
+ type?: string;
122
123
  url?: string;
123
124
  protocol?: unknown;
124
125
  resolvedPackage?: string;
@@ -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 { clockFile, downWorld, listWorlds, statusWorld, upWorld } from "./runtime.js";
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
- booted = await upWorld(configRef, { name, root: worldRoot, keepState: true });
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 listed = answer?.ok ? (await answer.json()).screens : undefined;
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 queue = listWorldChangesets({ root: this.worldRoot, world: this.name }).filter((l) => l.changeset.applied === null || l.changeset.applied.outcome === 'refused').sort((a, b) => a.changeset.createdAt.localeCompare(b.changeset.createdAt));
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.keys(this.up().services)) {
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.keys(this.up().services)) {
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.138",
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.138",
74
- "@volter/world-console": "3.0.133"
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.138",
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 origin refused ${twin}'s ${key} (${put.status} ${(await put.text()).slice(0, 200)})`);
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 origin (${errorMessage(namedFailure)}); the next push from this branch that touches ${twins.map(([t]) => t).join(', ')} sends them` : undefined;
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 selected = inventory.selection === undefined || (uses.get(vendor) ?? []).some((use) => worldSelectionIncludes(inventory.selection!, vendor, use.usage));
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.label, interceptable: true } : null
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 {
@@ -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 { clockFile, downWorld, listWorlds, statusWorld, upWorld } from './runtime.ts';
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
- booted = await upWorld(configRef, { name, root: worldRoot, keepState: true });
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 listed = answer?.ok ? ((await answer.json()) as { screens?: unknown }).screens : undefined;
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 queue = listWorldChangesets({ root: this.worldRoot, world: this.name }).filter((l) => l.changeset.applied === null || l.changeset.applied.outcome === 'refused').sort((a, b) => a.changeset.createdAt.localeCompare(b.changeset.createdAt));
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.keys(this.up().services)) {
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.keys(this.up().services)) {
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)