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