@volter/twin-world 0.1.0 → 0.1.2

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.
@@ -0,0 +1,416 @@
1
+ // `volter-world mark` / `diff` / `changeset` — the WORLD-side verbs over the changeset
2
+ // primitive (docs/OPERATIONAL_VCS.md v0). Thin, exactly like `covers` and `tail` are thin: all
3
+ // the state logic (markers, deltas, content hashing, replay) lives in @volter/twin's
4
+ // control-plane, beside the `actions.jsonl` ledgers it reads. What lives HERE is the only thing
5
+ // the control plane cannot know — where a world keeps its twins' data, and where a world keeps
6
+ // its own marks and changesets.
7
+ //
8
+ // Discovery mirrors `tail.ts`: a world service's ledgers are
9
+ // `<data>/<service>/<state-dir>/world/<state-service>/actions.jsonl`, and one world service may
10
+ // record under more than one state service. Re-discovered on every call, because a twin's ledger
11
+ // dir does not exist until its first action.
12
+ //
13
+ // STORAGE. Marks and changesets are the world's own artifacts, so they live in the world's state
14
+ // dir beside `instance.json`: `.volter/worlds/<world>/{marks,changesets}/<name>.json`. That makes
15
+ // a changeset share its world's lifecycle — `down --purge` takes the twin data AND the changesets
16
+ // cut from it, which is the honest coupling (a changeset whose ledgers were purged is a fossil).
17
+ // `changeset show|replay|list` take a bare name because a changeset is world-scoped but
18
+ // world-PORTABLE: they scan every world's changeset dir, and an ambiguous name is a loud error
19
+ // asking for `--world`, never a silent pick.
20
+ //
21
+ // Read-only over the twins' data at rest, like `tail` — except `replay`, which is the one verb
22
+ // here that WRITES, and it writes only through the control plane's kernel write path.
23
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
24
+ import { tmpdir } from 'node:os';
25
+ import { join, resolve } from 'node:path';
26
+ import {
27
+ approveChangeset,
28
+ buildChangeset,
29
+ captureMarker,
30
+ changesetHashMatches,
31
+ changesetReadiness,
32
+ diffLedgers,
33
+ normalizeChangeset,
34
+ replayChangeset,
35
+ runChangesetVerifiers,
36
+ stateDirName,
37
+ withVerification,
38
+ worldBootMarker,
39
+ assertSafeChangesetName,
40
+ assertMarkerBelongsTo,
41
+ CHANGESET_KIND,
42
+ MARKER_KIND,
43
+ WORLD_BOOT_MARKER_ID,
44
+ } from '@volter/twin';
45
+ import type { Changeset, ChangesetApproval, ChangesetReadiness, ChangesetVerification, ChangesetVerifier, LedgerDelta, LedgerRef, ReplayReport, ReplayTarget, WorldMarker } from '@volter/twin';
46
+ import { instanceDir, listWorlds, statusWorld } from './runtime.ts';
47
+ import type { WorldInstance } from './schema.ts';
48
+
49
+ export type WorldRootOptions = { root?: string };
50
+
51
+ function resolveRoot(root?: string): string {
52
+ return resolve(root ?? process.cwd());
53
+ }
54
+
55
+ /** The world instance, or a loud error that names what this repo actually has. `statusWorld`'s
56
+ * own "not found" is correct but bare; a verb an operator reaches for by name should say which
57
+ * names exist. */
58
+ function requireWorld(name: string, root: string): WorldInstance {
59
+ try {
60
+ return statusWorld(name, root);
61
+ } catch (error) {
62
+ if (!/World instance not found/.test(String((error as Error).message))) throw error;
63
+ const known = listWorlds(root).map((world) => world.name);
64
+ 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}\``);
65
+ }
66
+ }
67
+
68
+ /** Every state service a world service currently records actions under (often exactly one). */
69
+ function stateServicesFor(controlRoot: string): string[] {
70
+ const stateRoot = join(controlRoot, stateDirName(), 'world');
71
+ if (!existsSync(stateRoot)) return [];
72
+ return readdirSync(stateRoot, { withFileTypes: true })
73
+ .filter((entry) => entry.isDirectory() && existsSync(join(stateRoot, entry.name, 'actions.jsonl')))
74
+ .map((entry) => entry.name)
75
+ .sort();
76
+ }
77
+
78
+ /** Every action ledger in a world, as control-plane ledger references. */
79
+ export function worldLedgers(name: string, root?: string): LedgerRef[] {
80
+ const resolvedRoot = resolveRoot(root);
81
+ const instance = requireWorld(name, resolvedRoot);
82
+ const ledgers: LedgerRef[] = [];
83
+ for (const service of Object.keys(instance.services).sort()) {
84
+ const controlRoot = join(instance.dirs.data, service);
85
+ for (const stateService of stateServicesFor(controlRoot)) {
86
+ ledgers.push({ service, stateService, controlRoot });
87
+ }
88
+ }
89
+ return ledgers;
90
+ }
91
+
92
+ // ── marks ──────────────────────────────────────────────────────────────────────────────────────
93
+
94
+ export function worldMarksDir(name: string, root?: string): string {
95
+ return join(instanceDir(resolveRoot(root), name), 'marks');
96
+ }
97
+
98
+ export function worldChangesetsDir(name: string, root?: string): string {
99
+ return join(instanceDir(resolveRoot(root), name), 'changesets');
100
+ }
101
+
102
+ /** `mark-<compact-utc>-<rand>` — sortable by name, collision-free within a millisecond. */
103
+ function defaultMarkerId(now: Date): string {
104
+ const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
105
+ return `mark-${stamp}-${Math.random().toString(36).slice(2, 6)}`;
106
+ }
107
+
108
+ /** Capture a cross-service base marker for `world` and persist it. */
109
+ export function markWorld(name: string, options: WorldRootOptions & { id?: string; now?: Date } = {}): WorldMarker {
110
+ const root = resolveRoot(options.root);
111
+ requireWorld(name, root);
112
+ const now = options.now ?? new Date();
113
+ const id = options.id ?? defaultMarkerId(now);
114
+ assertSafeChangesetName(id, 'marker');
115
+ const marker = captureMarker({ id, world: name, ledgers: worldLedgers(name, root), createdAt: now.toISOString() });
116
+ const dir = worldMarksDir(name, root);
117
+ mkdirSync(dir, { recursive: true });
118
+ writeFileSync(join(dir, `${id}.json`), `${JSON.stringify(marker, null, 2)}\n`);
119
+ return marker;
120
+ }
121
+
122
+ /** Every mark recorded in a world, oldest first. */
123
+ export function listWorldMarks(name: string, root?: string): WorldMarker[] {
124
+ const dir = worldMarksDir(name, root);
125
+ if (!existsSync(dir)) return [];
126
+ return readdirSync(dir)
127
+ .filter((file) => file.endsWith('.json'))
128
+ .map((file) => readMarkerFile(join(dir, file)))
129
+ .sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : a.id < b.id ? -1 : 1));
130
+ }
131
+
132
+ function readMarkerFile(path: string): WorldMarker {
133
+ const marker = JSON.parse(readFileSync(path, 'utf8')) as WorldMarker;
134
+ if (marker.kind !== MARKER_KIND) throw new Error(`${path}: not a world marker (kind=${JSON.stringify(marker.kind)})`);
135
+ return marker;
136
+ }
137
+
138
+ /**
139
+ * The base a diff/changeset is taken against:
140
+ * explicit `--base <id>` → that mark (which must belong to THIS world),
141
+ * otherwise the world's most recent mark,
142
+ * otherwise `world-boot` — position 0 on every ledger, i.e. everything this world recorded.
143
+ *
144
+ * `--base world-boot` is spellable explicitly, so "show me the whole session" never requires
145
+ * deleting marks.
146
+ */
147
+ export function resolveBaseMarker(name: string, options: WorldRootOptions & { base?: string } = {}): WorldMarker {
148
+ const root = resolveRoot(options.root);
149
+ const instance = requireWorld(name, root);
150
+ const boot = (): WorldMarker => worldBootMarker(name, instance.createdAt);
151
+ if (options.base === WORLD_BOOT_MARKER_ID) return boot();
152
+ if (options.base) {
153
+ // A marker id may address a mark in ANOTHER world — read it wherever it lives so the
154
+ // cross-world case fails with "captured in world X, not Y" rather than "no such mark".
155
+ const found = findMarkerAnywhere(root, options.base);
156
+ if (!found) {
157
+ const known = listWorldMarks(name, root).map((mark) => mark.id);
158
+ throw new Error(`No marker "${options.base}" (world "${name}" has: ${known.length ? known.join(', ') : 'no marks yet — run `volter-world mark ' + name + '`'})`);
159
+ }
160
+ assertMarkerBelongsTo(found, name);
161
+ return found;
162
+ }
163
+ const marks = listWorldMarks(name, root);
164
+ return marks.length ? marks[marks.length - 1]! : boot();
165
+ }
166
+
167
+ /** Look for a marker id in every world under `root` — the seam that makes a wrong-world base a
168
+ * precise error instead of a confusing "not found". */
169
+ function findMarkerAnywhere(root: string, id: string): WorldMarker | null {
170
+ for (const world of listWorlds(root)) {
171
+ const path = join(worldMarksDir(world.name, root), `${id}.json`);
172
+ if (existsSync(path)) return readMarkerFile(path);
173
+ }
174
+ return null;
175
+ }
176
+
177
+ // ── diff ───────────────────────────────────────────────────────────────────────────────────────
178
+
179
+ /** The world's ledger delta since `base` (default: the last mark, else world-boot). */
180
+ export function diffWorld(name: string, options: WorldRootOptions & { base?: string } = {}): LedgerDelta {
181
+ const root = resolveRoot(options.root);
182
+ const base = resolveBaseMarker(name, { root, ...(options.base === undefined ? {} : { base: options.base }) });
183
+ return diffLedgers({ world: name, ledgers: worldLedgers(name, root), base });
184
+ }
185
+
186
+ // ── changesets ─────────────────────────────────────────────────────────────────────────────────
187
+
188
+ export type ChangesetLocation = { changeset: Changeset; world: string; path: string };
189
+
190
+ /** Freeze the current delta into `<world>/changesets/<name>.json`. */
191
+ export function createWorldChangeset(
192
+ world: string,
193
+ name: string,
194
+ options: WorldRootOptions & { base?: string; verifiers?: ChangesetVerifier[]; now?: Date; overwrite?: boolean } = {},
195
+ ): Changeset {
196
+ const root = resolveRoot(options.root);
197
+ assertSafeChangesetName(name);
198
+ const dir = worldChangesetsDir(world, root);
199
+ const path = join(dir, `${name}.json`);
200
+ if (existsSync(path) && !options.overwrite) {
201
+ throw new Error(`Changeset "${name}" already exists in world "${world}" (${path}) — pick another name, or pass --force to replace it`);
202
+ }
203
+ const delta = diffWorld(world, { root, ...(options.base === undefined ? {} : { base: options.base }) });
204
+ const changeset = buildChangeset({
205
+ name,
206
+ world,
207
+ base: delta.base.id,
208
+ actions: delta.actions,
209
+ ...(options.verifiers ? { verifiers: options.verifiers } : {}),
210
+ ...(options.now ? { createdAt: options.now.toISOString() } : {}),
211
+ });
212
+ mkdirSync(dir, { recursive: true });
213
+ writeFileSync(path, `${JSON.stringify(changeset, null, 2)}\n`);
214
+ return changeset;
215
+ }
216
+
217
+ function readChangesetFile(path: string): Changeset {
218
+ const changeset = JSON.parse(readFileSync(path, 'utf8')) as Changeset;
219
+ if (changeset.kind !== CHANGESET_KIND) throw new Error(`${path}: not a changeset (kind=${JSON.stringify(changeset.kind)})`);
220
+ // a v0 object lacks the v1 lifecycle fields on disk; filling them is hash-neutral
221
+ return normalizeChangeset(changeset);
222
+ }
223
+
224
+ /** Persist a changeset back where it was found — verify/approve write THROUGH this, so the
225
+ * object on disk is always the object the verbs returned. */
226
+ function writeChangesetFile(path: string, changeset: Changeset): void {
227
+ writeFileSync(path, `${JSON.stringify(changeset, null, 2)}\n`);
228
+ }
229
+
230
+ /** Every changeset in every world under `root` (or one world with `world`), newest first. */
231
+ export function listWorldChangesets(options: WorldRootOptions & { world?: string } = {}): ChangesetLocation[] {
232
+ const root = resolveRoot(options.root);
233
+ const worldNames = options.world ? [options.world] : listWorlds(root).map((world) => world.name);
234
+ const found: ChangesetLocation[] = [];
235
+ for (const world of worldNames) {
236
+ const dir = worldChangesetsDir(world, root);
237
+ if (!existsSync(dir)) continue;
238
+ for (const file of readdirSync(dir).filter((entry) => entry.endsWith('.json')).sort()) {
239
+ const path = join(dir, file);
240
+ found.push({ changeset: readChangesetFile(path), world, path });
241
+ }
242
+ }
243
+ return found.sort((a, b) => (a.changeset.createdAt > b.changeset.createdAt ? -1 : a.changeset.createdAt < b.changeset.createdAt ? 1 : 0));
244
+ }
245
+
246
+ /** Resolve a bare changeset name across worlds. Ambiguity is an error, never a guess. */
247
+ export function findWorldChangeset(name: string, options: WorldRootOptions & { world?: string } = {}): ChangesetLocation {
248
+ const root = resolveRoot(options.root);
249
+ assertSafeChangesetName(name);
250
+ const matches = listWorldChangesets({ root, ...(options.world ? { world: options.world } : {}) }).filter((entry) => entry.changeset.name === name);
251
+ if (matches.length === 1) return matches[0]!;
252
+ if (matches.length > 1) {
253
+ throw new Error(`Changeset "${name}" exists in more than one world (${matches.map((m) => m.world).sort().join(', ')}) — disambiguate with --world <name>`);
254
+ }
255
+ const known = listWorldChangesets({ root, ...(options.world ? { world: options.world } : {}) });
256
+ const scope = options.world ? `world "${options.world}"` : `${root}`;
257
+ 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>`'})`);
258
+ }
259
+
260
+ // ── replay ─────────────────────────────────────────────────────────────────────────────────────
261
+
262
+ /**
263
+ * Which state service a replayed action lands in for a given target world service:
264
+ * the changeset's own state service when the target already records under it (or records
265
+ * nothing yet — a fresh world, the CI case), the target's single existing state service when
266
+ * it uses a different name, and a loud error when the target records under several and none of
267
+ * them is the one the changeset names (there is no correct guess there).
268
+ */
269
+ function resolveTargetStateService(into: string, service: string, controlRoot: string, wanted: string): string {
270
+ const existing = stateServicesFor(controlRoot);
271
+ if (existing.length === 0 || existing.includes(wanted)) return wanted;
272
+ if (existing.length === 1) return existing[0]!;
273
+ throw new Error(`World "${into}" service "${service}" records actions under multiple state services (${existing.join(', ')}) and none is "${wanted}" — cannot decide where to replay`);
274
+ }
275
+
276
+ /** The replay targets a changeset needs inside an existing world (services the world lacks are
277
+ * left out — `replayChangeset` reports them loudly by name). */
278
+ function worldReplayTargets(changeset: Changeset, into: string, root: string): { targets: ReplayTarget[]; available: string[]; dataDir: string } {
279
+ const instance = requireWorld(into, root);
280
+ const available = Object.keys(instance.services).sort();
281
+ const targets: ReplayTarget[] = [];
282
+ for (const service of [...new Set(changeset.actions.map((entry) => entry.service))]) {
283
+ if (!instance.services[service]) continue; // reported as a missing twin by replayChangeset
284
+ const controlRoot = join(instance.dirs.data, service);
285
+ const wanted = changeset.actions.find((entry) => entry.service === service)!.stateService;
286
+ targets.push({ service, stateService: resolveTargetStateService(into, service, controlRoot, wanted), controlRoot });
287
+ }
288
+ return { targets, available, dataDir: instance.dirs.data };
289
+ }
290
+
291
+ /** Replay a changeset into `into`'s twins, through the control plane's kernel write path. */
292
+ export async function replayWorldChangeset(
293
+ name: string,
294
+ options: WorldRootOptions & { into: string; world?: string },
295
+ ): Promise<ReplayReport> {
296
+ const root = resolveRoot(options.root);
297
+ const located = findWorldChangeset(name, { root, ...(options.world ? { world: options.world } : {}) });
298
+ const { targets, available } = worldReplayTargets(located.changeset, options.into, root);
299
+ return replayChangeset(located.changeset, { into: options.into, targets, available });
300
+ }
301
+
302
+ // ── verify / approve / status — the operational-PR contract (v1) ───────────────────────────────
303
+
304
+ export type VerifyWorldOutcome = {
305
+ changeset: Changeset;
306
+ verification: ChangesetVerification;
307
+ report: ReplayReport;
308
+ world: string;
309
+ path: string;
310
+ };
311
+
312
+ /** The label a throwaway verify target reports as `into` — not a world name on purpose. */
313
+ export const EPHEMERAL_VERIFY_TARGET = 'ephemeral';
314
+
315
+ /**
316
+ * `volter-world changeset verify <name> --into <world> | --ephemeral`: replay the changeset
317
+ * into a clean target, run its verifiers against the post-replay projected state, and record
318
+ * the outcome ON the object as `verification` — REPLACING any prior run (the record carries
319
+ * its own provenance: when, into what, against which body hash and world digest). The body
320
+ * hash never moves: verification is about-the-body metadata, like approvals.
321
+ *
322
+ * `--ephemeral` builds a throwaway replay target (fresh empty ledgers per twin, no world
323
+ * booted), verifies against it, and removes it — the zero-setup CI check. `--into` verifies
324
+ * inside an existing world, whose twins must cover the changeset AND its verifiers.
325
+ */
326
+ export async function verifyWorldChangeset(
327
+ name: string,
328
+ options: WorldRootOptions & { into?: string; ephemeral?: boolean; world?: string; now?: Date },
329
+ ): Promise<VerifyWorldOutcome> {
330
+ const root = resolveRoot(options.root);
331
+ if (Boolean(options.into) === Boolean(options.ephemeral)) {
332
+ throw new Error('volter-world changeset verify: pass exactly one of --into <world> (verify inside an existing world) or --ephemeral (a throwaway replay target)');
333
+ }
334
+ const located = findWorldChangeset(name, { root, ...(options.world ? { world: options.world } : {}) });
335
+ const changeset = located.changeset;
336
+ if (!changesetHashMatches(changeset)) {
337
+ 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`);
338
+ }
339
+
340
+ const intoLabel = options.into ?? EPHEMERAL_VERIFY_TARGET;
341
+ let scratch: string | null = null;
342
+ let targets: ReplayTarget[];
343
+ let available: string[] | undefined;
344
+ if (options.into) {
345
+ const resolved = worldReplayTargets(changeset, options.into, root);
346
+ targets = resolved.targets;
347
+ available = resolved.available;
348
+ // a verifier may check a twin the actions never touched — it still needs a real target
349
+ for (const verifier of changeset.verifiers) {
350
+ if (targets.some((target) => target.service === verifier.service)) continue;
351
+ if (!available.includes(verifier.service)) {
352
+ 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'})`);
353
+ }
354
+ const controlRoot = join(resolved.dataDir, verifier.service);
355
+ targets.push({ service: verifier.service, stateService: resolveTargetStateService(options.into, verifier.service, controlRoot, verifier.service), controlRoot });
356
+ }
357
+ } else {
358
+ scratch = mkdtempSync(join(tmpdir(), 'volter-world-verify-'));
359
+ const pairs = new Map<string, ReplayTarget>();
360
+ for (const entry of changeset.actions) {
361
+ pairs.set(`${entry.service} ${entry.stateService}`, { service: entry.service, stateService: entry.stateService, controlRoot: join(scratch, entry.service) });
362
+ }
363
+ for (const verifier of changeset.verifiers) {
364
+ if (![...pairs.values()].some((target) => target.service === verifier.service)) {
365
+ pairs.set(`${verifier.service} ${verifier.service}`, { service: verifier.service, stateService: verifier.service, controlRoot: join(scratch, verifier.service) });
366
+ }
367
+ }
368
+ targets = [...pairs.values()];
369
+ }
370
+
371
+ try {
372
+ const report = await replayChangeset(changeset, { into: intoLabel, targets, ...(available ? { available } : {}) });
373
+ const at = (options.now ?? new Date()).toISOString();
374
+ const verification = runChangesetVerifiers(changeset, targets, { at, into: intoLabel });
375
+ const updated = withVerification(changeset, verification);
376
+ writeChangesetFile(located.path, updated);
377
+ return { changeset: updated, verification, report, world: located.world, path: located.path };
378
+ } finally {
379
+ if (scratch) rmSync(scratch, { recursive: true, force: true });
380
+ }
381
+ }
382
+
383
+ export type ApproveWorldOutcome = {
384
+ changeset: Changeset;
385
+ approval: ChangesetApproval;
386
+ world: string;
387
+ path: string;
388
+ };
389
+
390
+ /** `volter-world changeset approve <name> --as <principal>`: append an approval bound to the
391
+ * current body hash. The control plane refuses a drifted object loudly. */
392
+ export function approveWorldChangeset(
393
+ name: string,
394
+ options: WorldRootOptions & { principal: string; note?: string; world?: string; now?: Date },
395
+ ): ApproveWorldOutcome {
396
+ const root = resolveRoot(options.root);
397
+ const located = findWorldChangeset(name, { root, ...(options.world ? { world: options.world } : {}) });
398
+ const { changeset, approval } = approveChangeset(located.changeset, {
399
+ principal: options.principal,
400
+ ...(options.note ? { note: options.note } : {}),
401
+ ...(options.now ? { at: options.now.toISOString() } : {}),
402
+ });
403
+ writeChangesetFile(located.path, changeset);
404
+ return { changeset, approval, world: located.world, path: located.path };
405
+ }
406
+
407
+ /** `volter-world changeset status <name>`: the apply-readiness gate, recomputed from the
408
+ * object. Truth only — pushing is v2's job. */
409
+ export function statusWorldChangeset(
410
+ name: string,
411
+ options: WorldRootOptions & { world?: string } = {},
412
+ ): ChangesetReadiness {
413
+ const root = resolveRoot(options.root);
414
+ const located = findWorldChangeset(name, { root, ...(options.world ? { world: options.world } : {}) });
415
+ return changesetReadiness(located.changeset);
416
+ }