@volter/world-runtime 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/LICENSE +202 -0
  2. package/dist/known-external-services.json +1108 -0
  3. package/dist/src/ancestry.d.ts +2 -0
  4. package/dist/src/ancestry.js +42 -0
  5. package/dist/src/app-url.d.ts +47 -0
  6. package/dist/src/app-url.js +239 -0
  7. package/dist/src/attach.d.ts +48 -0
  8. package/dist/src/attach.js +87 -0
  9. package/dist/src/branch.d.ts +20 -0
  10. package/dist/src/branch.js +65 -0
  11. package/dist/src/browser-proxy-cli.d.ts +2 -0
  12. package/dist/src/browser-proxy-cli.js +41 -0
  13. package/dist/src/ca-trust.d.ts +5 -0
  14. package/dist/src/ca-trust.js +64 -0
  15. package/dist/src/catalog.d.ts +31 -0
  16. package/dist/src/catalog.js +148 -0
  17. package/dist/src/changeset.d.ts +142 -0
  18. package/dist/src/changeset.js +570 -0
  19. package/dist/src/cli.d.ts +2 -0
  20. package/dist/src/cli.js +1262 -0
  21. package/dist/src/command-lifetime.d.ts +15 -0
  22. package/dist/src/command-lifetime.js +98 -0
  23. package/dist/src/configs.d.ts +18 -0
  24. package/dist/src/configs.js +119 -0
  25. package/dist/src/console-apart.d.ts +38 -0
  26. package/dist/src/console-apart.js +107 -0
  27. package/dist/src/consumers.d.ts +46 -0
  28. package/dist/src/consumers.js +200 -0
  29. package/dist/src/covers.d.ts +183 -0
  30. package/dist/src/covers.js +800 -0
  31. package/dist/src/fixture-env.d.ts +42 -0
  32. package/dist/src/fixture-env.js +221 -0
  33. package/dist/src/host-cli.d.ts +2 -0
  34. package/dist/src/host-cli.js +92 -0
  35. package/dist/src/host-fault-fixture.d.ts +32 -0
  36. package/dist/src/host-fault-fixture.js +100 -0
  37. package/dist/src/host-worker.d.ts +1 -0
  38. package/dist/src/host-worker.js +23 -0
  39. package/dist/src/host.d.ts +38 -0
  40. package/dist/src/host.js +135 -0
  41. package/dist/src/index.d.ts +48 -0
  42. package/dist/src/index.js +35 -0
  43. package/dist/src/infra-cli.d.ts +2 -0
  44. package/dist/src/infra-cli.js +136 -0
  45. package/dist/src/init.d.ts +227 -0
  46. package/dist/src/init.js +1117 -0
  47. package/dist/src/inject-map.d.ts +34 -0
  48. package/dist/src/inject-map.js +56 -0
  49. package/dist/src/lifecycle-record.d.ts +47 -0
  50. package/dist/src/lifecycle-record.js +196 -0
  51. package/dist/src/origin.d.ts +31 -0
  52. package/dist/src/origin.js +139 -0
  53. package/dist/src/pack-facts.d.ts +75 -0
  54. package/dist/src/pack-facts.js +98 -0
  55. package/dist/src/pglite-backing.d.ts +21 -0
  56. package/dist/src/pglite-backing.js +158 -0
  57. package/dist/src/pglite-host.mjs +147 -0
  58. package/dist/src/placeholder.d.ts +20 -0
  59. package/dist/src/placeholder.js +100 -0
  60. package/dist/src/prerequisites.d.ts +21 -0
  61. package/dist/src/prerequisites.js +49 -0
  62. package/dist/src/process-groups.d.ts +4 -0
  63. package/dist/src/process-groups.js +49 -0
  64. package/dist/src/project-inspect.d.ts +109 -0
  65. package/dist/src/project-inspect.js +827 -0
  66. package/dist/src/proxy-daemon.d.ts +2 -0
  67. package/dist/src/proxy-daemon.js +18 -0
  68. package/dist/src/redirect-proxy.d.ts +105 -0
  69. package/dist/src/redirect-proxy.js +665 -0
  70. package/dist/src/reflect.d.ts +74 -0
  71. package/dist/src/reflect.js +392 -0
  72. package/dist/src/resources.d.ts +26 -0
  73. package/dist/src/resources.js +22 -0
  74. package/dist/src/root.d.ts +114 -0
  75. package/dist/src/root.js +312 -0
  76. package/dist/src/run-task-worker.d.ts +1 -0
  77. package/dist/src/run-task-worker.js +38 -0
  78. package/dist/src/run-task.d.ts +18 -0
  79. package/dist/src/run-task.js +48 -0
  80. package/dist/src/runtime-test-support.d.ts +59 -0
  81. package/dist/src/runtime-test-support.js +205 -0
  82. package/dist/src/runtime.d.ts +256 -0
  83. package/dist/src/runtime.js +3502 -0
  84. package/dist/src/schema.d.ts +449 -0
  85. package/dist/src/schema.js +605 -0
  86. package/dist/src/serve.d.ts +30 -0
  87. package/dist/src/serve.js +82 -0
  88. package/dist/src/served-world.d.ts +194 -0
  89. package/dist/src/served-world.js +986 -0
  90. package/dist/src/service-exit.d.ts +46 -0
  91. package/dist/src/service-exit.js +195 -0
  92. package/dist/src/service-recorder.d.ts +1 -0
  93. package/dist/src/service-recorder.js +121 -0
  94. package/dist/src/sibling.d.ts +1 -0
  95. package/dist/src/sibling.js +9 -0
  96. package/dist/src/signals.d.ts +1 -0
  97. package/dist/src/signals.js +11 -0
  98. package/dist/src/storage-capacity.d.ts +8 -0
  99. package/dist/src/storage-capacity.js +61 -0
  100. package/dist/src/tail.d.ts +30 -0
  101. package/dist/src/tail.js +160 -0
  102. package/dist/src/tcp-port.d.ts +2 -0
  103. package/dist/src/tcp-port.js +36 -0
  104. package/dist/src/up-task-worker.d.ts +1 -0
  105. package/dist/src/up-task-worker.js +61 -0
  106. package/dist/src/up-task.d.ts +17 -0
  107. package/dist/src/up-task.js +49 -0
  108. package/dist/src/websocket-relay.d.ts +3 -0
  109. package/dist/src/websocket-relay.js +40 -0
  110. package/known-external-services.json +1108 -0
  111. package/package.json +83 -0
  112. package/src/ancestry.ts +36 -0
  113. package/src/app-url.ts +253 -0
  114. package/src/attach.ts +117 -0
  115. package/src/branch.ts +63 -0
  116. package/src/browser-proxy-cli.ts +44 -0
  117. package/src/ca-trust.ts +57 -0
  118. package/src/catalog.ts +156 -0
  119. package/src/changeset.ts +627 -0
  120. package/src/cli.ts +1111 -0
  121. package/src/command-lifetime.ts +79 -0
  122. package/src/configs.ts +110 -0
  123. package/src/console-apart.ts +90 -0
  124. package/src/consumers.ts +185 -0
  125. package/src/covers.ts +934 -0
  126. package/src/fixture-env.ts +230 -0
  127. package/src/host-cli.ts +90 -0
  128. package/src/host-worker.ts +23 -0
  129. package/src/host.ts +169 -0
  130. package/src/index.ts +171 -0
  131. package/src/infra-cli.ts +133 -0
  132. package/src/init.ts +1316 -0
  133. package/src/inject-map.ts +72 -0
  134. package/src/lifecycle-record.ts +168 -0
  135. package/src/origin.ts +134 -0
  136. package/src/pack-facts.ts +128 -0
  137. package/src/pglite-backing.ts +141 -0
  138. package/src/pglite-host.mjs +147 -0
  139. package/src/placeholder.ts +89 -0
  140. package/src/prerequisites.ts +66 -0
  141. package/src/process-groups.ts +33 -0
  142. package/src/project-inspect.ts +770 -0
  143. package/src/proxy-daemon.ts +21 -0
  144. package/src/redirect-proxy.ts +684 -0
  145. package/src/reflect.ts +440 -0
  146. package/src/resources.ts +22 -0
  147. package/src/root.ts +290 -0
  148. package/src/run-task-worker.ts +27 -0
  149. package/src/run-task.ts +44 -0
  150. package/src/runtime-test-support.ts +208 -0
  151. package/src/runtime.ts +3357 -0
  152. package/src/schema.ts +922 -0
  153. package/src/serve.ts +102 -0
  154. package/src/served-world.ts +812 -0
  155. package/src/service-exit.ts +175 -0
  156. package/src/service-recorder.ts +89 -0
  157. package/src/sibling.ts +10 -0
  158. package/src/signals.ts +10 -0
  159. package/src/storage-capacity.ts +60 -0
  160. package/src/tail.ts +205 -0
  161. package/src/tcp-port.ts +35 -0
  162. package/src/up-task-worker.ts +40 -0
  163. package/src/up-task.ts +45 -0
  164. package/src/websocket-relay.ts +32 -0
@@ -0,0 +1,627 @@
1
+ import { blobDigest, captureHistory, captureParentHistory, getActiveBlobStore, getActiveWorldStore, originHead, withAncestryLock, worldPaths, type HistoryReference } from '@volter/world-core';
2
+ // `volter-world mark` / `diff` / `changeset` — the WORLD-side verbs over the changeset
3
+ // primitive (docs/concepts/the-model.md v0). Thin, exactly like `covers` and `tail` are thin: all
4
+ // the state logic (markers, deltas, content hashing, replay) lives in @volter/world-core's
5
+ // control-plane, beside the `actions.jsonl` ledgers it reads. What lives HERE is the only thing
6
+ // the control plane cannot know — where a world keeps its twins' data, and where a world keeps
7
+ // its own marks and changesets.
8
+ //
9
+ // Discovery mirrors `tail.ts`: a world service's ledgers are
10
+ // `<data>/<service>/<state-dir>/world/<state-service>/actions.jsonl`, and one world service may
11
+ // record under more than one state service. Re-discovered on every call, because a twin's ledger
12
+ // dir does not exist until its first action.
13
+ //
14
+ // STORAGE. Marks and changesets are the world's own artifacts, so they live in the world's state
15
+ // dir beside `instance.json`: `.volter/worlds/<world>/{marks,changesets}/<name>.json`. That makes
16
+ // a changeset share its world's lifecycle — `down --purge` takes the twin data AND the changesets
17
+ // cut from it, which is the honest coupling (a changeset whose ledgers were purged is a fossil).
18
+ // `changeset show|replay|list` take a bare name because a changeset is world-scoped but
19
+ // world-PORTABLE: they scan every world's changeset dir, and an ambiguous name is a loud error
20
+ // asking for `--world`, never a silent pick.
21
+ //
22
+ // Read-only over the twins' data at rest, like `tail` — except `replay`, which is the one verb
23
+ // here that WRITES, and it writes only through the control plane's kernel write path.
24
+ import { tmpdir } from 'node:os';
25
+ import { join, relative, resolve } from 'node:path';
26
+ import {
27
+ approveChangeset,
28
+ rebaseChangeset,
29
+ buildChangeset,
30
+ captureMarker,
31
+ changesetHashMatches,
32
+ changesetReadiness,
33
+ diffLedgers,
34
+ normalizeChangeset,
35
+ replayChangeset,
36
+ runChangesetVerifiers,
37
+ stateDirName,
38
+ withApplication,
39
+ withVerification,
40
+ worldBootMarker,
41
+ assertSafeChangesetName,
42
+ assertMarkerBelongsTo,
43
+ CHANGESET_KIND,
44
+ MARKER_KIND,
45
+ WORLD_BOOT_MARKER_ID,
46
+ } from '@volter/world-core';
47
+ import type { ApplyReceipt, Receipt, ChangesetApplication, RebaseReport, RebaseTarget, Changeset, ChangesetApproval, ChangesetReadiness, ChangesetVerification, ChangesetVerifier, LedgerDelta, LedgerRef, ReplayReport, ReplayTarget, WorldMarker } from '@volter/world-core';
48
+ import { instanceDir, listWorlds, saveWorldInstance, statusWorld } from './runtime.ts';
49
+ import { fetchFromOrigin } from './origin.ts';
50
+ import type { WorldInstance } from './schema.ts';
51
+ import { fetchReady, isPathRemote, landChangeset, landReceipts, localWorld, TOKEN_HEADER } from './served-world.ts';
52
+ import { loadWorldChecks } from './root.ts';
53
+ import { originEntries, parentEntries, readBranchMeta, readTree, rebaseBranch, runChecks, wholeLog, writeBranchMeta } from '@volter/world-core';
54
+
55
+ export type WorldRootOptions = { root?: string };
56
+
57
+ function resolveRoot(root?: string): string {
58
+ return resolve(root ?? process.cwd());
59
+ }
60
+
61
+ /** The world instance, or a loud error that names what this repo actually has. `statusWorld`'s
62
+ * own "not found" is correct but bare; a verb an operator reaches for by name should say which
63
+ * names exist. */
64
+ function requireWorld(name: string, root: string): WorldInstance {
65
+ try {
66
+ return statusWorld(name, root);
67
+ } catch (error) {
68
+ if (!/World instance not found/.test(String((error as Error).message))) throw error;
69
+ const known = listWorlds(root).map((world) => world.name);
70
+ throw new Error(`World "${name}" not found in ${root}${known.length ? ` (known worlds: ${known.join(', ')})` : ' (no worlds here)'} — boot it with \`volter-world up <config> --env-file <path> --name ${name}\``);
71
+ }
72
+ }
73
+
74
+ /** Every state service a world service currently records actions under (often exactly one). */
75
+ function stateServicesFor(controlRoot: string): string[] {
76
+ const stateRoot = join(controlRoot, stateDirName(), 'world');
77
+ const store = getActiveWorldStore();
78
+ return store.list(stateRoot)
79
+ .filter((name) => store.stat(join(stateRoot, name))?.isDirectory && ['actions.jsonl', 'events.jsonl', 'branch.json'].some((f) => store.exists(join(stateRoot, name, f))))
80
+ .sort();
81
+ }
82
+
83
+ /** Every action ledger in a world, as control-plane ledger references. */
84
+ export function worldLedgers(name: string, root?: string): LedgerRef[] {
85
+ const resolvedRoot = resolveRoot(root);
86
+ const instance = requireWorld(name, resolvedRoot);
87
+ const ledgers: LedgerRef[] = [];
88
+ for (const service of Object.keys(instance.services).sort()) {
89
+ const controlRoot = join(instance.dirs.data, service);
90
+ for (const stateService of stateServicesFor(controlRoot)) {
91
+ ledgers.push({ service, stateService, controlRoot });
92
+ }
93
+ }
94
+ return ledgers;
95
+ }
96
+
97
+ // ── marks ──────────────────────────────────────────────────────────────────────────────────────
98
+
99
+ export function worldMarksDir(name: string, root?: string): string {
100
+ return join(instanceDir(resolveRoot(root), name), 'marks');
101
+ }
102
+
103
+ export function worldChangesetsDir(name: string, root?: string): string {
104
+ return join(instanceDir(resolveRoot(root), name), 'changesets');
105
+ }
106
+
107
+ /** `mark-<compact-utc>-<rand>` — sortable by name, collision-free within a millisecond. */
108
+ function defaultMarkerId(now: Date): string {
109
+ const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
110
+ return `mark-${stamp}-${Math.random().toString(36).slice(2, 6)}`;
111
+ }
112
+
113
+ /** Capture a cross-service base marker for `world` and persist it. */
114
+ /** A world mark with the note it was taken with, if any: what the moment was, in words (a commit's message). The
115
+ * note is the runtime's, beside world-core's marker, which reads no field of it. */
116
+ export type NotedMarker = WorldMarker & { note?: string };
117
+
118
+ /** The longest note a mark keeps; a longer one is cut. */
119
+ export const MARK_NOTE_MAX = 600;
120
+
121
+ export function markWorld(name: string, options: WorldRootOptions & { id?: string; now?: Date; note?: string } = {}): NotedMarker {
122
+ const root = resolveRoot(options.root);
123
+ requireWorld(name, root);
124
+ const now = options.now ?? new Date();
125
+ const id = options.id ?? defaultMarkerId(now);
126
+ assertSafeChangesetName(id, 'marker');
127
+ const captured = captureMarker({ id, world: name, ledgers: worldLedgers(name, root), createdAt: now.toISOString() });
128
+ const note = options.note?.trim().slice(0, MARK_NOTE_MAX);
129
+ const marker: NotedMarker = note ? { ...captured, note } : captured;
130
+ getActiveWorldStore().writeAtomic(join(worldMarksDir(name, root), `${id}.json`), `${JSON.stringify(marker, null, 2)}\n`);
131
+ return marker;
132
+ }
133
+
134
+ /** Every mark recorded in a world, oldest first. */
135
+ export function listWorldMarks(name: string, root?: string): NotedMarker[] {
136
+ const dir = worldMarksDir(name, root);
137
+ return getActiveWorldStore().list(dir)
138
+ .filter((file) => file.endsWith('.json'))
139
+ .map((file) => readMarkerFile(join(dir, file)))
140
+ .sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : a.id < b.id ? -1 : 1));
141
+ }
142
+
143
+ function readMarkerFile(path: string): NotedMarker {
144
+ const marker = JSON.parse(readStored(path)) as NotedMarker;
145
+ if (marker.kind !== MARKER_KIND) throw new Error(`${path}: not a world marker (kind=${JSON.stringify(marker.kind)})`);
146
+ return marker;
147
+ }
148
+
149
+ /**
150
+ * The base a diff/changeset is taken against:
151
+ * explicit `--base <id>` → that mark (which must belong to THIS world),
152
+ * otherwise the world's most recent mark,
153
+ * otherwise `world-boot` — position 0 on every ledger, i.e. everything this world recorded.
154
+ *
155
+ * `--base world-boot` is spellable explicitly, so "show me the whole session" never requires
156
+ * deleting marks.
157
+ */
158
+ export function resolveBaseMarker(name: string, options: WorldRootOptions & { base?: string } = {}): WorldMarker {
159
+ const root = resolveRoot(options.root);
160
+ const instance = requireWorld(name, root);
161
+ const boot = (): WorldMarker => worldBootMarker(name, instance.createdAt);
162
+ if (options.base === WORLD_BOOT_MARKER_ID) return boot();
163
+ if (options.base) {
164
+ // A marker id may address a mark in ANOTHER world — read it wherever it lives so the
165
+ // cross-world case fails with "captured in world X, not Y" rather than "no such mark".
166
+ const found = findMarkerAnywhere(root, options.base);
167
+ if (!found) {
168
+ const known = listWorldMarks(name, root).map((mark) => mark.id);
169
+ throw new Error(`No marker "${options.base}" (world "${name}" has: ${known.length ? known.join(', ') : 'no marks yet — run `volter-world mark ' + name + '`'})`);
170
+ }
171
+ assertMarkerBelongsTo(found, name);
172
+ return found;
173
+ }
174
+ const marks = listWorldMarks(name, root);
175
+ return marks.length ? marks[marks.length - 1]! : boot();
176
+ }
177
+
178
+ /** Look for a marker id in every world under `root` — the seam that makes a wrong-world base a
179
+ * precise error instead of a confusing "not found". */
180
+ function findMarkerAnywhere(root: string, id: string): WorldMarker | null {
181
+ for (const world of listWorlds(root)) {
182
+ const path = join(worldMarksDir(world.name, root), `${id}.json`);
183
+ if (getActiveWorldStore().exists(path)) return readMarkerFile(path);
184
+ }
185
+ return null;
186
+ }
187
+
188
+ // ── diff ───────────────────────────────────────────────────────────────────────────────────────
189
+
190
+ /** The world's ledger delta since `base` (default: the last mark, else world-boot). */
191
+ export function diffWorld(name: string, options: WorldRootOptions & { base?: string } = {}): LedgerDelta {
192
+ const root = resolveRoot(options.root);
193
+ const base = resolveBaseMarker(name, { root, ...(options.base === undefined ? {} : { base: options.base }) });
194
+ return diffLedgers({ world: name, ledgers: worldLedgers(name, root), base });
195
+ }
196
+
197
+ // ── changesets ─────────────────────────────────────────────────────────────────────────────────
198
+
199
+ export type ChangesetLocation = { changeset: Changeset; world: string; path: string };
200
+
201
+ /** Freeze the current delta into `<world>/changesets/<name>.json`. */
202
+ export function createWorldChangeset(
203
+ world: string,
204
+ name: string,
205
+ options: WorldRootOptions & { base?: string; verifiers?: ChangesetVerifier[]; now?: Date; overwrite?: boolean; message?: string } = {},
206
+ ): Changeset {
207
+ const root = resolveRoot(options.root);
208
+ assertSafeChangesetName(name);
209
+ const dir = worldChangesetsDir(world, root);
210
+ const path = join(dir, `${name}.json`);
211
+ if (getActiveWorldStore().exists(path) && !options.overwrite) {
212
+ throw new Error(`Changeset "${name}" already exists in world "${world}" (${path}) — pick another name, or pass --force to replace it`);
213
+ }
214
+ // re-cutting an existing name (overwrite) starts from that changeset's own base, not from its cut
215
+ const base = options.base ?? (getActiveWorldStore().exists(path) ? readChangesetFile(path).base : undefined);
216
+ const delta = diffWorld(world, { root, ...(base === undefined ? {} : { base }) });
217
+ // where the parent log stands for each twin the changeset touches (PROTOCOL 2: the cut position a rebase reads from)
218
+ const cutInstance = requireWorld(world, root);
219
+ const cut: Record<string, HistoryReference> = {};
220
+ for (const a of delta.actions) if (!(a.service in cut)) { const cr = join(cutInstance.dirs.data, a.service); const st = a.stateService ?? a.service; cut[a.service] = captureParentHistory(st, cr); }
221
+ const changeset = buildChangeset({
222
+ name,
223
+ world,
224
+ base: delta.base.id,
225
+ actions: delta.actions,
226
+ cut,
227
+ ...(options.verifiers ? { verifiers: options.verifiers } : {}),
228
+ ...(options.now ? { createdAt: options.now.toISOString() } : {}),
229
+ ...(options.message === undefined ? {} : { message: options.message }),
230
+ });
231
+ getActiveWorldStore().write(path, `${JSON.stringify(changeset, null, 2)}\n`);
232
+ // a changeset records where the log was cut (contract "The model"): the next one starts here
233
+ markWorld(world, { root, id: `cut-${name}`, ...(options.now ? { now: options.now } : {}) });
234
+ return changeset;
235
+ }
236
+
237
+ function readChangesetFile(path: string): Changeset {
238
+ const changeset = JSON.parse(readStored(path)) as Changeset;
239
+ if (changeset.kind !== CHANGESET_KIND) throw new Error(`${path}: not a changeset (kind=${JSON.stringify(changeset.kind)})`);
240
+ // a v0 object lacks the v1 lifecycle fields on disk; filling them is hash-neutral
241
+ return normalizeChangeset(changeset);
242
+ }
243
+
244
+ /** Persist a changeset back where it was found — verify/approve write THROUGH this, so the
245
+ * object on disk is always the object the verbs returned. */
246
+ function writeChangesetFile(path: string, changeset: Changeset): void {
247
+ getActiveWorldStore().write(path, `${JSON.stringify(changeset, null, 2)}\n`);
248
+ }
249
+
250
+ /** A world file's content through the active store; a missing file is an error naming it. */
251
+ function readStored(path: string): string {
252
+ const text = getActiveWorldStore().read(path);
253
+ if (text === null) throw new Error(`ENOENT: no such file, open '${path}'`);
254
+ return text;
255
+ }
256
+
257
+ /** Every changeset in every world under `root` (or one world with `world`), newest first. */
258
+ export function listWorldChangesets(options: WorldRootOptions & { world?: string } = {}): ChangesetLocation[] {
259
+ const root = resolveRoot(options.root);
260
+ const worldNames = options.world ? [options.world] : listWorlds(root).map((world) => world.name);
261
+ const found: ChangesetLocation[] = [];
262
+ for (const world of worldNames) {
263
+ const dir = worldChangesetsDir(world, root);
264
+ for (const file of getActiveWorldStore().list(dir).filter((entry) => entry.endsWith('.json')).sort()) {
265
+ const path = join(dir, file);
266
+ found.push({ changeset: readChangesetFile(path), world, path });
267
+ }
268
+ }
269
+ return found.sort((a, b) => (a.changeset.createdAt > b.changeset.createdAt ? -1 : a.changeset.createdAt < b.changeset.createdAt ? 1 : 0));
270
+ }
271
+
272
+ /** Resolve a bare changeset name across worlds. Ambiguity is an error, never a guess. */
273
+ export function findWorldChangeset(name: string, options: WorldRootOptions & { world?: string } = {}): ChangesetLocation {
274
+ const root = resolveRoot(options.root);
275
+ assertSafeChangesetName(name);
276
+ const matches = listWorldChangesets({ root, ...(options.world ? { world: options.world } : {}) }).filter((entry) => entry.changeset.name === name);
277
+ if (matches.length === 1) return matches[0]!;
278
+ if (matches.length > 1) {
279
+ throw new Error(`Changeset "${name}" exists in more than one world (${matches.map((m) => m.world).sort().join(', ')}) — disambiguate with --world <name>`);
280
+ }
281
+ const known = listWorldChangesets({ root, ...(options.world ? { world: options.world } : {}) });
282
+ const scope = options.world ? `world "${options.world}"` : `${root}`;
283
+ throw new Error(`No changeset "${name}" in ${scope} (have: ${known.length ? known.map((entry) => `${entry.changeset.name} [${entry.world}]`).join(', ') : 'none — create one with `volter-world changeset create <world> <name>`'})`);
284
+ }
285
+
286
+ // ── replay ─────────────────────────────────────────────────────────────────────────────────────
287
+
288
+ /**
289
+ * Which state service a replayed action lands in for a given target world service:
290
+ * the changeset's own state service when the target already records under it (or records
291
+ * nothing yet — a fresh world, the CI case), the target's single existing state service when
292
+ * it uses a different name, and a loud error when the target records under several and none of
293
+ * them is the one the changeset names (there is no correct guess there).
294
+ */
295
+ function resolveTargetStateService(into: string, service: string, controlRoot: string, wanted: string): string {
296
+ const existing = stateServicesFor(controlRoot);
297
+ if (existing.length === 0 || existing.includes(wanted)) return wanted;
298
+ if (existing.length === 1) return existing[0]!;
299
+ throw new Error(`World "${into}" service "${service}" records actions under multiple state services (${existing.join(', ')}) and none is "${wanted}" — cannot decide where to replay`);
300
+ }
301
+
302
+ /** The replay targets a changeset needs inside an existing world (services the world lacks are
303
+ * left out — `replayChangeset` reports them loudly by name). */
304
+ function worldReplayTargets(changeset: Changeset, into: string, root: string): { targets: ReplayTarget[]; available: string[]; dataDir: string } {
305
+ const instance = requireWorld(into, root);
306
+ const available = Object.keys(instance.services).sort();
307
+ const targets: ReplayTarget[] = [];
308
+ for (const service of [...new Set(changeset.actions.map((entry) => entry.service))]) {
309
+ if (!instance.services[service]) continue; // reported as a missing twin by replayChangeset
310
+ const controlRoot = join(instance.dirs.data, service);
311
+ const wanted = changeset.actions.find((entry) => entry.service === service)!.stateService;
312
+ targets.push({ service, stateService: resolveTargetStateService(into, service, controlRoot, wanted), controlRoot });
313
+ }
314
+ return { targets, available, dataDir: instance.dirs.data };
315
+ }
316
+
317
+ /** Replay a changeset into `into`'s twins, through the control plane's kernel write path. */
318
+ export async function replayWorldChangeset(
319
+ name: string,
320
+ options: WorldRootOptions & { into: string; world?: string },
321
+ ): Promise<ReplayReport> {
322
+ const root = resolveRoot(options.root);
323
+ const located = findWorldChangeset(name, { root, ...(options.world ? { world: options.world } : {}) });
324
+ const { targets, available } = worldReplayTargets(located.changeset, options.into, root);
325
+ return replayChangeset(located.changeset, { into: options.into, targets, available });
326
+ }
327
+
328
+ // ── verify / approve / status — the operational-PR contract (v1) ───────────────────────────────
329
+
330
+ export type VerifyWorldOutcome = {
331
+ changeset: Changeset;
332
+ verification: ChangesetVerification;
333
+ report: ReplayReport;
334
+ world: string;
335
+ path: string;
336
+ };
337
+
338
+ /** The label a throwaway verify target reports as `into` — not a world name on purpose. */
339
+ export const EPHEMERAL_VERIFY_TARGET = 'ephemeral';
340
+
341
+ /**
342
+ * `volter-world changeset verify <name> --into <world> | --ephemeral`: replay the changeset
343
+ * into a clean target, run its verifiers against the post-replay projected state, and record
344
+ * the outcome ON the object as `verification` — REPLACING any prior run (the record carries
345
+ * its own provenance: when, into what, against which body hash and world digest). The body
346
+ * hash never moves: verification is about-the-body metadata, like approvals.
347
+ *
348
+ * `--ephemeral` builds a throwaway replay target (fresh empty ledgers per twin, no world
349
+ * booted), verifies against it, and removes it — the zero-setup CI check. `--into` verifies
350
+ * inside an existing world, whose twins must cover the changeset AND its verifiers.
351
+ */
352
+ export async function verifyWorldChangeset(
353
+ name: string,
354
+ options: WorldRootOptions & { into?: string; ephemeral?: boolean; world?: string; now?: Date },
355
+ ): Promise<VerifyWorldOutcome> {
356
+ const root = resolveRoot(options.root);
357
+ if (Boolean(options.into) === Boolean(options.ephemeral)) {
358
+ throw new Error('volter-world changeset verify: pass exactly one of --into <world> (verify inside an existing world) or --ephemeral (a throwaway replay target)');
359
+ }
360
+ const located = findWorldChangeset(name, { root, ...(options.world ? { world: options.world } : {}) });
361
+ const changeset = located.changeset;
362
+ if (!changesetHashMatches(changeset)) {
363
+ throw new Error(`Refusing to verify changeset "${name}": its stored contentHash does not match its body — the object drifted after authoring, and a verification would launder that drift`);
364
+ }
365
+
366
+ const intoLabel = options.into ?? EPHEMERAL_VERIFY_TARGET;
367
+ let scratch: string | null = null;
368
+ let targets: ReplayTarget[];
369
+ let available: string[] | undefined;
370
+ if (options.into) {
371
+ const resolved = worldReplayTargets(changeset, options.into, root);
372
+ targets = resolved.targets;
373
+ available = resolved.available;
374
+ // a verifier may check a twin the actions never touched — it still needs a real target
375
+ for (const verifier of changeset.verifiers) {
376
+ if (targets.some((target) => target.service === verifier.service)) continue;
377
+ if (!available.includes(verifier.service)) {
378
+ throw new Error(`Cannot verify changeset "${name}" in world "${options.into}": verifier "${verifier.id}" checks twin "${verifier.service}", which that world does not have (world "${options.into}" has: ${available.join(', ') || 'no services'})`);
379
+ }
380
+ const controlRoot = join(resolved.dataDir, verifier.service);
381
+ targets.push({ service: verifier.service, stateService: resolveTargetStateService(options.into, verifier.service, controlRoot, verifier.service), controlRoot });
382
+ }
383
+ } else {
384
+ scratch = join(tmpdir(), `volter-world-verify-${crypto.randomUUID()}`);
385
+ const pairs = new Map<string, ReplayTarget>();
386
+ for (const entry of changeset.actions) {
387
+ pairs.set(`${entry.service} ${entry.stateService}`, { service: entry.service, stateService: entry.stateService, controlRoot: join(scratch, entry.service) });
388
+ }
389
+ for (const verifier of changeset.verifiers) {
390
+ if (![...pairs.values()].some((target) => target.service === verifier.service)) {
391
+ pairs.set(`${verifier.service} ${verifier.service}`, { service: verifier.service, stateService: verifier.service, controlRoot: join(scratch, verifier.service) });
392
+ }
393
+ }
394
+ targets = [...pairs.values()];
395
+ }
396
+
397
+ try {
398
+ const report = await replayChangeset(changeset, { into: intoLabel, targets, ...(available ? { available } : {}) });
399
+ const at = (options.now ?? new Date()).toISOString();
400
+ const verification = runChangesetVerifiers(changeset, targets, { at, into: intoLabel });
401
+ // the world's checks (CI on deployment): every entry, against the replayed tree
402
+ const checks = await loadWorldChecks(root);
403
+ const refusals: NonNullable<ChangesetVerification['refusals']> = [];
404
+ for (const entry of changeset.actions) {
405
+ const target = targets.find((t) => t.service === entry.service && t.stateService === entry.stateService) ?? targets.find((t) => t.service === entry.service);
406
+ const verdict = await runChecks(checks, entry.action, target ? readTree(target.stateService, target.controlRoot) : []);
407
+ if (verdict) refusals.push({ actionId: entry.action.id, service: entry.service, check: verdict.name, reason: verdict.reason });
408
+ }
409
+ if (refusals.length) { verification.refusals = refusals; verification.passed = false; }
410
+ const updated = withVerification(changeset, verification);
411
+ writeChangesetFile(located.path, updated);
412
+ return { changeset: updated, verification, report, world: located.world, path: located.path };
413
+ } finally {
414
+ if (scratch) getActiveWorldStore().remove(scratch);
415
+ }
416
+ }
417
+
418
+ export type ApproveWorldOutcome = {
419
+ changeset: Changeset;
420
+ approval: ChangesetApproval;
421
+ world: string;
422
+ path: string;
423
+ };
424
+
425
+ /** `volter-world changeset approve <name> --as <principal>`: append an approval bound to the
426
+ * current body hash. The control plane refuses a drifted object loudly. */
427
+ export function approveWorldChangeset(
428
+ name: string,
429
+ options: WorldRootOptions & { principal: string; note?: string; world?: string; now?: Date },
430
+ ): ApproveWorldOutcome {
431
+ const root = resolveRoot(options.root);
432
+ const located = findWorldChangeset(name, { root, ...(options.world ? { world: options.world } : {}) });
433
+ const { changeset, approval } = approveChangeset(located.changeset, {
434
+ principal: options.principal,
435
+ ...(options.note ? { note: options.note } : {}),
436
+ ...(options.now ? { at: options.now.toISOString() } : {}),
437
+ });
438
+ writeChangesetFile(located.path, changeset);
439
+ return { changeset, approval, world: located.world, path: located.path };
440
+ }
441
+
442
+ /** `volter-world changeset status <name>`: the apply-readiness gate, recomputed from the
443
+ * object. Truth only — pushing is v2's job. */
444
+ export function statusWorldChangeset(
445
+ name: string,
446
+ options: WorldRootOptions & { world?: string } = {},
447
+ ): ChangesetReadiness {
448
+ const root = resolveRoot(options.root);
449
+ const located = findWorldChangeset(name, { root, ...(options.world ? { world: options.world } : {}) });
450
+ return changesetReadiness(located.changeset);
451
+ }
452
+
453
+ // ── governed push: `volter-world changeset push` ─────────────────────────────────────────
454
+ //
455
+ // A local world never holds a vendor credential (docs/concepts/the-model.md: a deployment exists
456
+ // only where the sealed credential lives). A push goes to the REMOTE — the hosted twin, the
457
+ // world's origin — through its push door with a twins key; the remote refreshes, deploys the
458
+ // changeset to the vendor with its own keys, and answers with the receipts, which are recorded on
459
+ // the object here.
460
+
461
+ export type PushWorldOptions = WorldRootOptions & {
462
+ world?: string;
463
+ /** the remote's twins url and namespace; default to the world's recorded origin */
464
+ to?: string;
465
+ namespace?: string;
466
+ /** the namespace key (a push is a write; the read key is refused) */
467
+ key: string;
468
+ force?: boolean;
469
+ };
470
+ export type PushWorldOutcome = { application: ChangesetApplication; changeset: Changeset; world: string; path: string; remote: { url: string; namespace: string }; /** the entries landed; these named records did not follow them (a warning, not a refusal) */ unsent?: string };
471
+
472
+ export async function pushWorldChangeset(name: string, options: PushWorldOptions): Promise<PushWorldOutcome> {
473
+ // PUSH (docs/concepts/the-model.md): the changeset goes to the served world's push door; its
474
+ // entries land on that world's log, and each receipt (landed under gated/hold, deployed/refused/
475
+ // failed under auto) is landed here on the parent log too, so `log --receipts` shows it and the
476
+ // entry stops being unpushed. The changeset object records the application as before.
477
+ const root = resolveRoot(options.root);
478
+ const located = findWorldChangeset(name, { root, ...(options.world ? { world: options.world } : {}) });
479
+ const instance = requireWorld(located.world, root);
480
+ const url = (options.to ?? instance.origin?.url)?.replace(/\/+$/, '');
481
+ const namespace = options.namespace ?? instance.origin?.namespace;
482
+ if (!url || !namespace) throw new Error(`World "${located.world}" has no origin to push to — \`volter remote add origin <url> --token <token>\` names one`);
483
+ // over http to a served world's push door, or straight into a world at a path (git's file transport)
484
+ let res: { ok: boolean; status: number }; let body: { receipts?: Array<{ actionId: string; twin: string } & Receipt>; error?: string; refusal?: string[]; position?: Record<string, number>; snapshots?: Record<string, HistoryReference> };
485
+ const expect: Record<string, HistoryReference> = {};
486
+ for (const a of located.changeset.actions) {
487
+ const meta = readBranchMeta(a.stateService ?? a.service, join(instance.dirs.data, a.service));
488
+ const pointer = meta?.origin ?? meta?.parent;
489
+ if (meta?.origin?.incomplete) throw new Error('The branch cuts through its tracked origin; pull and rebase before pushing');
490
+ const view = isPathRemote(url) ? pointer?.view : pointer?.remoteView;
491
+ if (pointer && view) expect[a.service] = { view, position: pointer.position };
492
+ }
493
+ // THE RESOURCE PAYLOADS the entries name (uploaded media, file bytes), in two phases. Content-addressed
494
+ // blobs (a key segment that IS a sha256) never change, so they go before the entries: a refused push
495
+ // leaves only bytes nothing names yet. Records kept by name (an upload's status, a media record) are the
496
+ // branch's state moving forward and go only after the entries landed, so a push the origin refuses
497
+ // (drift) never overwrites the origin's newer records.
498
+ const blobs = getActiveBlobStore();
499
+ const twins = [...new Map(located.changeset.actions.map((a) => [a.service, a.stateService ?? a.service])).entries()];
500
+ const ownBlobs = async (twin: string, state: string): Promise<Array<{ key: string; path: string }>> => {
501
+ const dir = worldPaths(state, join(instance.dirs.data, twin)).resources;
502
+ return (await blobs.list(dir)).map((path) => ({ key: relative(dir, path).split('\\').join('/'), path }));
503
+ };
504
+ // content-addressed means a segment of the key IS the digest of the bytes it holds: such bytes never
505
+ // change. A name built on a digest of something else (a record keyed by a hash of an action id, a
506
+ // type keyed by its blob's digest) may change, so it is a record kept by name.
507
+ // each blob is read and hashed once per push, and a key with no 64-hex segment is never read to classify it
508
+ const digests = new Map<string, string | null>();
509
+ const digestOf = async (path: string): Promise<string | null> => {
510
+ if (!digests.has(path)) { const bytes = await blobs.get(path); digests.set(path, bytes === null ? null : blobDigest(bytes)); }
511
+ return digests.get(path)!;
512
+ };
513
+ const contentAddressed = async (key: string, path: string): Promise<boolean> => {
514
+ if (!key.split('/').some((seg) => /^[0-9a-f]{64}$/.test(seg))) return false;
515
+ const digest = await digestOf(path);
516
+ return digest !== null && key.split('/').includes(digest);
517
+ };
518
+ const sendBlobs = async (phase: 'content' | 'named'): Promise<void> => {
519
+ const wanted = async (key: string, path: string): Promise<boolean> => (phase === 'content') === (await contentAddressed(key, path));
520
+ if (isPathRemote(url)) {
521
+ const target = localWorld(url);
522
+ for (const [twin, state] of twins) {
523
+ const there = worldPaths(state, join(target.instance.dirs.data, twin)).resources;
524
+ for (const { key, path } of await ownBlobs(twin, state)) {
525
+ if (!(await wanted(key, path))) continue;
526
+ const held = await blobs.get(join(there, key));
527
+ if (held !== null && blobDigest(held) === (await digestOf(path))) continue;
528
+ const bytes = await blobs.get(path);
529
+ if (bytes) await blobs.put(join(there, key), bytes);
530
+ }
531
+ }
532
+ return;
533
+ }
534
+ for (const [twin, state] of twins) {
535
+ for (const { key, path } of await ownBlobs(twin, state)) {
536
+ if (!(await wanted(key, path))) continue;
537
+ const at = `${url}/-/${namespace}/blobs/${twin}/${key.split('/').map(encodeURIComponent).join('/')}`;
538
+ const head = await fetch(at, { method: 'HEAD', headers: { [TOKEN_HEADER]: options.key } });
539
+ // no blobs door for this twin there (older code, or the twin is not in that World): it takes entries only
540
+ if (head.status === 404 && head.headers.get('x-volter-blob') !== 'absent') break;
541
+ if (head.status === 200 && head.headers.get('x-volter-blob-sha256') === (await digestOf(path))) continue;
542
+ const bytes = await blobs.get(path);
543
+ if (!bytes) continue;
544
+ const put = await fetch(at, { method: 'PUT', headers: { [TOKEN_HEADER]: options.key, 'content-type': 'application/octet-stream' }, body: new Blob([bytes as Uint8Array<ArrayBuffer>]) });
545
+ if (!put.ok) throw new Error(`push: the origin refused ${twin}'s ${key} (${put.status} ${(await put.text()).slice(0, 200)})`);
546
+ }
547
+ }
548
+ };
549
+ await sendBlobs('content');
550
+ if (isPathRemote(url)) {
551
+ try { const target = localWorld(url);
552
+ const receipts = await landChangeset(target.name, target.root, target.instance, located.changeset, expect); const position = Object.fromEntries([...new Set(located.changeset.actions.map((a) => a.service))].map((twin) => { const st = located.changeset.actions.find((e) => e.service === twin)?.stateService ?? twin; return [twin, wholeLog(st, join(target.instance.dirs.data, twin)).length]; })); const snapshots = Object.fromEntries(Object.keys(position).map(twin => { const state = located.changeset.actions.find(a => a.service === twin)?.stateService ?? twin; const head = captureHistory(state, join(target.instance.dirs.data, twin)); return [twin, { view: head.view, position: head.position }]; })); body = { receipts, position, snapshots }; res = { ok: true, status: 200 }; }
553
+ catch (error) { body = { error: error instanceof Error ? error.message : String(error) }; res = { ok: false, status: 409 }; }
554
+ } else {
555
+ // the client's position on each twin rides along (its fetch cursor): the door refuses when origin moved past it
556
+ const answer = await fetchReady(`${url}/-/${namespace}/push`, { method: 'POST', headers: { [TOKEN_HEADER]: options.key, 'content-type': 'application/json' }, body: JSON.stringify({ changeset: located.changeset, expect }) });
557
+ body = (await answer.json()) as typeof body; res = { ok: answer.ok, status: answer.status };
558
+ }
559
+ const at = new Date().toISOString();
560
+ if (!res.ok || !body.receipts) {
561
+ const refusal = body.refusal ?? [body.error ?? `the remote answered ${res.status}`];
562
+ const application: ChangesetApplication = { at, contentHash: located.changeset.contentHash, outcome: 'refused', refusal, receipts: [], compensation: [] };
563
+ writeChangesetFile(located.path, withApplication(located.changeset, application));
564
+ return { application, changeset: withApplication(located.changeset, application), world: located.world, path: located.path, remote: { url, namespace } };
565
+ }
566
+
567
+ // the entries landed: the records they name follow (retried; a failure is reported only after this
568
+ // push's application and receipts are recorded, so the entries that landed are never forgotten here)
569
+ let namedFailure: unknown = null;
570
+ for (let attempt = 0; attempt < 3; attempt += 1) {
571
+ try { await sendBlobs('named'); namedFailure = null; break; } catch (error) { namedFailure = error; }
572
+ }
573
+ // origin advanced by what landed (and what it performed): the cursor moves with it, so the next
574
+ // fetch and the next push start from where origin now stands
575
+ if (body.snapshots && instance.origin) {
576
+ const twins = Object.keys(body.snapshots);
577
+ const fetched = await fetchFromOrigin(located.world, { root, url, namespace, key: options.key, services: twins, views: body.snapshots });
578
+ for (const twin of twins) {
579
+ const st = located.changeset.actions.find(e => e.service === twin)?.stateService ?? twin; const cr = join(instance.dirs.data, twin);
580
+ const meta = readBranchMeta(st, cr); if (!meta?.parent) continue;
581
+ if (meta.origin) rebaseBranch(st, cr, { origin: true });
582
+ else {
583
+ const snapshot = fetched.snapshots[twin]!;
584
+ writeBranchMeta(st, { ...meta, parent: { ...meta.parent, ...snapshot } }, cr);
585
+ }
586
+ }
587
+ }
588
+ // land each receipt on this world's parent log, per twin's state service
589
+ const byTwin = new Map<string, Array<{ actionId: string } & Receipt>>();
590
+ for (const r of body.receipts) byTwin.set(r.twin, [...(byTwin.get(r.twin) ?? []), r]);
591
+ for (const [twin, receipts] of byTwin) {
592
+ const entry = located.changeset.actions.find((e) => e.service === twin);
593
+ const state = entry?.stateService ?? twin;
594
+ landReceipts(join(instance.dirs.data, twin), state, receipts, at);
595
+ }
596
+ const statusOf = (s: Receipt['status']): ApplyReceipt['status'] => (s === 'deployed' ? 'confirmed' : s === 'landed' ? 'replayed' : s === 'refused' || s === 'failed' ? 'failed' : 'skipped');
597
+ const receipts: ApplyReceipt[] = body.receipts.map((r) => ({ actionId: r.actionId, service: r.twin, stateService: located.changeset.actions.find((e) => e.service === r.twin)?.stateService ?? r.twin, status: statusOf(r.status), ...(r.externalId ? { externalId: r.externalId } : {}), ...(r.reason ? { error: r.reason } : {}) }));
598
+ const outcome: ChangesetApplication['outcome'] = receipts.every((r) => r.status !== 'failed') ? 'applied' : receipts.some((r) => r.status === 'confirmed') ? 'partial' : 'refused';
599
+ const application: ChangesetApplication = { at, contentHash: located.changeset.contentHash, outcome, receipts, compensation: [], ...(outcome === 'refused' ? { refusal: receipts.filter((r) => r.error).map((r) => r.error!) } : {}) };
600
+ const changeset = withApplication(located.changeset, application);
601
+ writeChangesetFile(located.path, changeset);
602
+ const unsent = namedFailure ? `records the landed entries name did not reach the origin (${namedFailure instanceof Error ? namedFailure.message : String(namedFailure)}); the next push from this branch that touches ${twins.map(([t]) => t).join(', ')} sends them` : undefined;
603
+ return { ...(unsent ? { unsent } : {}), application, changeset, world: located.world, path: located.path, remote: { url, namespace } };
604
+ }
605
+
606
+ // ── v3: rebase ─────────────────────────────────────────────────────────────────────────────
607
+ export type RebaseWorldOutcome = { changeset: Changeset; report: RebaseReport; world: string; path: string };
608
+ /** `volter-world changeset rebase <name>`: the merge onto the authoring world's moved mirror. */
609
+ export function rebaseWorldChangeset(name: string, options: WorldRootOptions & { world?: string; now?: Date } = {}): RebaseWorldOutcome {
610
+ const root = resolveRoot(options.root);
611
+ const located = findWorldChangeset(name, { root, ...(options.world ? { world: options.world } : {}) });
612
+ // the branch first: its pointer moves to the parent's current position (what a fetch brought in)
613
+ withAncestryLock(() => {
614
+ const inst = requireWorld(located.world, root);
615
+ for (const service of new Set(located.changeset.actions.map((e) => e.service))) {
616
+ const st = located.changeset.actions.find((e) => e.service === service)?.stateService ?? service;
617
+ const controlRoot = join(inst.dirs.data, service);
618
+ rebaseBranch(st, controlRoot, { origin: Boolean(readBranchMeta(st, controlRoot)?.origin) });
619
+ }
620
+ });
621
+ if (!changesetHashMatches(located.changeset)) throw new Error(`Refusing to rebase changeset "${name}": its stored contentHash does not match its body — the object drifted after authoring`);
622
+ const instance = requireWorld(located.world, root);
623
+ const targets: RebaseTarget[] = [...new Set(located.changeset.actions.map((entry) => entry.service))].map((service) => ({ service, stateService: located.changeset.actions.find((entry) => entry.service === service)!.stateService, controlRoot: join(instance.dirs.data, service) }));
624
+ const { changeset, report } = rebaseChangeset(located.changeset, { targets, ...(options.now ? { at: options.now.toISOString() } : {}) });
625
+ if (report.outcome === 'rebased') writeChangesetFile(located.path, changeset);
626
+ return { changeset, report, world: located.world, path: located.path };
627
+ }