narraeon 0.1.1 → 0.3.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.
- package/dist/node/protocol/modelConnections.js +49 -0
- package/dist/node/protocol/modelUsage.js +39 -0
- package/dist/node/protocol/v1.js +35 -0
- package/dist/node/runtime/FileNativePhysicalClone.js +86 -0
- package/dist/node/runtime/V1Runtime.js +66 -29
- package/dist/node/runtime/model/FileNativeModelAdapters.js +730 -287
- package/dist/node/runtime/model/ModelConnectionStore.js +123 -2
- package/dist/node/runtime/model/ModelHost.js +81 -9
- package/dist/node/runtime/model/ModelHostStream.js +61 -1
- package/dist/node/runtime/model/SettingAuthorStream.js +16 -113
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +269 -0
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +1128 -0
- package/dist/node/runtime/play/PlayCallChain.js +904 -369
- package/dist/node/runtime/play/PlayDocumentTools.js +35 -0
- package/dist/node/runtime/play/PlayFollowupRequests.js +43 -26
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +50 -35
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +55 -29
- package/dist/node/runtime/setting/DocumentCandidateSettingImprovement.js +9 -4
- package/dist/node/runtime/world/FileNativeAuthorityV3.js +971 -0
- package/dist/node/runtime/world/FileNativeHistoryProjection.js +39 -0
- package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +1067 -0
- package/dist/node/runtime/world/FileNativeWorldStore.js +615 -784
- package/dist/node/server/createServer.js +10 -2
- package/dist/web/assets/index-D5VqDvjY.js +1192 -0
- package/dist/web/assets/index-Xa0s35CJ.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-CRGTZ9Ak.css +0 -1
- package/dist/web/assets/index-DDx1itzT.js +0 -1192
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { isDeepStrictEqual } from "node:util";
|
|
5
5
|
import { inspectContentPackageCurrentTree, isContentPackageWorldDocumentPath, } from "../content/FileNativeContentTree.js";
|
|
6
6
|
import { FileNativePromptCompiler, } from "../prompt/FileNativePromptCompiler.js";
|
|
7
7
|
import { PlayerViewRenderer } from "./PlayerViewRenderer.js";
|
|
8
8
|
import { WorldDocumentStore } from "./WorldDocumentStore.js";
|
|
9
|
+
import { FileNativePlayTimelineStore } from "../play/FileNativePlayTimelineStore.js";
|
|
10
|
+
import { FileNativePlayAdvanceStore } from "../play/FileNativePlayAdvanceStore.js";
|
|
11
|
+
import { FileNativeWorldStorageMigrator } from "./FileNativeWorldStorageMigrator.js";
|
|
12
|
+
import { FileNativeAuthorityV3, FileNativeAuthorityV3Error, } from "./FileNativeAuthorityV3.js";
|
|
9
13
|
import { FileNativeWorldOperationCoordinator, WorldOperationBusyError, } from "./WorldOperationCoordinator.js";
|
|
14
|
+
import { fileNativeHistoryMessageIdFromProjectionPath, projectFileNativeHistorySurface, } from "./FileNativeHistoryProjection.js";
|
|
10
15
|
const publicationFile = "publication.json";
|
|
11
16
|
const localMetadataFile = "local.json";
|
|
12
17
|
export class FileNativeWorldCreationError extends Error {
|
|
@@ -29,6 +34,11 @@ export class FileNativeWorldStore {
|
|
|
29
34
|
#promptCompiler;
|
|
30
35
|
#activeControlUsers = new Map();
|
|
31
36
|
operations;
|
|
37
|
+
playTimeline;
|
|
38
|
+
playAdvances;
|
|
39
|
+
#storageMigrator;
|
|
40
|
+
#storageMigrations = new Map();
|
|
41
|
+
#currentStorage = new Set();
|
|
32
42
|
constructor(dataRoot, options = {}) {
|
|
33
43
|
const root = resolve(dataRoot);
|
|
34
44
|
this.#worldsRoot = join(root, "worlds-file-native");
|
|
@@ -36,6 +46,62 @@ export class FileNativeWorldStore {
|
|
|
36
46
|
this.#promptCompiler =
|
|
37
47
|
options.promptCompiler ?? new FileNativePromptCompiler();
|
|
38
48
|
this.operations = new FileNativeWorldOperationCoordinator(root);
|
|
49
|
+
this.playTimeline = new FileNativePlayTimelineStore(root);
|
|
50
|
+
this.playAdvances = new FileNativePlayAdvanceStore(root);
|
|
51
|
+
this.#storageMigrator = new FileNativeWorldStorageMigrator(root, this.playTimeline);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Upgrade a formally released cumulative layout before current Authority or
|
|
55
|
+
* play-timeline behavior crosses its seam. Concurrent callers share one
|
|
56
|
+
* in-process promise; the durable world lock serializes processes.
|
|
57
|
+
*/
|
|
58
|
+
async ensureCurrentStorage(worldId) {
|
|
59
|
+
assertIdentity(worldId, "World ID");
|
|
60
|
+
if (this.#currentStorage.has(worldId))
|
|
61
|
+
return;
|
|
62
|
+
const existing = this.#storageMigrations.get(worldId);
|
|
63
|
+
if (existing !== undefined)
|
|
64
|
+
return existing;
|
|
65
|
+
const migration = this.#ensureCurrentStorageSerialized(worldId);
|
|
66
|
+
this.#storageMigrations.set(worldId, migration);
|
|
67
|
+
try {
|
|
68
|
+
await migration;
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
if (this.#storageMigrations.get(worldId) === migration)
|
|
72
|
+
this.#storageMigrations.delete(worldId);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async #ensureCurrentStorageSerialized(worldId) {
|
|
76
|
+
try {
|
|
77
|
+
const root = join(this.#worldsRoot, worldId);
|
|
78
|
+
const authority = new FileNativeAuthorityV3(root);
|
|
79
|
+
if (await authority.exists()) {
|
|
80
|
+
await authority.readHead();
|
|
81
|
+
this.#currentStorage.add(worldId);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
await this.operations.withWorldAuthorityLock(worldId, async () => {
|
|
85
|
+
await this.#storageMigrator.migrate(worldId);
|
|
86
|
+
});
|
|
87
|
+
this.#currentStorage.add(worldId);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error instanceof FileNativeWorldCreationError)
|
|
91
|
+
throw error;
|
|
92
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
93
|
+
const root = await stat(join(this.#worldsRoot, worldId)).catch((statError) => {
|
|
94
|
+
if (isNodeError(statError) && statError.code === "ENOENT")
|
|
95
|
+
return null;
|
|
96
|
+
throw statError;
|
|
97
|
+
});
|
|
98
|
+
if (root === null)
|
|
99
|
+
throw new FileNativeWorldNotFoundError({ cause: error });
|
|
100
|
+
}
|
|
101
|
+
if (error instanceof WorldOperationBusyError)
|
|
102
|
+
throw new FileNativeWorldCreationError("operation_conflict", "World storage is being upgraded by another durable operation", { cause: error });
|
|
103
|
+
throw new FileNativeWorldCreationError("world_corrupt", "Released world storage could not be migrated to the current format", error instanceof Error ? { cause: error } : undefined);
|
|
104
|
+
}
|
|
39
105
|
}
|
|
40
106
|
async listWorlds() {
|
|
41
107
|
await mkdir(this.#worldsRoot, { recursive: true, mode: 0o700 });
|
|
@@ -90,6 +156,7 @@ export class FileNativeWorldStore {
|
|
|
90
156
|
}
|
|
91
157
|
return this.operations.withExclusiveWorldStateMutation(worldId, async () => {
|
|
92
158
|
await rm(root, { recursive: true, force: true });
|
|
159
|
+
this.#currentStorage.delete(worldId);
|
|
93
160
|
return { deleted: true };
|
|
94
161
|
});
|
|
95
162
|
}
|
|
@@ -148,7 +215,7 @@ export class FileNativeWorldStore {
|
|
|
148
215
|
await mkdir(this.#operationsRoot, { recursive: true, mode: 0o700 });
|
|
149
216
|
await rm(stagingRoot, { recursive: true, force: true });
|
|
150
217
|
try {
|
|
151
|
-
const openingMessage = genesisOpeningMessage(
|
|
218
|
+
const openingMessage = genesisOpeningMessage(openingText(packageFiles));
|
|
152
219
|
const initialMaterials = [
|
|
153
220
|
{ kind: "history_message", message: openingMessage.messageId },
|
|
154
221
|
];
|
|
@@ -156,7 +223,7 @@ export class FileNativeWorldStore {
|
|
|
156
223
|
await writeSurface(stagingRoot, "control", control);
|
|
157
224
|
await mkdir(join(stagingRoot, "history"), { recursive: true });
|
|
158
225
|
await mkdir(join(stagingRoot, "runtime"), { recursive: true });
|
|
159
|
-
await writeIdempotentText(join(stagingRoot, "history",
|
|
226
|
+
await writeIdempotentText(join(stagingRoot, "history", historySurfaceFiles([openingMessage])[0].path), openingMessage.exactText);
|
|
160
227
|
const genesis = {
|
|
161
228
|
schemaVersion: 1,
|
|
162
229
|
type: "file_native_genesis",
|
|
@@ -169,8 +236,22 @@ export class FileNativeWorldStore {
|
|
|
169
236
|
additionalMaterials: initialMaterials,
|
|
170
237
|
};
|
|
171
238
|
await writeJson(join(stagingRoot, "runtime", "genesis.json"), genesis);
|
|
239
|
+
await writeJson(join(stagingRoot, "runtime", "play-genesis-timeline.json"), {
|
|
240
|
+
schemaVersion: 1,
|
|
241
|
+
worldId,
|
|
242
|
+
history: genesis.history,
|
|
243
|
+
});
|
|
244
|
+
await new FileNativeAuthorityV3(stagingRoot).initialize({
|
|
245
|
+
operationId: input.operationId,
|
|
246
|
+
state,
|
|
247
|
+
history: [openingMessage],
|
|
248
|
+
additionalMaterials: initialMaterials,
|
|
249
|
+
});
|
|
172
250
|
await writeJson(join(stagingRoot, "runtime", "materialized-head.json"), {
|
|
251
|
+
schemaVersion: 3,
|
|
173
252
|
head: "genesis",
|
|
253
|
+
sequence: 0,
|
|
254
|
+
commitDigest: null,
|
|
174
255
|
});
|
|
175
256
|
await writeJson(join(stagingRoot, "runtime", "additional-materials.json"), { head: "genesis", items: initialMaterials });
|
|
176
257
|
const publication = {
|
|
@@ -215,6 +296,7 @@ export class FileNativeWorldStore {
|
|
|
215
296
|
}
|
|
216
297
|
async readSurface(worldId, surface) {
|
|
217
298
|
assertIdentity(worldId, "World ID");
|
|
299
|
+
await this.ensureCurrentStorage(worldId);
|
|
218
300
|
const root = join(this.#worldsRoot, worldId);
|
|
219
301
|
await readPublicationAt(root);
|
|
220
302
|
if (surface === "runtime") {
|
|
@@ -236,28 +318,37 @@ export class FileNativeWorldStore {
|
|
|
236
318
|
* must never pair a mutable state tree with a different response head.
|
|
237
319
|
*/
|
|
238
320
|
async renderPlayerViewsAtHead(worldId, head) {
|
|
239
|
-
const
|
|
240
|
-
const
|
|
321
|
+
const root = join(this.#worldsRoot, worldId);
|
|
322
|
+
const materialized = await readOptionalJson(join(root, "runtime", "materialized-head.json"));
|
|
323
|
+
const [state, control] = await Promise.all([
|
|
324
|
+
materialized?.head === head
|
|
325
|
+
? readTree(join(root, "state"))
|
|
326
|
+
: this.recoverEndpoint(worldId, head).then(({ state }) => state),
|
|
327
|
+
this.readSurface(worldId, "control"),
|
|
328
|
+
]);
|
|
241
329
|
const source = control.find(({ path }) => path === "player-views.yaml");
|
|
242
330
|
return new PlayerViewRenderer().render({
|
|
243
|
-
snapshot: openWorldDocumentSnapshot(
|
|
331
|
+
snapshot: openWorldDocumentSnapshot(state),
|
|
244
332
|
control: source?.contents ?? "",
|
|
245
333
|
});
|
|
246
334
|
}
|
|
247
335
|
async currentHead(worldId) {
|
|
248
336
|
assertIdentity(worldId, "World ID");
|
|
337
|
+
await this.ensureCurrentStorage(worldId);
|
|
249
338
|
const root = join(this.#worldsRoot, worldId);
|
|
250
339
|
await readPublicationAt(root);
|
|
251
|
-
return (await
|
|
340
|
+
return (await new FileNativeAuthorityV3(root).readHead()).head;
|
|
252
341
|
}
|
|
253
342
|
async currentHeadOperationId(worldId) {
|
|
254
343
|
assertIdentity(worldId, "World ID");
|
|
344
|
+
await this.ensureCurrentStorage(worldId);
|
|
255
345
|
const root = join(this.#worldsRoot, worldId);
|
|
256
346
|
await readPublicationAt(root);
|
|
257
|
-
return
|
|
347
|
+
return new FileNativeAuthorityV3(root).currentOperationId();
|
|
258
348
|
}
|
|
259
349
|
async saveControlDraft(worldId, files) {
|
|
260
350
|
assertIdentity(worldId, "World ID");
|
|
351
|
+
await this.ensureCurrentStorage(worldId);
|
|
261
352
|
const root = join(this.#worldsRoot, worldId);
|
|
262
353
|
await readPublicationAt(root);
|
|
263
354
|
for (const file of files)
|
|
@@ -350,6 +441,7 @@ export class FileNativeWorldStore {
|
|
|
350
441
|
}
|
|
351
442
|
async #readControlDraft(worldId) {
|
|
352
443
|
assertIdentity(worldId, "World ID");
|
|
444
|
+
await this.ensureCurrentStorage(worldId);
|
|
353
445
|
const root = join(this.#worldsRoot, worldId);
|
|
354
446
|
await readPublicationAt(root);
|
|
355
447
|
const [state, draft] = await Promise.all([
|
|
@@ -363,6 +455,7 @@ export class FileNativeWorldStore {
|
|
|
363
455
|
/** Bind the current Authority endpoint for a model-directed play call chain. */
|
|
364
456
|
async bindPlayCallChain(worldId) {
|
|
365
457
|
assertIdentity(worldId, "World ID");
|
|
458
|
+
await this.ensureCurrentStorage(worldId);
|
|
366
459
|
const root = join(this.#worldsRoot, worldId);
|
|
367
460
|
try {
|
|
368
461
|
await readPublicationAt(root);
|
|
@@ -384,61 +477,47 @@ export class FileNativeWorldStore {
|
|
|
384
477
|
}
|
|
385
478
|
try {
|
|
386
479
|
return await this.operations.withWorldAuthorityLock(worldId, async () => {
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
? {}
|
|
406
|
-
: { timelineRevision: commit.timelineRevision }),
|
|
407
|
-
});
|
|
408
|
-
await publishJson(this.#operationOutcomePath(commit.operationId), {
|
|
409
|
-
outcome: "committed",
|
|
410
|
-
worldId,
|
|
411
|
-
parentHead: commit.parentHead,
|
|
412
|
-
head: commit.head,
|
|
413
|
-
historyAppend: commit.historyAppend.map(({ role, exactText }) => ({ role, exactText })),
|
|
414
|
-
nextAdditionalMaterials: commit.nextAdditionalMaterials,
|
|
415
|
-
mode: commit.mode,
|
|
416
|
-
});
|
|
480
|
+
const authorityStore = new FileNativeAuthorityV3(root);
|
|
481
|
+
const authority = await authorityStore.readHead();
|
|
482
|
+
const checkpoint = await readMaterializedCheckpoint(root);
|
|
483
|
+
if (!sameMaterializedEndpoint(checkpoint, authority)) {
|
|
484
|
+
const recovered = await authorityStore.recover();
|
|
485
|
+
let operation = null;
|
|
486
|
+
if (authority.operationId !== null && authority.sequence > 0) {
|
|
487
|
+
const candidate = await this.getOperationOutcome(authority.operationId);
|
|
488
|
+
if (isCommittedOutcome(candidate)) {
|
|
489
|
+
if (candidate.worldId !== worldId ||
|
|
490
|
+
candidate.head !== authority.head ||
|
|
491
|
+
candidate.commitDigest !== authority.commitDigest)
|
|
492
|
+
throw new FileNativeWorldCreationError("world_corrupt", "Current Authority head is bound to a different operation receipt");
|
|
493
|
+
operation = candidate;
|
|
494
|
+
}
|
|
495
|
+
else if (candidate.outcome !== "not_started") {
|
|
496
|
+
throw new FileNativeWorldCreationError("world_corrupt", "Current Authority head has no recoverable acceptance receipt");
|
|
497
|
+
}
|
|
417
498
|
}
|
|
418
|
-
await
|
|
419
|
-
|
|
420
|
-
|
|
499
|
+
await materializeRecoveredEndpoint(root, authority, recovered);
|
|
500
|
+
if (operation !== null)
|
|
501
|
+
await publishJson(this.#operationOutcomePath(authority.operationId), { ...operation, outcome: "committed" });
|
|
421
502
|
}
|
|
422
|
-
const [state, control,
|
|
503
|
+
const [state, control, history, materials] = await Promise.all([
|
|
423
504
|
readTree(join(root, "state")),
|
|
424
505
|
readTree(join(root, "control")),
|
|
425
|
-
|
|
506
|
+
readTree(join(root, "history")),
|
|
426
507
|
readOptionalJson(join(root, "runtime", "additional-materials.json")),
|
|
427
|
-
this.recoverEndpoint(worldId),
|
|
428
508
|
]);
|
|
509
|
+
if ((materials?.head ?? "genesis") !== authority.head)
|
|
510
|
+
throw new FileNativeWorldCreationError("inconsistent_materialization", "Materialized additional materials do not match the current Authority endpoint");
|
|
429
511
|
return {
|
|
430
512
|
worldId,
|
|
431
|
-
parentHead: authority
|
|
513
|
+
parentHead: authority.head,
|
|
432
514
|
files: [...state, ...control].reduce((files, { path, contents }, index) => {
|
|
433
515
|
files[`${index < state.length ? "state" : "control"}/${path}`] =
|
|
434
516
|
contents;
|
|
435
517
|
return files;
|
|
436
518
|
}, {}),
|
|
437
519
|
additionalMaterials: materials?.items ?? [],
|
|
438
|
-
history:
|
|
439
|
-
messageId,
|
|
440
|
-
exactText,
|
|
441
|
-
])),
|
|
520
|
+
history: materializedHistoryRecord(history),
|
|
442
521
|
};
|
|
443
522
|
});
|
|
444
523
|
}
|
|
@@ -479,73 +558,33 @@ export class FileNativeWorldStore {
|
|
|
479
558
|
}
|
|
480
559
|
async recoverEndpoint(worldId, head) {
|
|
481
560
|
assertIdentity(worldId, "World ID");
|
|
561
|
+
await this.ensureCurrentStorage(worldId);
|
|
482
562
|
const root = join(this.#worldsRoot, worldId);
|
|
483
563
|
await readPublicationAt(root);
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
let additionalMaterials = genesis.additionalMaterials;
|
|
499
|
-
for (const commit of commits.slice(0, end)) {
|
|
500
|
-
if (commit.mode === "timeline_revision") {
|
|
501
|
-
const revision = commit.timelineRevision;
|
|
502
|
-
if (revision === undefined)
|
|
503
|
-
throw new FileNativeWorldCreationError("world_corrupt", "Timeline-revision commit is missing its recovery snapshot");
|
|
504
|
-
const restored = await this.recoverEndpoint(worldId, revision.restoresHead);
|
|
505
|
-
if (!isDeepStrictEqual(revision.replacementState, immutableFiles(restored.state)) ||
|
|
506
|
-
!isDeepStrictEqual(revision.replacementHistory, restored.history) ||
|
|
507
|
-
!isDeepStrictEqual(commit.nextAdditionalMaterials, restored.additionalMaterials))
|
|
508
|
-
throw new FileNativeWorldCreationError("world_corrupt", "Timeline-revision recovery snapshot does not match its logical parent endpoint");
|
|
509
|
-
state.clear();
|
|
510
|
-
for (const file of revision.replacementState) {
|
|
511
|
-
if (sha256(file.canonicalBytes) !== file.sha256)
|
|
512
|
-
throw new FileNativeWorldCreationError("world_corrupt", `Timeline-revision state hash does not match: ${file.path}`);
|
|
513
|
-
state.set(file.path, file.canonicalBytes);
|
|
514
|
-
}
|
|
515
|
-
history.splice(0, history.length, ...structuredClone(revision.replacementHistory));
|
|
516
|
-
}
|
|
517
|
-
else {
|
|
518
|
-
for (const change of commit.stateChanges) {
|
|
519
|
-
const existing = state.get(change.relativePath);
|
|
520
|
-
const existingHash = existing === undefined ? null : sha256(existing);
|
|
521
|
-
if (existingHash !== change.expectedPreviousHash)
|
|
522
|
-
throw new FileNativeWorldCreationError("world_corrupt", `Immutable commit-chain previous hash does not match: ${change.relativePath}`);
|
|
523
|
-
if (sha256(change.canonicalNextBytes) !== change.nextHash)
|
|
524
|
-
throw new FileNativeWorldCreationError("world_corrupt", `Immutable commit-chain next hash does not match: ${change.relativePath}`);
|
|
525
|
-
state.set(change.relativePath, change.canonicalNextBytes);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
history.push(...structuredClone(commit.historyAppend));
|
|
529
|
-
additionalMaterials = structuredClone(commit.nextAdditionalMaterials);
|
|
564
|
+
try {
|
|
565
|
+
const recovered = await new FileNativeAuthorityV3(root).recover(head);
|
|
566
|
+
return {
|
|
567
|
+
worldId,
|
|
568
|
+
head: recovered.head,
|
|
569
|
+
state: recovered.state,
|
|
570
|
+
history: recovered.history,
|
|
571
|
+
additionalMaterials: recovered.additionalMaterials,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
catch (error) {
|
|
575
|
+
if (error instanceof FileNativeWorldCreationError)
|
|
576
|
+
throw error;
|
|
577
|
+
throw new FileNativeWorldCreationError("world_corrupt", `Cannot recover immutable Authority endpoint${head === undefined ? "" : `: ${head}`}`, error instanceof Error ? { cause: error } : undefined);
|
|
530
578
|
}
|
|
531
|
-
return {
|
|
532
|
-
worldId,
|
|
533
|
-
head: target,
|
|
534
|
-
state: [...state]
|
|
535
|
-
.map(([path, contents]) => ({ path, contents }))
|
|
536
|
-
.sort((a, b) => a.path.localeCompare(b.path)),
|
|
537
|
-
history,
|
|
538
|
-
additionalMaterials,
|
|
539
|
-
};
|
|
540
579
|
}
|
|
541
580
|
async repairMaterialization(worldId) {
|
|
542
581
|
assertIdentity(worldId, "World ID");
|
|
582
|
+
await this.ensureCurrentStorage(worldId);
|
|
543
583
|
try {
|
|
544
584
|
const operationId = await this.operations.withExclusiveWorldStateMutation(worldId, async () => {
|
|
545
585
|
await this.bindPlayCallChain(worldId);
|
|
546
586
|
const root = join(this.#worldsRoot, worldId);
|
|
547
|
-
|
|
548
|
-
return authority?.commits.at(-1)?.operationId ?? null;
|
|
587
|
+
return (await new FileNativeAuthorityV3(root).readHead()).operationId;
|
|
549
588
|
});
|
|
550
589
|
return operationId === null
|
|
551
590
|
? { outcome: "not_started" }
|
|
@@ -572,6 +611,7 @@ export class FileNativeWorldStore {
|
|
|
572
611
|
const finalRoot = join(this.#worldsRoot, worldId);
|
|
573
612
|
const previous = await readOptionalJson(operationPath);
|
|
574
613
|
if (previous !== null) {
|
|
614
|
+
await this.ensureCurrentStorage(previous.worldId);
|
|
575
615
|
if (!(await derivationPublicationMatches(join(this.#worldsRoot, previous.worldId), previous, input, requestFingerprint)))
|
|
576
616
|
throw new FileNativeWorldCreationError("operation_conflict", "The same fork operation ID is bound to a different fork payload");
|
|
577
617
|
return {
|
|
@@ -581,6 +621,7 @@ export class FileNativeWorldStore {
|
|
|
581
621
|
}
|
|
582
622
|
const alreadyPublished = await readOptionalJson(join(finalRoot, publicationFile));
|
|
583
623
|
if (alreadyPublished !== null) {
|
|
624
|
+
await this.ensureCurrentStorage(alreadyPublished.worldId);
|
|
584
625
|
if (!(await derivationPublicationMatches(finalRoot, alreadyPublished, input, requestFingerprint)))
|
|
585
626
|
throw new FileNativeWorldCreationError("operation_conflict", "Published fork world does not match the operation payload");
|
|
586
627
|
await mkdir(this.#operationsRoot, { recursive: true, mode: 0o700 });
|
|
@@ -590,98 +631,112 @@ export class FileNativeWorldStore {
|
|
|
590
631
|
world: await this.#currentSummary(alreadyPublished),
|
|
591
632
|
};
|
|
592
633
|
}
|
|
593
|
-
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
try {
|
|
613
|
-
await writeSurface(staging, "state", sourceGenesis.state);
|
|
614
|
-
await writeSurface(staging, "control", control);
|
|
615
|
-
await mkdir(join(staging, "history"), { recursive: true });
|
|
616
|
-
await mkdir(join(staging, "runtime"), { recursive: true });
|
|
617
|
-
for (const [index, message] of cloned.genesisHistory.entries())
|
|
618
|
-
await writeIdempotentText(join(staging, "history", `00000000-${historyFileName(index + 1, message)}`), message.exactText);
|
|
619
|
-
const genesis = {
|
|
620
|
-
schemaVersion: 1,
|
|
621
|
-
type: "file_native_genesis",
|
|
622
|
-
worldId,
|
|
623
|
-
operationId: input.operationId,
|
|
624
|
-
parentEndpoint: "genesis",
|
|
625
|
-
state: immutableFiles(sourceGenesis.state),
|
|
626
|
-
control: immutableFiles(control),
|
|
627
|
-
history: cloned.genesisHistory,
|
|
628
|
-
additionalMaterials: cloned.genesisMaterials,
|
|
629
|
-
};
|
|
630
|
-
await writeJson(join(staging, "runtime", "genesis.json"), genesis);
|
|
631
|
-
await writeJson(join(staging, "runtime", "additional-materials.json"), {
|
|
632
|
-
head: "genesis",
|
|
633
|
-
items: cloned.genesisMaterials,
|
|
634
|
-
});
|
|
635
|
-
for (const [index, commit] of cloned.commits.entries()) {
|
|
636
|
-
await publishImmutableJson(join(staging, "runtime", "play-commits", `${createHash("sha256").update(JSON.stringify(commit)).digest("hex")}.json`), commit);
|
|
637
|
-
await materializePlayCommit(staging, index + 1, {
|
|
638
|
-
mode: commit.mode,
|
|
639
|
-
historyAppend: commit.historyAppend,
|
|
640
|
-
nextMaterials: commit.nextAdditionalMaterials,
|
|
641
|
-
stateChanges: commit.stateChanges,
|
|
642
|
-
...(commit.timelineRevision === undefined
|
|
643
|
-
? {}
|
|
644
|
-
: { timelineRevision: commit.timelineRevision }),
|
|
634
|
+
await this.ensureCurrentStorage(input.sourceWorldId);
|
|
635
|
+
const retainSourceSnapshot = async () => {
|
|
636
|
+
const sourceRoot = join(this.#worldsRoot, input.sourceWorldId);
|
|
637
|
+
const sourcePublication = await readPublicationAt(sourceRoot);
|
|
638
|
+
const sourceSummary = await readWorldSummaryAt(sourceRoot, sourcePublication);
|
|
639
|
+
const sourceAuthority = new FileNativeAuthorityV3(sourceRoot);
|
|
640
|
+
const [sourceGenesis, control] = await Promise.all([
|
|
641
|
+
readGenesisForFork(sourceRoot, input.sourceWorldId),
|
|
642
|
+
readTree(join(sourceRoot, "control")),
|
|
643
|
+
]);
|
|
644
|
+
const staging = join(this.#worldsRoot, `.staging-${worldId}-${randomUUID()}`);
|
|
645
|
+
await mkdir(this.#worldsRoot, { recursive: true, mode: 0o700 });
|
|
646
|
+
await mkdir(this.#operationsRoot, { recursive: true, mode: 0o700 });
|
|
647
|
+
try {
|
|
648
|
+
await mkdir(join(staging, "runtime"), { recursive: true });
|
|
649
|
+
const clonedHead = await sourceAuthority.clonePrefixTo({
|
|
650
|
+
targetWorldRoot: staging,
|
|
651
|
+
selectedHead: input.sourceHead,
|
|
652
|
+
operationId: input.operationId,
|
|
645
653
|
});
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
await
|
|
654
|
+
const selected = await new FileNativeAuthorityV3(staging).recoverHeadResult();
|
|
655
|
+
await writeSurface(staging, "state", selected.state);
|
|
656
|
+
await writeSurface(staging, "control", control);
|
|
657
|
+
await mkdir(join(staging, "history"), { recursive: true });
|
|
658
|
+
for (const file of historySurfaceFiles(selected.history))
|
|
659
|
+
await writeIdempotentText(join(staging, "history", file.path), file.contents);
|
|
660
|
+
const genesis = {
|
|
649
661
|
schemaVersion: 1,
|
|
662
|
+
type: "file_native_genesis",
|
|
663
|
+
worldId,
|
|
664
|
+
operationId: input.operationId,
|
|
665
|
+
parentEndpoint: "genesis",
|
|
666
|
+
state: structuredClone(sourceGenesis.state),
|
|
667
|
+
control: immutableFiles(control),
|
|
668
|
+
history: structuredClone(sourceGenesis.history),
|
|
669
|
+
additionalMaterials: structuredClone(sourceGenesis.additionalMaterials),
|
|
670
|
+
};
|
|
671
|
+
await writeJson(join(staging, "runtime", "genesis.json"), genesis);
|
|
672
|
+
await writeJson(join(staging, "runtime", "play-genesis-timeline.json"), {
|
|
673
|
+
schemaVersion: 1,
|
|
674
|
+
worldId,
|
|
675
|
+
history: genesis.history,
|
|
676
|
+
});
|
|
677
|
+
await writeJson(join(staging, "runtime", "additional-materials.json"), {
|
|
650
678
|
head: input.sourceHead,
|
|
651
|
-
|
|
679
|
+
items: selected.additionalMaterials,
|
|
652
680
|
});
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
681
|
+
await writeJson(join(staging, "runtime", "materialized-head.json"), {
|
|
682
|
+
schemaVersion: 3,
|
|
683
|
+
head: input.sourceHead,
|
|
684
|
+
sequence: clonedHead.sequence,
|
|
685
|
+
commitDigest: clonedHead.commitDigest,
|
|
686
|
+
});
|
|
687
|
+
await input.stageTarget?.({
|
|
688
|
+
targetWorldRoot: staging,
|
|
689
|
+
targetWorldId: worldId,
|
|
690
|
+
binding: {
|
|
691
|
+
worldId,
|
|
692
|
+
parentHead: input.sourceHead,
|
|
693
|
+
files: [...selected.state, ...control].reduce((files, { path, contents }, index) => {
|
|
694
|
+
files[`${index < selected.state.length ? "state" : "control"}/${path}`] = contents;
|
|
695
|
+
return files;
|
|
696
|
+
}, {}),
|
|
697
|
+
additionalMaterials: structuredClone(selected.additionalMaterials),
|
|
698
|
+
history: Object.fromEntries(selected.history.map(({ messageId, exactText }) => [
|
|
699
|
+
messageId,
|
|
700
|
+
exactText,
|
|
701
|
+
])),
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
crashAtFileNativeAuthorityEdge("derivation_after_target_staging");
|
|
705
|
+
const publication = {
|
|
706
|
+
schemaVersion: 1,
|
|
707
|
+
worldId,
|
|
708
|
+
title: derivedWorldName(sourceSummary.title),
|
|
709
|
+
parentEndpoint: "genesis",
|
|
710
|
+
operationId: input.operationId,
|
|
711
|
+
sourceFingerprint: requestFingerprint,
|
|
712
|
+
};
|
|
713
|
+
await writeJson(join(staging, publicationFile), publication);
|
|
714
|
+
await durableTree(staging);
|
|
715
|
+
crashAtFileNativeAuthorityEdge("derivation_before_publish");
|
|
716
|
+
await rename(staging, finalRoot);
|
|
717
|
+
await syncDirectory(this.#worldsRoot);
|
|
718
|
+
crashAtFileNativeAuthorityEdge("derivation_after_publish");
|
|
719
|
+
await publishJson(operationPath, publication);
|
|
720
|
+
return { outcome: "derived", world: toSummary(publication) };
|
|
721
|
+
}
|
|
722
|
+
catch (error) {
|
|
723
|
+
await rm(staging, { recursive: true, force: true }).catch(() => undefined);
|
|
724
|
+
const published = await readOptionalJson(join(finalRoot, publicationFile));
|
|
725
|
+
if (published !== null) {
|
|
726
|
+
if (!(await derivationPublicationMatches(finalRoot, published, input, requestFingerprint)))
|
|
727
|
+
throw new FileNativeWorldCreationError("operation_conflict", "Published fork world does not match the operation payload");
|
|
728
|
+
await publishJson(operationPath, published);
|
|
729
|
+
return { outcome: "derived", world: toSummary(published) };
|
|
730
|
+
}
|
|
731
|
+
throw error;
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
try {
|
|
735
|
+
return await this.operations.withExclusiveWorldStateMutation(input.sourceWorldId, async () => this.operations.withWorldAuthorityLock(input.sourceWorldId, retainSourceSnapshot));
|
|
675
736
|
}
|
|
676
737
|
catch (error) {
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
if (published !== null) {
|
|
680
|
-
if (!(await derivationPublicationMatches(finalRoot, published, input, requestFingerprint)))
|
|
681
|
-
throw new FileNativeWorldCreationError("operation_conflict", "Published fork world does not match the operation payload");
|
|
682
|
-
await publishJson(operationPath, published);
|
|
683
|
-
return { outcome: "derived", world: toSummary(published) };
|
|
684
|
-
}
|
|
738
|
+
if (error instanceof WorldOperationBusyError)
|
|
739
|
+
throw new FileNativeWorldCreationError("operation_conflict", "The source world is changing and cannot provide one stable fork snapshot", { cause: error });
|
|
685
740
|
throw error;
|
|
686
741
|
}
|
|
687
742
|
}
|
|
@@ -698,7 +753,15 @@ export class FileNativeWorldStore {
|
|
|
698
753
|
assertIdentity(operationId, "operation ID");
|
|
699
754
|
let directRecord;
|
|
700
755
|
try {
|
|
701
|
-
|
|
756
|
+
let value = await readOptionalJson(this.#operationOutcomePath(operationId));
|
|
757
|
+
if (isRecord(value) &&
|
|
758
|
+
(value.outcome === "acceptance_prepared" ||
|
|
759
|
+
value.outcome === "committed" ||
|
|
760
|
+
value.outcome === "committed_materialization_pending") &&
|
|
761
|
+
typeof value.worldId === "string") {
|
|
762
|
+
await this.ensureCurrentStorage(value.worldId);
|
|
763
|
+
value = await readOptionalJson(this.#operationOutcomePath(operationId));
|
|
764
|
+
}
|
|
702
765
|
if (value === null)
|
|
703
766
|
directRecord = null;
|
|
704
767
|
else {
|
|
@@ -711,46 +774,18 @@ export class FileNativeWorldStore {
|
|
|
711
774
|
throw error;
|
|
712
775
|
throw new FileNativeWorldCreationError("world_corrupt", "Authority operation outcome has an invalid structure", error instanceof Error ? { cause: error } : undefined);
|
|
713
776
|
}
|
|
777
|
+
if (directRecord?.outcome === "acceptance_prepared")
|
|
778
|
+
return resolvePreparedOperationOutcome(this.#worldsRoot, operationId, directRecord);
|
|
714
779
|
const direct = directRecord === null ? null : projectOperationOutcome(directRecord);
|
|
715
780
|
if (direct?.outcome === "committed" ||
|
|
716
781
|
direct?.outcome === "committed_materialization_pending") {
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
await assertCommittedAuthorityOutcomeMatches(join(this.#worldsRoot, direct.worldId), operationId, direct);
|
|
725
|
-
await assertPendingMaterializationCompatible(join(this.#worldsRoot, direct.worldId), operationId);
|
|
726
|
-
}
|
|
727
|
-
return direct;
|
|
728
|
-
}
|
|
729
|
-
const worlds = await readDirectoryNames(this.#worldsRoot);
|
|
730
|
-
for (const worldId of worlds) {
|
|
731
|
-
const authority = await readAcceptedAuthority(join(this.#worldsRoot, worldId));
|
|
732
|
-
const commitIndex = authority?.commits.findIndex((candidate) => candidate.operationId === operationId) ?? -1;
|
|
733
|
-
const commit = authority?.commits[commitIndex];
|
|
734
|
-
if (commit !== undefined) {
|
|
735
|
-
const materialized = await readOptionalJson(join(this.#worldsRoot, worldId, "runtime", "materialized-head.json"));
|
|
736
|
-
const materializedIndex = materialized?.head === "genesis"
|
|
737
|
-
? -1
|
|
738
|
-
: (authority?.commits.findIndex(({ head }) => head === materialized?.head) ?? -1);
|
|
739
|
-
return {
|
|
740
|
-
outcome: materializedIndex >= commitIndex
|
|
741
|
-
? "committed"
|
|
742
|
-
: "committed_materialization_pending",
|
|
743
|
-
worldId,
|
|
744
|
-
parentHead: commit.parentHead,
|
|
745
|
-
head: commit.head,
|
|
746
|
-
historyAppend: commit.historyAppend.map(({ role, exactText }) => ({
|
|
747
|
-
role,
|
|
748
|
-
exactText,
|
|
749
|
-
})),
|
|
750
|
-
nextAdditionalMaterials: commit.nextAdditionalMaterials,
|
|
751
|
-
mode: commit.mode,
|
|
752
|
-
};
|
|
753
|
-
}
|
|
782
|
+
const worldRoot = join(this.#worldsRoot, direct.worldId);
|
|
783
|
+
const commit = await assertCommittedAuthorityOutcomeMatches(worldRoot, direct);
|
|
784
|
+
const materialized = await readMaterializedCheckpoint(worldRoot);
|
|
785
|
+
if (materialized !== null && materialized.sequence >= commit.sequence)
|
|
786
|
+
return { ...direct, outcome: "committed" };
|
|
787
|
+
await assertPendingMaterializationCompatible(worldRoot, direct);
|
|
788
|
+
return { ...direct, outcome: "committed_materialization_pending" };
|
|
754
789
|
}
|
|
755
790
|
return direct?.outcome === "in_progress"
|
|
756
791
|
? { outcome: "in_progress" }
|
|
@@ -773,11 +808,13 @@ export class FileNativeWorldStore {
|
|
|
773
808
|
throw new TypeError("Edited player message cannot be empty");
|
|
774
809
|
if (!/^sha256:[a-f0-9]{64}$/u.test(input.requestFingerprint))
|
|
775
810
|
throw new TypeError("Timeline-revision request fingerprint is invalid");
|
|
811
|
+
await this.ensureCurrentStorage(input.worldId);
|
|
776
812
|
const existing = await this.getOperationOutcome(input.operationId);
|
|
777
813
|
if (isCommittedOutcome(existing)) {
|
|
778
|
-
const
|
|
779
|
-
const commit =
|
|
814
|
+
const fact = await new FileNativeAuthorityV3(join(this.#worldsRoot, existing.worldId)).authorityFactAt(existing.head);
|
|
815
|
+
const commit = fact.commit;
|
|
780
816
|
if (existing.worldId !== input.worldId ||
|
|
817
|
+
fact.digest !== existing.commitDigest ||
|
|
781
818
|
commit?.mode !== "timeline_revision" ||
|
|
782
819
|
commit.timelineRevision?.restoresHead !== input.restoresHead ||
|
|
783
820
|
commit.timelineRevision.replacesHead !== input.replacesHead ||
|
|
@@ -791,15 +828,13 @@ export class FileNativeWorldStore {
|
|
|
791
828
|
return existing;
|
|
792
829
|
}
|
|
793
830
|
const root = join(this.#worldsRoot, input.worldId);
|
|
794
|
-
const
|
|
795
|
-
|
|
796
|
-
|
|
831
|
+
const authorityStore = new FileNativeAuthorityV3(root);
|
|
832
|
+
const [replaced, replacementMaterials] = await Promise.all([
|
|
833
|
+
authorityStore.commitAt(input.replacesHead),
|
|
834
|
+
authorityStore.recoverMaterials(input.restoresHead),
|
|
797
835
|
]);
|
|
798
|
-
const
|
|
799
|
-
|
|
800
|
-
? replaced.timelineRevision?.restoresHead
|
|
801
|
-
: replaced?.parentHead;
|
|
802
|
-
if (replaced === undefined ||
|
|
836
|
+
const logicalParent = replaced?.timelineParent.head;
|
|
837
|
+
if (replaced === null ||
|
|
803
838
|
!replaced.historyAppend.some(({ role }) => role === "player") ||
|
|
804
839
|
logicalParent !== input.restoresHead)
|
|
805
840
|
throw new FileNativeWorldCreationError("operation_conflict", "Selected player message does not match the logical parent endpoint to restore");
|
|
@@ -808,19 +843,18 @@ export class FileNativeWorldStore {
|
|
|
808
843
|
worldId: input.worldId,
|
|
809
844
|
parentHead: input.expectedCurrentHead,
|
|
810
845
|
historyAppend: [{ role: "player", exactText: input.replacementText }],
|
|
811
|
-
nextMaterials:
|
|
846
|
+
nextMaterials: worldNeutralMaterials(input.worldId, replacementMaterials),
|
|
812
847
|
stateChanges: [],
|
|
813
848
|
mode: "timeline_revision",
|
|
814
849
|
timelineRevision: {
|
|
815
850
|
restoresHead: input.restoresHead,
|
|
816
851
|
replacesHead: input.replacesHead,
|
|
817
852
|
requestFingerprint: input.requestFingerprint,
|
|
818
|
-
replacementState: immutableFiles(replacement.state),
|
|
819
|
-
replacementHistory: structuredClone(replacement.history),
|
|
820
853
|
},
|
|
821
854
|
});
|
|
822
855
|
}
|
|
823
856
|
async commitCorrection(input) {
|
|
857
|
+
await this.ensureCurrentStorage(input.worldId);
|
|
824
858
|
try {
|
|
825
859
|
const commit = async () => {
|
|
826
860
|
let operationReserved = input.stateOperationClaim !== undefined;
|
|
@@ -833,7 +867,7 @@ export class FileNativeWorldStore {
|
|
|
833
867
|
operationId: input.operationId,
|
|
834
868
|
worldId: input.worldId,
|
|
835
869
|
parentHead: input.parentHead,
|
|
836
|
-
nextMaterials: input.nextMaterials,
|
|
870
|
+
nextMaterials: worldNeutralMaterials(input.worldId, input.nextMaterials),
|
|
837
871
|
stateChanges: input.stateChanges,
|
|
838
872
|
mode: "correction",
|
|
839
873
|
historyAppend: [],
|
|
@@ -856,40 +890,39 @@ export class FileNativeWorldStore {
|
|
|
856
890
|
* separate commits; there is no synthetic call-chain-wide settlement boundary.
|
|
857
891
|
*/
|
|
858
892
|
async commitPlayStep(input) {
|
|
893
|
+
await this.ensureCurrentStorage(input.worldId);
|
|
859
894
|
if (input.historyAppend.length === 0 && input.stateChanges.length === 0)
|
|
860
895
|
throw new TypeError("A call-chain commit cannot omit both narrative and state changes");
|
|
861
896
|
return this.#commitHistoryChange({
|
|
862
897
|
...input,
|
|
898
|
+
nextMaterials: worldNeutralMaterials(input.worldId, input.nextMaterials),
|
|
863
899
|
mode: "play",
|
|
864
900
|
});
|
|
865
901
|
}
|
|
866
|
-
async readPlayCallChain(worldId) {
|
|
867
|
-
assertIdentity(worldId, "World ID");
|
|
868
|
-
return readOptionalJson(join(this.#worldsRoot, worldId, "runtime", "play-call-chain.json"));
|
|
869
|
-
}
|
|
870
|
-
async writePlayCallChain(worldId, value) {
|
|
871
|
-
assertIdentity(worldId, "World ID");
|
|
872
|
-
await publishJson(join(this.#worldsRoot, worldId, "runtime", "play-call-chain.json"), value);
|
|
873
|
-
}
|
|
874
|
-
async removePlayCallChain(worldId) {
|
|
875
|
-
assertIdentity(worldId, "World ID");
|
|
876
|
-
await rm(join(this.#worldsRoot, worldId, "runtime", "play-call-chain.json"), { force: true });
|
|
877
|
-
}
|
|
878
902
|
async #commitHistoryChange(input) {
|
|
879
903
|
const existing = await this.getOperationOutcome(input.operationId);
|
|
880
904
|
if (isCommittedOutcome(existing)) {
|
|
881
905
|
assertSameOperationOutcome(existing, input);
|
|
882
|
-
const
|
|
883
|
-
const
|
|
884
|
-
if (
|
|
885
|
-
|
|
886
|
-
JSON.stringify(input.stateChanges) ||
|
|
887
|
-
!isDeepStrictEqual(commit?.timelineRevision, input.timelineRevision))
|
|
906
|
+
const authority = new FileNativeAuthorityV3(join(this.#worldsRoot, existing.worldId));
|
|
907
|
+
const fact = await authority.authorityFactAt(existing.head);
|
|
908
|
+
if (fact.digest !== existing.commitDigest ||
|
|
909
|
+
!authorityCommitMatchesInput(fact.commit, input))
|
|
888
910
|
throw new FileNativeWorldCreationError("operation_conflict", "The same play operation ID is bound to a different complete commit payload");
|
|
889
911
|
return existing;
|
|
890
912
|
}
|
|
913
|
+
const prepared = existing.outcome === "in_progress"
|
|
914
|
+
? await readAcceptancePreparedOutcome(this.#operationOutcomePath(input.operationId))
|
|
915
|
+
: null;
|
|
916
|
+
if (prepared !== null) {
|
|
917
|
+
const comparable = {
|
|
918
|
+
...structuredClone(prepared),
|
|
919
|
+
outcome: "committed_materialization_pending",
|
|
920
|
+
};
|
|
921
|
+
assertSameOperationOutcome(comparable, input);
|
|
922
|
+
}
|
|
891
923
|
if (existing.outcome !== "not_started" &&
|
|
892
|
-
!(
|
|
924
|
+
!(existing.outcome === "in_progress" &&
|
|
925
|
+
(input.operationReserved === true || prepared !== null)))
|
|
893
926
|
throw new FileNativeWorldCreationError("operation_conflict", "The same play operation ID is occupied by another durable operation");
|
|
894
927
|
const root = join(this.#worldsRoot, input.worldId);
|
|
895
928
|
try {
|
|
@@ -897,42 +930,40 @@ export class FileNativeWorldStore {
|
|
|
897
930
|
const concurrent = await this.#readOperationOutcome(input.operationId);
|
|
898
931
|
if (isCommittedOutcome(concurrent)) {
|
|
899
932
|
assertSameOperationOutcome(concurrent, input);
|
|
900
|
-
const
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
!isDeepStrictEqual(knownCommit.stateChanges, input.stateChanges) ||
|
|
904
|
-
!isDeepStrictEqual(knownCommit.timelineRevision, input.timelineRevision))
|
|
933
|
+
const known = await new FileNativeAuthorityV3(join(this.#worldsRoot, concurrent.worldId)).authorityFactAt(concurrent.head);
|
|
934
|
+
if (known.digest !== concurrent.commitDigest ||
|
|
935
|
+
!authorityCommitMatchesInput(known.commit, input))
|
|
905
936
|
throw new FileNativeWorldCreationError("operation_conflict", "The same play operation ID is bound to a different complete commit payload");
|
|
906
937
|
return concurrent;
|
|
907
938
|
}
|
|
908
|
-
const
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
939
|
+
const concurrentPrepared = concurrent.outcome === "in_progress"
|
|
940
|
+
? await readAcceptancePreparedOutcome(this.#operationOutcomePath(input.operationId))
|
|
941
|
+
: null;
|
|
942
|
+
if (concurrentPrepared !== null)
|
|
943
|
+
assertSameOperationOutcome({
|
|
944
|
+
...structuredClone(concurrentPrepared),
|
|
945
|
+
outcome: "committed_materialization_pending",
|
|
946
|
+
}, input);
|
|
947
|
+
else if (concurrent.outcome === "in_progress" &&
|
|
948
|
+
input.operationReserved !== true)
|
|
949
|
+
throw new FileNativeWorldCreationError("operation_conflict", "The same play operation ID is occupied by another durable operation");
|
|
950
|
+
const authorityStore = new FileNativeAuthorityV3(root);
|
|
951
|
+
const authority = await authorityStore.readHead();
|
|
914
952
|
if (authority.head !== input.parentHead) {
|
|
915
953
|
throw new FileNativeWorldCreationError("operation_conflict", "The parent endpoint of the play operation has changed");
|
|
916
954
|
}
|
|
917
955
|
const materialized = await readOptionalJson(join(root, "runtime", "materialized-head.json"));
|
|
918
956
|
if ((materialized?.head ?? "genesis") !== authority.head)
|
|
919
957
|
throw new FileNativeWorldCreationError("operation_conflict", "The world has accepted but unrepaired materialization; new competing writes are forbidden");
|
|
920
|
-
const [
|
|
921
|
-
|
|
958
|
+
const [basisState, currentControl] = await Promise.all([
|
|
959
|
+
authorityStore.recoverState(input.timelineRevision?.restoresHead ?? authority.head),
|
|
922
960
|
readTree(join(root, "control")),
|
|
923
961
|
]);
|
|
924
|
-
const candidateState = new Map(
|
|
962
|
+
const candidateState = new Map(basisState.map((file) => [file.path, file.contents]));
|
|
925
963
|
if (input.mode === "timeline_revision") {
|
|
926
964
|
if (input.timelineRevision === undefined ||
|
|
927
965
|
input.stateChanges.length !== 0)
|
|
928
966
|
throw new FileNativeWorldCreationError("operation_conflict", "Timeline-revision commit payload is invalid");
|
|
929
|
-
candidateState.clear();
|
|
930
|
-
for (const file of input.timelineRevision.replacementState) {
|
|
931
|
-
assertRelativePath(file.path);
|
|
932
|
-
if (sha256(file.canonicalBytes) !== file.sha256)
|
|
933
|
-
throw new FileNativeWorldCreationError("operation_conflict", `Timeline-revision state hash conflicts: ${file.path}`);
|
|
934
|
-
candidateState.set(file.path, file.canonicalBytes);
|
|
935
|
-
}
|
|
936
967
|
}
|
|
937
968
|
else {
|
|
938
969
|
if (input.timelineRevision !== undefined)
|
|
@@ -959,64 +990,58 @@ export class FileNativeWorldStore {
|
|
|
959
990
|
], { requireOpening: false });
|
|
960
991
|
if (inspection.status !== "usable")
|
|
961
992
|
throw new FileNativeWorldCreationError("candidate_validation_failed", `Candidate world failed mechanical validation: ${inspection.issues.map(({ message }) => message).join("; ")}`);
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
993
|
+
if (concurrentPrepared === null)
|
|
994
|
+
await authorityStore.discardUnacceptedNextEpoch();
|
|
995
|
+
crashAtFileNativeAuthorityEdge("before_commit_acceptance");
|
|
996
|
+
const preparedAppend = await authorityStore.prepareAppend({
|
|
966
997
|
operationId: input.operationId,
|
|
967
998
|
parentHead: input.parentHead,
|
|
968
|
-
|
|
999
|
+
...(input.timelineRevision === undefined
|
|
1000
|
+
? {}
|
|
1001
|
+
: { timelineParentHead: input.timelineRevision.restoresHead }),
|
|
969
1002
|
mode: input.mode,
|
|
970
|
-
historyAppend: input.historyAppend
|
|
971
|
-
messageId: `${input.worldId}.message.${sequence}.${index + 1}.${message.role}`,
|
|
972
|
-
...structuredClone(message),
|
|
973
|
-
})),
|
|
1003
|
+
historyAppend: input.historyAppend,
|
|
974
1004
|
stateChanges: structuredClone(input.stateChanges),
|
|
975
|
-
|
|
1005
|
+
nextMaterials: input.nextMaterials,
|
|
976
1006
|
...(input.mode === "correction"
|
|
977
1007
|
? {
|
|
978
1008
|
correctionTargets: input.stateChanges.map(({ documentId }) => documentId),
|
|
979
1009
|
corrects: input.parentHead,
|
|
980
1010
|
}
|
|
981
1011
|
: {}),
|
|
982
|
-
...(input.
|
|
983
|
-
? {
|
|
984
|
-
|
|
985
|
-
}
|
|
986
|
-
: {}),
|
|
987
|
-
};
|
|
988
|
-
crashAtFileNativeAuthorityEdge("before_commit_acceptance");
|
|
989
|
-
await publishImmutableJson(join(root, "runtime", "play-commits", `${createHash("sha256").update(JSON.stringify(commit)).digest("hex")}.json`), commit);
|
|
990
|
-
await publishJson(authorityPath, {
|
|
991
|
-
schemaVersion: 1,
|
|
992
|
-
head,
|
|
993
|
-
commits: [...authority.commits, commit],
|
|
1012
|
+
...(input.timelineRevision === undefined
|
|
1013
|
+
? {}
|
|
1014
|
+
: { timelineRevision: input.timelineRevision }),
|
|
994
1015
|
});
|
|
995
|
-
crashAtFileNativeAuthorityEdge("
|
|
1016
|
+
crashAtFileNativeAuthorityEdge("after_authority_objects");
|
|
1017
|
+
const acceptedPreparation = concurrentPrepared ?? prepared;
|
|
1018
|
+
if (acceptedPreparation !== null &&
|
|
1019
|
+
acceptedPreparation.commitDigest !== preparedAppend.commitDigest)
|
|
1020
|
+
throw new FileNativeWorldCreationError("operation_conflict", "The prepared Authority fact does not match the reserved operation");
|
|
1021
|
+
const { commit } = preparedAppend;
|
|
996
1022
|
const pending = {
|
|
997
1023
|
outcome: "committed_materialization_pending",
|
|
998
1024
|
worldId: input.worldId,
|
|
999
1025
|
parentHead: input.parentHead,
|
|
1000
|
-
head,
|
|
1026
|
+
head: commit.head,
|
|
1027
|
+
commitDigest: preparedAppend.commitDigest,
|
|
1001
1028
|
historyAppend: structuredClone(input.historyAppend),
|
|
1002
1029
|
nextAdditionalMaterials: structuredClone(input.nextMaterials),
|
|
1003
1030
|
mode: input.mode,
|
|
1004
1031
|
};
|
|
1032
|
+
await publishJson(this.#operationOutcomePath(input.operationId), {
|
|
1033
|
+
...pending,
|
|
1034
|
+
outcome: "acceptance_prepared",
|
|
1035
|
+
});
|
|
1036
|
+
crashAtFileNativeAuthorityEdge("after_acceptance_prepared");
|
|
1037
|
+
await authorityStore.publishPrepared(preparedAppend);
|
|
1038
|
+
crashAtFileNativeAuthorityEdge("after_commit_acceptance");
|
|
1005
1039
|
await publishJson(this.#operationOutcomePath(input.operationId), pending);
|
|
1006
1040
|
try {
|
|
1007
|
-
await
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
nextMaterials: input.nextMaterials,
|
|
1011
|
-
stateChanges: input.stateChanges,
|
|
1012
|
-
...(commit.timelineRevision === undefined
|
|
1013
|
-
? {}
|
|
1014
|
-
: { timelineRevision: commit.timelineRevision }),
|
|
1015
|
-
});
|
|
1041
|
+
const recovered = await authorityStore.recover(commit.head);
|
|
1042
|
+
await assertPendingMaterializationCompatible(root, pending);
|
|
1043
|
+
await materializeRecoveredEndpoint(root, preparedAppend.nextHead, recovered);
|
|
1016
1044
|
crashAtFileNativeAuthorityEdge("after_materialization");
|
|
1017
|
-
await publishJson(join(root, "runtime", "materialized-head.json"), {
|
|
1018
|
-
head,
|
|
1019
|
-
});
|
|
1020
1045
|
const outcome = { ...pending, outcome: "committed" };
|
|
1021
1046
|
await publishJson(this.#operationOutcomePath(input.operationId), outcome);
|
|
1022
1047
|
return outcome;
|
|
@@ -1033,17 +1058,18 @@ export class FileNativeWorldStore {
|
|
|
1033
1058
|
catch (error) {
|
|
1034
1059
|
if (error instanceof WorldOperationBusyError)
|
|
1035
1060
|
throw new FileNativeWorldCreationError("operation_conflict", "World Authority commit is being serialized by another process");
|
|
1061
|
+
if (error instanceof FileNativeAuthorityV3Error)
|
|
1062
|
+
throw new FileNativeWorldCreationError(error.code === "corrupt" ? "world_corrupt" : "operation_conflict", error.message, { cause: error });
|
|
1036
1063
|
throw error;
|
|
1037
1064
|
}
|
|
1038
1065
|
}
|
|
1039
1066
|
async readAuthorityHistory(worldId) {
|
|
1040
1067
|
assertIdentity(worldId, "World ID");
|
|
1068
|
+
await this.ensureCurrentStorage(worldId);
|
|
1041
1069
|
const root = join(this.#worldsRoot, worldId);
|
|
1042
1070
|
await readPublicationAt(root);
|
|
1043
|
-
const authority = await
|
|
1044
|
-
return authority
|
|
1045
|
-
? { head: "genesis", commits: [] }
|
|
1046
|
-
: { head: authority.head, commits: structuredClone(authority.commits) };
|
|
1071
|
+
const authority = await new FileNativeAuthorityV3(root).readHistory();
|
|
1072
|
+
return structuredClone(authority);
|
|
1047
1073
|
}
|
|
1048
1074
|
async readAuthorityEndpoint(worldId, endpoint) {
|
|
1049
1075
|
const recovered = await this.recoverEndpoint(worldId, endpoint);
|
|
@@ -1115,7 +1141,7 @@ export class FileNativeWorldStore {
|
|
|
1115
1141
|
return join(this.#operationsRoot, `derive-${operationDigest(operationId)}.json`);
|
|
1116
1142
|
}
|
|
1117
1143
|
#preview(input, worldId) {
|
|
1118
|
-
const openingMessage = genesisOpeningMessage(
|
|
1144
|
+
const openingMessage = genesisOpeningMessage(openingText(input.packageFiles));
|
|
1119
1145
|
return this.#promptCompiler.preview({
|
|
1120
1146
|
endpoint: { id: `${worldId}:genesis`, commit: "genesis" },
|
|
1121
1147
|
hostBinding: input.prompt.hostBinding,
|
|
@@ -1148,9 +1174,9 @@ function openingText(files) {
|
|
|
1148
1174
|
throw new FileNativeWorldCreationError("content_package_needs_repair", "Content package is missing a usable opening.md");
|
|
1149
1175
|
return opening.contents;
|
|
1150
1176
|
}
|
|
1151
|
-
function genesisOpeningMessage(
|
|
1177
|
+
function genesisOpeningMessage(exactText) {
|
|
1152
1178
|
return {
|
|
1153
|
-
messageId:
|
|
1179
|
+
messageId: "message.genesis.narrator",
|
|
1154
1180
|
role: "narrator",
|
|
1155
1181
|
exactText,
|
|
1156
1182
|
};
|
|
@@ -1222,19 +1248,6 @@ async function readTree(root, prefix = "") {
|
|
|
1222
1248
|
}
|
|
1223
1249
|
return files;
|
|
1224
1250
|
}
|
|
1225
|
-
async function readDirectoryNames(root) {
|
|
1226
|
-
try {
|
|
1227
|
-
return (await readdir(root, { withFileTypes: true }))
|
|
1228
|
-
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
|
|
1229
|
-
.map((entry) => entry.name)
|
|
1230
|
-
.sort();
|
|
1231
|
-
}
|
|
1232
|
-
catch (error) {
|
|
1233
|
-
if (isNodeError(error) && error.code === "ENOENT")
|
|
1234
|
-
return [];
|
|
1235
|
-
throw error;
|
|
1236
|
-
}
|
|
1237
|
-
}
|
|
1238
1251
|
function immutableFiles(files) {
|
|
1239
1252
|
return files.map(({ path, contents }) => ({
|
|
1240
1253
|
path,
|
|
@@ -1286,13 +1299,15 @@ function assertOperationOutcomeIntegrity(value) {
|
|
|
1286
1299
|
throw corruptOperationOutcome();
|
|
1287
1300
|
return;
|
|
1288
1301
|
}
|
|
1289
|
-
if (value.outcome === "
|
|
1302
|
+
if (value.outcome === "acceptance_prepared" ||
|
|
1303
|
+
value.outcome === "committed" ||
|
|
1290
1304
|
value.outcome === "committed_materialization_pending") {
|
|
1291
1305
|
if (!hasExactKeys(value, [
|
|
1292
1306
|
"outcome",
|
|
1293
1307
|
"worldId",
|
|
1294
1308
|
"parentHead",
|
|
1295
1309
|
"head",
|
|
1310
|
+
"commitDigest",
|
|
1296
1311
|
"historyAppend",
|
|
1297
1312
|
"nextAdditionalMaterials",
|
|
1298
1313
|
"mode",
|
|
@@ -1303,6 +1318,8 @@ function assertOperationOutcomeIntegrity(value) {
|
|
|
1303
1318
|
!/^(?:genesis|commit:[1-9][0-9]*)$/u.test(value.parentHead) ||
|
|
1304
1319
|
typeof value.head !== "string" ||
|
|
1305
1320
|
!/^commit:[1-9][0-9]*$/u.test(value.head) ||
|
|
1321
|
+
typeof value.commitDigest !== "string" ||
|
|
1322
|
+
!/^[a-f0-9]{64}$/u.test(value.commitDigest) ||
|
|
1306
1323
|
!Array.isArray(value.historyAppend) ||
|
|
1307
1324
|
!value.historyAppend.every(isPlayHistoryAppendInput) ||
|
|
1308
1325
|
!Array.isArray(value.nextAdditionalMaterials) ||
|
|
@@ -1318,53 +1335,95 @@ function assertOperationOutcomeIntegrity(value) {
|
|
|
1318
1335
|
function projectOperationOutcome(value) {
|
|
1319
1336
|
return structuredClone(value);
|
|
1320
1337
|
}
|
|
1321
|
-
function
|
|
1322
|
-
|
|
1338
|
+
async function readAcceptancePreparedOutcome(path) {
|
|
1339
|
+
const value = await readOptionalJson(path);
|
|
1340
|
+
if (value === null)
|
|
1341
|
+
return null;
|
|
1342
|
+
assertOperationOutcomeIntegrity(value);
|
|
1343
|
+
if (value.outcome !== "acceptance_prepared")
|
|
1344
|
+
return null;
|
|
1345
|
+
return structuredClone(value);
|
|
1323
1346
|
}
|
|
1324
|
-
async function
|
|
1325
|
-
const
|
|
1326
|
-
const
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1347
|
+
async function resolvePreparedOperationOutcome(worldsRoot, operationId, value) {
|
|
1348
|
+
const prepared = value;
|
|
1349
|
+
const worldRoot = join(worldsRoot, prepared.worldId);
|
|
1350
|
+
const authorityStore = new FileNativeAuthorityV3(worldRoot);
|
|
1351
|
+
const authority = await authorityStore.readHead();
|
|
1352
|
+
if (authority.head === prepared.parentHead)
|
|
1353
|
+
return { outcome: "in_progress" };
|
|
1354
|
+
if (authority.operationId !== operationId)
|
|
1355
|
+
throw corruptOperationOutcome();
|
|
1356
|
+
const commit = await assertCommittedAuthorityOutcomeMatches(worldRoot, prepared);
|
|
1357
|
+
if (authority.sequence < commit.sequence)
|
|
1332
1358
|
throw corruptOperationOutcome();
|
|
1359
|
+
const materialized = await readMaterializedCheckpoint(worldRoot);
|
|
1360
|
+
const outcome = materialized !== null && materialized.sequence >= commit.sequence
|
|
1361
|
+
? "committed"
|
|
1362
|
+
: "committed_materialization_pending";
|
|
1363
|
+
const resolved = { ...structuredClone(prepared), outcome };
|
|
1364
|
+
if (outcome === "committed_materialization_pending")
|
|
1365
|
+
await assertPendingMaterializationCompatible(worldRoot, resolved);
|
|
1366
|
+
return resolved;
|
|
1333
1367
|
}
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1368
|
+
function corruptOperationOutcome() {
|
|
1369
|
+
return new FileNativeWorldCreationError("world_corrupt", "Authority operation outcome structure or operation identity is invalid");
|
|
1370
|
+
}
|
|
1371
|
+
async function assertCommittedAuthorityOutcomeMatches(worldRoot, outcome) {
|
|
1372
|
+
const fact = await new FileNativeAuthorityV3(worldRoot).authorityFactAt(outcome.head);
|
|
1373
|
+
const { commit } = fact;
|
|
1374
|
+
if (fact.digest !== outcome.commitDigest ||
|
|
1375
|
+
commit.auditParent.head !== outcome.parentHead ||
|
|
1376
|
+
commit.head !== outcome.head ||
|
|
1377
|
+
commit.mode !== outcome.mode ||
|
|
1378
|
+
!isDeepStrictEqual(commit.historyAppend.map(({ role, exactText }) => ({ role, exactText })), outcome.historyAppend) ||
|
|
1379
|
+
!isDeepStrictEqual(commit.nextAdditionalMaterials, outcome.nextAdditionalMaterials))
|
|
1339
1380
|
throw corruptOperationOutcome();
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
throw new FileNativeWorldCreationError("inconsistent_materialization", "History-message materialization content conflict");
|
|
1354
|
-
}
|
|
1381
|
+
return commit;
|
|
1382
|
+
}
|
|
1383
|
+
async function assertPendingMaterializationCompatible(worldRoot, outcome) {
|
|
1384
|
+
const authority = new FileNativeAuthorityV3(worldRoot);
|
|
1385
|
+
await assertCommittedAuthorityOutcomeMatches(worldRoot, outcome);
|
|
1386
|
+
const [parent, target, state, history] = await Promise.all([
|
|
1387
|
+
authority.recover(outcome.parentHead),
|
|
1388
|
+
authority.recover(outcome.head),
|
|
1389
|
+
readTree(join(worldRoot, "state")),
|
|
1390
|
+
readTree(join(worldRoot, "history")),
|
|
1391
|
+
]);
|
|
1392
|
+
assertProjectionBetweenEndpoints("state", state, parent.state, target.state);
|
|
1393
|
+
assertHistoryProjectionBetweenEndpoints(history, historySurfaceFiles(parent.history), historySurfaceFiles(target.history));
|
|
1355
1394
|
const existingMaterials = await readOptionalJson(join(worldRoot, "runtime", "additional-materials.json"));
|
|
1356
|
-
if (existingMaterials
|
|
1357
|
-
!
|
|
1395
|
+
if (existingMaterials !== null &&
|
|
1396
|
+
!((existingMaterials.head === parent.head &&
|
|
1397
|
+
isDeepStrictEqual(existingMaterials.items, parent.additionalMaterials)) ||
|
|
1398
|
+
(existingMaterials.head === target.head &&
|
|
1399
|
+
isDeepStrictEqual(existingMaterials.items, target.additionalMaterials))))
|
|
1358
1400
|
throw new FileNativeWorldCreationError("inconsistent_materialization", "Additional-material list content conflicts at the same endpoint");
|
|
1359
1401
|
}
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1402
|
+
function assertProjectionBetweenEndpoints(surface, current, parent, target) {
|
|
1403
|
+
const parentFiles = new Map(parent.map((file) => [file.path, file.contents]));
|
|
1404
|
+
const targetFiles = new Map(target.map((file) => [file.path, file.contents]));
|
|
1405
|
+
for (const file of current) {
|
|
1406
|
+
if (file.contents !== parentFiles.get(file.path) &&
|
|
1407
|
+
file.contents !== targetFiles.get(file.path))
|
|
1408
|
+
throw new FileNativeWorldCreationError("inconsistent_materialization", `World ${surface} projection conflicts with both accepted endpoints: ${file.path}`);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
function assertHistoryProjectionBetweenEndpoints(current, parent, target) {
|
|
1412
|
+
const byMessageId = (files) => new Map(files.map((file) => [
|
|
1413
|
+
fileNativeHistoryMessageIdFromProjectionPath(file.path),
|
|
1414
|
+
file.contents,
|
|
1415
|
+
]));
|
|
1416
|
+
const parentFiles = byMessageId(parent);
|
|
1417
|
+
const targetFiles = byMessageId(target);
|
|
1418
|
+
const seen = new Set();
|
|
1419
|
+
for (const file of current) {
|
|
1420
|
+
const messageId = fileNativeHistoryMessageIdFromProjectionPath(file.path);
|
|
1421
|
+
if (messageId === null ||
|
|
1422
|
+
seen.has(messageId) ||
|
|
1423
|
+
(file.contents !== parentFiles.get(messageId) &&
|
|
1424
|
+
file.contents !== targetFiles.get(messageId)))
|
|
1425
|
+
throw new FileNativeWorldCreationError("inconsistent_materialization", `World history projection conflicts with both accepted endpoints: ${file.path}`);
|
|
1426
|
+
seen.add(messageId);
|
|
1368
1427
|
}
|
|
1369
1428
|
}
|
|
1370
1429
|
function isAuthorityMaterialSelection(value) {
|
|
@@ -1400,228 +1459,36 @@ async function publishJson(path, value) {
|
|
|
1400
1459
|
await rename(temporary, path);
|
|
1401
1460
|
await syncDirectory(dirname(path));
|
|
1402
1461
|
}
|
|
1403
|
-
async function
|
|
1404
|
-
await
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
try {
|
|
1408
|
-
await writeFile(temporary, contents, {
|
|
1409
|
-
encoding: "utf8",
|
|
1410
|
-
flag: "wx",
|
|
1411
|
-
mode: 0o600,
|
|
1412
|
-
});
|
|
1413
|
-
await syncFile(temporary);
|
|
1414
|
-
try {
|
|
1415
|
-
await link(temporary, path);
|
|
1416
|
-
await syncDirectory(dirname(path));
|
|
1417
|
-
return true;
|
|
1418
|
-
}
|
|
1419
|
-
catch (error) {
|
|
1420
|
-
if (!isNodeError(error) || error.code !== "EEXIST")
|
|
1421
|
-
throw error;
|
|
1422
|
-
if ((await readFile(path, "utf8")) !== contents)
|
|
1423
|
-
throw new FileNativeWorldCreationError("operation_conflict", "Immutable play-commit file conflicts");
|
|
1424
|
-
return false;
|
|
1425
|
-
}
|
|
1426
|
-
}
|
|
1427
|
-
finally {
|
|
1428
|
-
await rm(temporary, { force: true });
|
|
1429
|
-
}
|
|
1430
|
-
}
|
|
1431
|
-
async function readAcceptedAuthority(root) {
|
|
1432
|
-
try {
|
|
1433
|
-
const published = await readOptionalJson(join(root, "runtime", "play-authority.json"));
|
|
1434
|
-
if (published === null) {
|
|
1435
|
-
const commitRoot = join(root, "runtime", "play-commits");
|
|
1436
|
-
const names = await readDirectoryFileNames(commitRoot);
|
|
1437
|
-
if (names.length === 0)
|
|
1438
|
-
return null;
|
|
1439
|
-
const remaining = await Promise.all(names.map(async (name) => {
|
|
1440
|
-
const commit = await readJson(join(commitRoot, name));
|
|
1441
|
-
assertFileNativePlayCommit(commit);
|
|
1442
|
-
const canonicalName = `${createHash("sha256")
|
|
1443
|
-
.update(JSON.stringify(commit))
|
|
1444
|
-
.digest("hex")}.json`;
|
|
1445
|
-
if (name !== canonicalName)
|
|
1446
|
-
throw new FileNativeWorldCreationError("world_corrupt", "Immutable commit does not match the durable path identity");
|
|
1447
|
-
return commit;
|
|
1448
|
-
}));
|
|
1449
|
-
const commits = [];
|
|
1450
|
-
let parent = "genesis";
|
|
1451
|
-
while (remaining.length > 0) {
|
|
1452
|
-
const matches = remaining.filter((commit) => commit.parentHead === parent);
|
|
1453
|
-
if (matches.length !== 1)
|
|
1454
|
-
throw new FileNativeWorldCreationError("world_corrupt", "Immutable commits cannot rebuild one unique authority chain");
|
|
1455
|
-
const next = matches[0];
|
|
1456
|
-
commits.push(next);
|
|
1457
|
-
remaining.splice(remaining.indexOf(next), 1);
|
|
1458
|
-
parent = next.head;
|
|
1459
|
-
}
|
|
1460
|
-
assertFileNativePlayAuthority({
|
|
1461
|
-
schemaVersion: 1,
|
|
1462
|
-
head: parent,
|
|
1463
|
-
commits,
|
|
1464
|
-
});
|
|
1465
|
-
return { schemaVersion: 1, head: parent, commits };
|
|
1466
|
-
}
|
|
1467
|
-
assertFileNativePlayAuthority(published);
|
|
1468
|
-
const commits = [];
|
|
1469
|
-
for (const recorded of published.commits) {
|
|
1470
|
-
const digest = createHash("sha256")
|
|
1471
|
-
.update(JSON.stringify(recorded))
|
|
1472
|
-
.digest("hex");
|
|
1473
|
-
const immutable = await readJson(join(root, "runtime", "play-commits", `${digest}.json`)).catch((error) => {
|
|
1474
|
-
throw new FileNativeWorldCreationError("world_corrupt", `Accepted endpoint is missing an immutable commit: ${recorded.head}`, { cause: error });
|
|
1475
|
-
});
|
|
1476
|
-
assertFileNativePlayCommit(immutable);
|
|
1477
|
-
if (JSON.stringify(immutable) !== JSON.stringify(recorded))
|
|
1478
|
-
throw new FileNativeWorldCreationError("world_corrupt", `Accepted endpoint does not match its immutable commit: ${recorded.head}`);
|
|
1479
|
-
commits.push(immutable);
|
|
1480
|
-
}
|
|
1481
|
-
return { ...published, commits };
|
|
1482
|
-
}
|
|
1483
|
-
catch (error) {
|
|
1484
|
-
if (error instanceof FileNativeWorldCreationError)
|
|
1485
|
-
throw error;
|
|
1486
|
-
throw new FileNativeWorldCreationError("world_corrupt", "Play Authority durable data is corrupt", { cause: error });
|
|
1487
|
-
}
|
|
1488
|
-
}
|
|
1489
|
-
function assertFileNativePlayAuthority(value) {
|
|
1490
|
-
if (!isRecord(value) ||
|
|
1491
|
-
!hasExactKeys(value, ["schemaVersion", "head", "commits"]) ||
|
|
1492
|
-
value.schemaVersion !== 1 ||
|
|
1493
|
-
typeof value.head !== "string" ||
|
|
1494
|
-
!Array.isArray(value.commits))
|
|
1495
|
-
throw new Error("Play Authority has an invalid shape");
|
|
1496
|
-
const operationIds = new Set();
|
|
1497
|
-
const heads = new Set();
|
|
1498
|
-
const commitsByHead = new Map();
|
|
1499
|
-
let parent = "genesis";
|
|
1500
|
-
for (const [index, commit] of value.commits.entries()) {
|
|
1501
|
-
assertFileNativePlayCommit(commit);
|
|
1502
|
-
const revision = commit.timelineRevision;
|
|
1503
|
-
const replaced = revision === undefined
|
|
1504
|
-
? undefined
|
|
1505
|
-
: commitsByHead.get(revision.replacesHead);
|
|
1506
|
-
const replacedLogicalParent = replaced?.mode === "timeline_revision"
|
|
1507
|
-
? replaced.timelineRevision.restoresHead
|
|
1508
|
-
: replaced?.parentHead;
|
|
1509
|
-
if (commit.parentHead !== parent ||
|
|
1510
|
-
commit.head !== `commit:${index + 1}` ||
|
|
1511
|
-
operationIds.has(commit.operationId) ||
|
|
1512
|
-
heads.has(commit.head) ||
|
|
1513
|
-
(commit.mode === "timeline_revision" &&
|
|
1514
|
-
((revision.restoresHead !== "genesis" &&
|
|
1515
|
-
!heads.has(revision.restoresHead)) ||
|
|
1516
|
-
replaced === undefined ||
|
|
1517
|
-
!replaced.historyAppend.some(({ role }) => role === "player") ||
|
|
1518
|
-
replacedLogicalParent !== revision.restoresHead)))
|
|
1519
|
-
throw new Error("Play Authority commit chain is invalid");
|
|
1520
|
-
operationIds.add(commit.operationId);
|
|
1521
|
-
heads.add(commit.head);
|
|
1522
|
-
commitsByHead.set(commit.head, commit);
|
|
1523
|
-
parent = commit.head;
|
|
1524
|
-
}
|
|
1525
|
-
if (value.head !== parent)
|
|
1526
|
-
throw new Error("Play Authority head is invalid");
|
|
1527
|
-
}
|
|
1528
|
-
function assertFileNativePlayCommit(value) {
|
|
1529
|
-
if (!isRecord(value))
|
|
1530
|
-
throw new Error("Play commit has an invalid shape");
|
|
1531
|
-
const correction = value.mode === "correction";
|
|
1532
|
-
const timelineRevision = value.mode === "timeline_revision";
|
|
1533
|
-
if (!hasExactKeys(value, [
|
|
1534
|
-
"schemaVersion",
|
|
1535
|
-
"operationId",
|
|
1536
|
-
"parentHead",
|
|
1537
|
-
"head",
|
|
1538
|
-
"mode",
|
|
1539
|
-
"historyAppend",
|
|
1540
|
-
"stateChanges",
|
|
1541
|
-
"nextAdditionalMaterials",
|
|
1542
|
-
...(correction ? ["correctionTargets", "corrects"] : []),
|
|
1543
|
-
...(timelineRevision ? ["timelineRevision"] : []),
|
|
1544
|
-
]) ||
|
|
1545
|
-
value.schemaVersion !== 1 ||
|
|
1546
|
-
typeof value.operationId !== "string" ||
|
|
1547
|
-
value.operationId.trim() === "" ||
|
|
1548
|
-
typeof value.parentHead !== "string" ||
|
|
1549
|
-
!/^(?:genesis|commit:[1-9][0-9]*)$/u.test(value.parentHead) ||
|
|
1550
|
-
typeof value.head !== "string" ||
|
|
1551
|
-
!/^commit:[1-9][0-9]*$/u.test(value.head) ||
|
|
1552
|
-
(value.mode !== "play" &&
|
|
1553
|
-
value.mode !== "correction" &&
|
|
1554
|
-
value.mode !== "timeline_revision") ||
|
|
1555
|
-
!Array.isArray(value.historyAppend) ||
|
|
1556
|
-
(correction
|
|
1557
|
-
? value.historyAppend.length !== 0
|
|
1558
|
-
: timelineRevision
|
|
1559
|
-
? value.historyAppend.length !== 1 ||
|
|
1560
|
-
!isRecord(value.historyAppend[0]) ||
|
|
1561
|
-
value.historyAppend[0].role !== "player"
|
|
1562
|
-
: value.historyAppend.length === 0 &&
|
|
1563
|
-
(!Array.isArray(value.stateChanges) ||
|
|
1564
|
-
value.stateChanges.length === 0)) ||
|
|
1565
|
-
!value.historyAppend.every(isFileNativeHistoryMessage) ||
|
|
1566
|
-
!Array.isArray(value.stateChanges) ||
|
|
1567
|
-
!value.stateChanges.every(isFileNativeStateChange) ||
|
|
1568
|
-
!Array.isArray(value.nextAdditionalMaterials) ||
|
|
1569
|
-
!value.nextAdditionalMaterials.every(isAuthorityMaterialSelection))
|
|
1570
|
-
throw new Error("Play commit has an invalid shape");
|
|
1571
|
-
if (correction &&
|
|
1572
|
-
(!Array.isArray(value.correctionTargets) ||
|
|
1573
|
-
!value.correctionTargets.every((target) => typeof target === "string" && target.trim() !== "") ||
|
|
1574
|
-
typeof value.corrects !== "string" ||
|
|
1575
|
-
value.corrects !== value.parentHead))
|
|
1576
|
-
throw new Error("Correction commit has an invalid shape");
|
|
1577
|
-
if (timelineRevision &&
|
|
1578
|
-
(value.stateChanges.length !== 0 ||
|
|
1579
|
-
!isFileNativeTimelineRevision(value.timelineRevision)))
|
|
1580
|
-
throw new Error("Timeline-revision commit has an invalid shape");
|
|
1581
|
-
}
|
|
1582
|
-
function isFileNativeTimelineRevision(value) {
|
|
1462
|
+
async function readMaterializedCheckpoint(root) {
|
|
1463
|
+
const value = await readOptionalJson(join(root, "runtime", "materialized-head.json"));
|
|
1464
|
+
if (value === null)
|
|
1465
|
+
return null;
|
|
1583
1466
|
if (!isRecord(value) ||
|
|
1584
1467
|
!hasExactKeys(value, [
|
|
1585
|
-
"
|
|
1586
|
-
"
|
|
1587
|
-
"
|
|
1588
|
-
"
|
|
1589
|
-
"replacementHistory",
|
|
1468
|
+
"schemaVersion",
|
|
1469
|
+
"head",
|
|
1470
|
+
"sequence",
|
|
1471
|
+
"commitDigest",
|
|
1590
1472
|
]) ||
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
assertRelativePath(file.path);
|
|
1611
|
-
}
|
|
1612
|
-
catch {
|
|
1613
|
-
return false;
|
|
1614
|
-
}
|
|
1615
|
-
}
|
|
1616
|
-
return true;
|
|
1617
|
-
}
|
|
1618
|
-
function isFileNativeHistoryMessage(value) {
|
|
1619
|
-
return (isRecord(value) &&
|
|
1620
|
-
hasExactKeys(value, ["messageId", "role", "exactText"]) &&
|
|
1621
|
-
typeof value.messageId === "string" &&
|
|
1622
|
-
value.messageId.trim() !== "" &&
|
|
1623
|
-
(value.role === "player" || value.role === "narrator") &&
|
|
1624
|
-
typeof value.exactText === "string");
|
|
1473
|
+
value.schemaVersion !== 3 ||
|
|
1474
|
+
typeof value.head !== "string" ||
|
|
1475
|
+
!Number.isSafeInteger(value.sequence) ||
|
|
1476
|
+
Number(value.sequence) < 0 ||
|
|
1477
|
+
(value.commitDigest !== null &&
|
|
1478
|
+
(typeof value.commitDigest !== "string" ||
|
|
1479
|
+
!/^[a-f0-9]{64}$/u.test(value.commitDigest))) ||
|
|
1480
|
+
(Number(value.sequence) === 0
|
|
1481
|
+
? value.head !== "genesis" || value.commitDigest !== null
|
|
1482
|
+
: value.head !== `commit:${String(value.sequence)}` ||
|
|
1483
|
+
value.commitDigest === null))
|
|
1484
|
+
throw new FileNativeWorldCreationError("world_corrupt", "Materialized world checkpoint has an invalid shape");
|
|
1485
|
+
return value;
|
|
1486
|
+
}
|
|
1487
|
+
function sameMaterializedEndpoint(checkpoint, authority) {
|
|
1488
|
+
return (checkpoint !== null &&
|
|
1489
|
+
checkpoint.head === authority.head &&
|
|
1490
|
+
checkpoint.sequence === authority.sequence &&
|
|
1491
|
+
checkpoint.commitDigest === authority.commitDigest);
|
|
1625
1492
|
}
|
|
1626
1493
|
function isPlayHistoryAppendInput(value) {
|
|
1627
1494
|
return (isRecord(value) &&
|
|
@@ -1629,107 +1496,22 @@ function isPlayHistoryAppendInput(value) {
|
|
|
1629
1496
|
(value.role === "player" || value.role === "narrator") &&
|
|
1630
1497
|
typeof value.exactText === "string");
|
|
1631
1498
|
}
|
|
1632
|
-
function
|
|
1633
|
-
if (
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
"documentId",
|
|
1637
|
-
"stableShortRef",
|
|
1638
|
-
"relativePath",
|
|
1639
|
-
"codec",
|
|
1640
|
-
"expectedPreviousHash",
|
|
1641
|
-
"nextHash",
|
|
1642
|
-
"canonicalNextBytes",
|
|
1643
|
-
]) ||
|
|
1644
|
-
(value.kind !== "create" && value.kind !== "replace") ||
|
|
1645
|
-
typeof value.documentId !== "string" ||
|
|
1646
|
-
value.documentId.trim() === "" ||
|
|
1647
|
-
typeof value.stableShortRef !== "string" ||
|
|
1648
|
-
value.stableShortRef.trim() === "" ||
|
|
1649
|
-
typeof value.relativePath !== "string" ||
|
|
1650
|
-
(value.codec !== "yaml" && value.codec !== "markdown") ||
|
|
1651
|
-
(value.expectedPreviousHash !== null &&
|
|
1652
|
-
(typeof value.expectedPreviousHash !== "string" ||
|
|
1653
|
-
!/^sha256:[a-f0-9]{64}$/u.test(value.expectedPreviousHash))) ||
|
|
1654
|
-
typeof value.nextHash !== "string" ||
|
|
1655
|
-
!/^sha256:[a-f0-9]{64}$/u.test(value.nextHash) ||
|
|
1656
|
-
typeof value.canonicalNextBytes !== "string" ||
|
|
1657
|
-
`sha256:${createHash("sha256")
|
|
1658
|
-
.update(value.canonicalNextBytes)
|
|
1659
|
-
.digest("hex")}` !== value.nextHash)
|
|
1660
|
-
return false;
|
|
1661
|
-
try {
|
|
1662
|
-
assertRelativePath(value.relativePath);
|
|
1663
|
-
}
|
|
1664
|
-
catch {
|
|
1665
|
-
return false;
|
|
1666
|
-
}
|
|
1667
|
-
return true;
|
|
1668
|
-
}
|
|
1669
|
-
async function readDirectoryFileNames(root) {
|
|
1670
|
-
try {
|
|
1671
|
-
return (await readdir(root, { withFileTypes: true }))
|
|
1672
|
-
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
1673
|
-
.map((entry) => entry.name)
|
|
1674
|
-
.sort();
|
|
1675
|
-
}
|
|
1676
|
-
catch (error) {
|
|
1677
|
-
if (isNodeError(error) && error.code === "ENOENT")
|
|
1678
|
-
return [];
|
|
1679
|
-
throw error;
|
|
1680
|
-
}
|
|
1681
|
-
}
|
|
1682
|
-
async function materializePlayCommit(root, sequence, input) {
|
|
1683
|
-
if (input.mode === "timeline_revision") {
|
|
1684
|
-
if (input.timelineRevision === undefined)
|
|
1685
|
-
throw new FileNativeWorldCreationError("world_corrupt", "Timeline-revision commit is missing its materialization snapshot");
|
|
1686
|
-
await replaceMaterializedTree(join(root, "state"), input.timelineRevision.replacementState.map(({ path, canonicalBytes }) => ({ path, contents: canonicalBytes })));
|
|
1687
|
-
}
|
|
1688
|
-
else {
|
|
1689
|
-
for (const change of input.stateChanges) {
|
|
1690
|
-
assertRelativePath(change.relativePath);
|
|
1691
|
-
const path = join(root, "state", change.relativePath);
|
|
1692
|
-
let existing = null;
|
|
1693
|
-
try {
|
|
1694
|
-
existing = await readFile(path, "utf8");
|
|
1695
|
-
}
|
|
1696
|
-
catch (error) {
|
|
1697
|
-
if (!isNodeError(error) || error.code !== "ENOENT")
|
|
1698
|
-
throw error;
|
|
1699
|
-
}
|
|
1700
|
-
const existingHash = existing === null ? null : sha256(existing);
|
|
1701
|
-
if (existingHash === change.nextHash)
|
|
1702
|
-
continue;
|
|
1703
|
-
if (existingHash !== change.expectedPreviousHash)
|
|
1704
|
-
throw new FileNativeWorldCreationError("inconsistent_materialization", `World-state materialization conflict: ${change.relativePath}`);
|
|
1705
|
-
await publishText(path, change.canonicalNextBytes);
|
|
1706
|
-
}
|
|
1707
|
-
}
|
|
1499
|
+
async function materializeRecoveredEndpoint(root, authority, endpoint) {
|
|
1500
|
+
if (authority.head !== endpoint.head)
|
|
1501
|
+
throw new FileNativeWorldCreationError("world_corrupt", "Recovered endpoint does not match the Authority head to materialize");
|
|
1502
|
+
await replaceMaterializedTree(join(root, "state"), endpoint.state);
|
|
1708
1503
|
crashAtFileNativeAuthorityEdge("after_state_materialization");
|
|
1709
|
-
|
|
1710
|
-
await
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
}
|
|
1721
|
-
const materialsPath = join(root, "runtime", "additional-materials.json");
|
|
1722
|
-
const nextMaterials = {
|
|
1723
|
-
head: `commit:${sequence}`,
|
|
1724
|
-
items: input.nextMaterials,
|
|
1725
|
-
};
|
|
1726
|
-
const existingMaterials = await readOptionalJson(materialsPath);
|
|
1727
|
-
if (existingMaterials?.head === nextMaterials.head &&
|
|
1728
|
-
JSON.stringify(existingMaterials.items) !==
|
|
1729
|
-
JSON.stringify(nextMaterials.items))
|
|
1730
|
-
throw new FileNativeWorldCreationError("inconsistent_materialization", "Additional-material list content conflicts at the same endpoint");
|
|
1731
|
-
if (JSON.stringify(existingMaterials) !== JSON.stringify(nextMaterials))
|
|
1732
|
-
await publishJson(materialsPath, nextMaterials);
|
|
1504
|
+
await replaceMaterializedTree(join(root, "history"), historySurfaceFiles(endpoint.history));
|
|
1505
|
+
await publishJson(join(root, "runtime", "additional-materials.json"), {
|
|
1506
|
+
head: authority.head,
|
|
1507
|
+
items: endpoint.additionalMaterials,
|
|
1508
|
+
});
|
|
1509
|
+
await publishJson(join(root, "runtime", "materialized-head.json"), {
|
|
1510
|
+
schemaVersion: 3,
|
|
1511
|
+
head: authority.head,
|
|
1512
|
+
sequence: authority.sequence,
|
|
1513
|
+
commitDigest: authority.commitDigest,
|
|
1514
|
+
});
|
|
1733
1515
|
}
|
|
1734
1516
|
async function replaceMaterializedTree(root, files) {
|
|
1735
1517
|
const next = new Map();
|
|
@@ -1762,26 +1544,22 @@ async function publishText(path, contents) {
|
|
|
1762
1544
|
await syncDirectory(dirname(path));
|
|
1763
1545
|
}
|
|
1764
1546
|
function historySurfaceFiles(messages) {
|
|
1765
|
-
return messages
|
|
1766
|
-
|
|
1767
|
-
const committed = /\.message\.([1-9][0-9]*)\.([1-9][0-9]*)\.(player|narrator)$/u.exec(message.messageId);
|
|
1768
|
-
const sequence = genesis !== null ? 0 : Number.parseInt(committed?.[1] ?? "", 10);
|
|
1769
|
-
const index = genesis !== null
|
|
1770
|
-
? Number.parseInt(genesis[1] ?? "1", 10)
|
|
1771
|
-
: Number.parseInt(committed?.[2] ?? "", 10);
|
|
1772
|
-
const role = genesis?.[2] ?? committed?.[3];
|
|
1773
|
-
if (!Number.isSafeInteger(sequence) ||
|
|
1774
|
-
sequence < 0 ||
|
|
1775
|
-
!Number.isSafeInteger(index) ||
|
|
1776
|
-
index < 1 ||
|
|
1777
|
-
role !== message.role)
|
|
1778
|
-
throw new FileNativeWorldCreationError("world_corrupt", `History-message identity cannot form a materialization path: ${message.messageId}`);
|
|
1779
|
-
return {
|
|
1780
|
-
path: `${String(sequence).padStart(8, "0")}-${historyFileName(index, message)}`,
|
|
1781
|
-
contents: message.exactText,
|
|
1782
|
-
};
|
|
1547
|
+
return projectFileNativeHistorySurface(messages, (messageId) => {
|
|
1548
|
+
throw new FileNativeWorldCreationError("world_corrupt", `History-message identity cannot form a materialization path: ${messageId}`);
|
|
1783
1549
|
});
|
|
1784
1550
|
}
|
|
1551
|
+
function materializedHistoryRecord(files) {
|
|
1552
|
+
const result = {};
|
|
1553
|
+
for (const file of files) {
|
|
1554
|
+
const messageId = fileNativeHistoryMessageIdFromProjectionPath(file.path);
|
|
1555
|
+
if (messageId === null)
|
|
1556
|
+
throw new FileNativeWorldCreationError("world_corrupt", `Materialized history path is invalid: ${file.path}`);
|
|
1557
|
+
if (messageId in result)
|
|
1558
|
+
throw new FileNativeWorldCreationError("world_corrupt", `Materialized history contains a duplicate message: ${messageId}`);
|
|
1559
|
+
result[messageId] = file.contents;
|
|
1560
|
+
}
|
|
1561
|
+
return result;
|
|
1562
|
+
}
|
|
1785
1563
|
async function writeIdempotentText(path, contents) {
|
|
1786
1564
|
try {
|
|
1787
1565
|
const existing = await readFile(path, "utf8");
|
|
@@ -1798,72 +1576,6 @@ async function writeIdempotentText(path, contents) {
|
|
|
1798
1576
|
await syncDirectory(dirname(path));
|
|
1799
1577
|
}
|
|
1800
1578
|
}
|
|
1801
|
-
function historyFileName(index, message) {
|
|
1802
|
-
return `${String(index).padStart(2, "0")}-${message.role}-${operationDigest(message.messageId).slice(0, 12)}.md`;
|
|
1803
|
-
}
|
|
1804
|
-
function cloneAuthorityPrefix(input) {
|
|
1805
|
-
const end = input.sourceHead === "genesis"
|
|
1806
|
-
? 0
|
|
1807
|
-
: (input.sourceAuthority?.commits.findIndex(({ head }) => head === input.sourceHead) ?? -1) + 1;
|
|
1808
|
-
if (input.sourceHead !== "genesis" && end === 0)
|
|
1809
|
-
throw new FileNativeWorldCreationError("world_corrupt", `Cannot copy a nonexistent source Authority endpoint: ${input.sourceHead}`);
|
|
1810
|
-
const sourceCommits = input.sourceAuthority?.commits.slice(0, end) ?? [];
|
|
1811
|
-
const messageIds = new Map();
|
|
1812
|
-
const bindMessageId = (source, target) => {
|
|
1813
|
-
if (messageIds.has(source))
|
|
1814
|
-
throw new FileNativeWorldCreationError("world_corrupt", `Source Authority contains a duplicate history-message identity: ${source}`);
|
|
1815
|
-
messageIds.set(source, target);
|
|
1816
|
-
};
|
|
1817
|
-
for (const [index, message] of input.sourceGenesis.history.entries())
|
|
1818
|
-
bindMessageId(message.messageId, index === 0 && message.role === "narrator"
|
|
1819
|
-
? `${input.targetWorldId}.message.genesis.narrator`
|
|
1820
|
-
: `${input.targetWorldId}.message.genesis.${index + 1}.${message.role}`);
|
|
1821
|
-
for (const [commitIndex, commit] of sourceCommits.entries())
|
|
1822
|
-
for (const [messageIndex, message] of commit.historyAppend.entries())
|
|
1823
|
-
bindMessageId(message.messageId, `${input.targetWorldId}.message.${commitIndex + 1}.${messageIndex + 1}.${message.role}`);
|
|
1824
|
-
const rewriteHistory = (history) => history.map((message) => ({
|
|
1825
|
-
...structuredClone(message),
|
|
1826
|
-
messageId: messageIds.get(message.messageId),
|
|
1827
|
-
}));
|
|
1828
|
-
return {
|
|
1829
|
-
genesisHistory: rewriteHistory(input.sourceGenesis.history),
|
|
1830
|
-
genesisMaterials: rewriteMaterials(input.sourceGenesis.additionalMaterials, messageIds, input.sourceWorldId, input.targetWorldId),
|
|
1831
|
-
commits: sourceCommits.map((commit) => ({
|
|
1832
|
-
...structuredClone(commit),
|
|
1833
|
-
operationId: `clone-${operationDigest(`${input.targetWorldId}\0${commit.head}`).slice(0, 56)}`,
|
|
1834
|
-
historyAppend: rewriteHistory(commit.historyAppend),
|
|
1835
|
-
nextAdditionalMaterials: rewriteMaterials(commit.nextAdditionalMaterials, messageIds, input.sourceWorldId, input.targetWorldId),
|
|
1836
|
-
...(commit.timelineRevision === undefined
|
|
1837
|
-
? {}
|
|
1838
|
-
: {
|
|
1839
|
-
timelineRevision: {
|
|
1840
|
-
...structuredClone(commit.timelineRevision),
|
|
1841
|
-
replacementHistory: rewriteHistory(commit.timelineRevision.replacementHistory),
|
|
1842
|
-
},
|
|
1843
|
-
}),
|
|
1844
|
-
})),
|
|
1845
|
-
};
|
|
1846
|
-
}
|
|
1847
|
-
function rewriteMaterials(materials, messageIds, sourceWorldId, targetWorldId) {
|
|
1848
|
-
return materials.map((material) => {
|
|
1849
|
-
if (material.kind === "history_message") {
|
|
1850
|
-
const message = messageIds.get(material.message);
|
|
1851
|
-
if (message === undefined)
|
|
1852
|
-
throw new FileNativeWorldCreationError("world_corrupt", `Fork material references a history message that cannot be remapped: ${material.message}`);
|
|
1853
|
-
return { ...material, message };
|
|
1854
|
-
}
|
|
1855
|
-
if (material.kind === "history_commit") {
|
|
1856
|
-
const sourcePrefix = `${sourceWorldId}.`;
|
|
1857
|
-
return {
|
|
1858
|
-
...material,
|
|
1859
|
-
commit: material.commit.startsWith(sourcePrefix)
|
|
1860
|
-
? `${targetWorldId}.${material.commit.slice(sourcePrefix.length)}`
|
|
1861
|
-
: material.commit,
|
|
1862
|
-
};
|
|
1863
|
-
}
|
|
1864
|
-
return structuredClone(material);
|
|
1865
|
-
});
|
|
1866
|
-
}
|
|
1867
1579
|
function isCommittedOutcome(outcome) {
|
|
1868
1580
|
return (outcome.outcome === "committed" ||
|
|
1869
1581
|
outcome.outcome === "committed_materialization_pending");
|
|
@@ -1876,6 +1588,63 @@ function assertSameOperationOutcome(outcome, input) {
|
|
|
1876
1588
|
throw new FileNativeWorldCreationError("operation_conflict", "The same play operation ID is bound to a different commit payload");
|
|
1877
1589
|
}
|
|
1878
1590
|
}
|
|
1591
|
+
function worldNeutralMaterials(worldId, materials) {
|
|
1592
|
+
const prefix = `${worldId}.`;
|
|
1593
|
+
return materials.map((material) => {
|
|
1594
|
+
if (material.kind === "history_message") {
|
|
1595
|
+
const message = material.message.startsWith(prefix)
|
|
1596
|
+
? material.message.slice(prefix.length)
|
|
1597
|
+
: material.message;
|
|
1598
|
+
if (!/^message\.(?:genesis(?:\.[1-9][0-9]*)?\.(?:player|narrator)|[1-9][0-9]*\.[1-9][0-9]*\.(?:player|narrator))$/u.test(message))
|
|
1599
|
+
throw new FileNativeWorldCreationError("operation_conflict", "History material does not name a local immutable message");
|
|
1600
|
+
return { ...material, message };
|
|
1601
|
+
}
|
|
1602
|
+
if (material.kind === "history_commit") {
|
|
1603
|
+
const commit = material.commit.startsWith(prefix)
|
|
1604
|
+
? material.commit.slice(prefix.length)
|
|
1605
|
+
: material.commit;
|
|
1606
|
+
if (!/^(?:genesis|commit:[1-9][0-9]*)$/u.test(commit))
|
|
1607
|
+
throw new FileNativeWorldCreationError("operation_conflict", "History material does not name a local immutable Authority endpoint");
|
|
1608
|
+
return { ...material, commit };
|
|
1609
|
+
}
|
|
1610
|
+
return structuredClone(material);
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
function authorityCommitMatchesInput(commit, input) {
|
|
1614
|
+
const stateChangesMatch = commit.stateChanges.length === input.stateChanges.length &&
|
|
1615
|
+
commit.stateChanges.every((stored, index) => {
|
|
1616
|
+
const candidate = input.stateChanges[index];
|
|
1617
|
+
if (candidate === undefined)
|
|
1618
|
+
return false;
|
|
1619
|
+
const { nextBlob, ...metadata } = stored;
|
|
1620
|
+
const expected = {
|
|
1621
|
+
kind: candidate.kind,
|
|
1622
|
+
documentId: candidate.documentId,
|
|
1623
|
+
stableShortRef: candidate.stableShortRef,
|
|
1624
|
+
relativePath: candidate.relativePath,
|
|
1625
|
+
codec: candidate.codec,
|
|
1626
|
+
expectedPreviousHash: candidate.expectedPreviousHash,
|
|
1627
|
+
nextHash: candidate.nextHash,
|
|
1628
|
+
};
|
|
1629
|
+
return (nextBlob.epoch === commit.sequence &&
|
|
1630
|
+
`sha256:${nextBlob.digest}` === candidate.nextHash &&
|
|
1631
|
+
isDeepStrictEqual(metadata, expected));
|
|
1632
|
+
});
|
|
1633
|
+
const expectedCorrection = input.mode === "correction"
|
|
1634
|
+
? input.stateChanges.map(({ documentId }) => documentId)
|
|
1635
|
+
: undefined;
|
|
1636
|
+
return (commit.mode === input.mode &&
|
|
1637
|
+
commit.auditParent.head === input.parentHead &&
|
|
1638
|
+
commit.timelineParent.head ===
|
|
1639
|
+
(input.timelineRevision?.restoresHead ?? input.parentHead) &&
|
|
1640
|
+
isDeepStrictEqual(commit.historyAppend.map(({ role, exactText }) => ({ role, exactText })), input.historyAppend) &&
|
|
1641
|
+
stateChangesMatch &&
|
|
1642
|
+
isDeepStrictEqual(commit.nextAdditionalMaterials, input.nextMaterials) &&
|
|
1643
|
+
isDeepStrictEqual(commit.timelineRevision, input.timelineRevision) &&
|
|
1644
|
+
isDeepStrictEqual(commit.correctionTargets, expectedCorrection) &&
|
|
1645
|
+
commit.corrects ===
|
|
1646
|
+
(input.mode === "correction" ? input.parentHead : undefined));
|
|
1647
|
+
}
|
|
1879
1648
|
function openWorldDocumentSnapshot(files) {
|
|
1880
1649
|
return WorldDocumentStore.open({
|
|
1881
1650
|
layout: "world_state",
|
|
@@ -1923,6 +1692,68 @@ async function durableTree(root) {
|
|
|
1923
1692
|
}
|
|
1924
1693
|
await syncDirectory(root);
|
|
1925
1694
|
}
|
|
1695
|
+
async function readGenesisForFork(worldRoot, worldId) {
|
|
1696
|
+
const value = await readJson(join(worldRoot, "runtime", "genesis.json"));
|
|
1697
|
+
if (!isRecord(value) ||
|
|
1698
|
+
value.schemaVersion !== 1 ||
|
|
1699
|
+
value.type !== "file_native_genesis" ||
|
|
1700
|
+
value.worldId !== worldId ||
|
|
1701
|
+
!Array.isArray(value.state) ||
|
|
1702
|
+
!Array.isArray(value.history) ||
|
|
1703
|
+
!Array.isArray(value.additionalMaterials))
|
|
1704
|
+
throw new FileNativeWorldCreationError("world_corrupt", "World genesis projection has an invalid shape");
|
|
1705
|
+
const state = value.state.map((file) => {
|
|
1706
|
+
if (!isRecord(file) ||
|
|
1707
|
+
!hasExactKeys(file, ["path", "sha256", "canonicalBytes"]) ||
|
|
1708
|
+
typeof file.path !== "string" ||
|
|
1709
|
+
typeof file.sha256 !== "string" ||
|
|
1710
|
+
typeof file.canonicalBytes !== "string" ||
|
|
1711
|
+
sha256(file.canonicalBytes) !== file.sha256)
|
|
1712
|
+
throw new FileNativeWorldCreationError("world_corrupt", "World genesis state projection is corrupt");
|
|
1713
|
+
try {
|
|
1714
|
+
assertRelativePath(file.path);
|
|
1715
|
+
}
|
|
1716
|
+
catch (error) {
|
|
1717
|
+
throw new FileNativeWorldCreationError("world_corrupt", "World genesis state path is invalid", error instanceof Error ? { cause: error } : undefined);
|
|
1718
|
+
}
|
|
1719
|
+
return {
|
|
1720
|
+
path: file.path,
|
|
1721
|
+
sha256: file.sha256,
|
|
1722
|
+
canonicalBytes: file.canonicalBytes,
|
|
1723
|
+
};
|
|
1724
|
+
});
|
|
1725
|
+
const prefix = `${worldId}.`;
|
|
1726
|
+
const history = value.history.map((message, index) => {
|
|
1727
|
+
if (!isRecord(message) ||
|
|
1728
|
+
!hasExactKeys(message, ["messageId", "role", "exactText"]) ||
|
|
1729
|
+
typeof message.messageId !== "string" ||
|
|
1730
|
+
(message.role !== "player" && message.role !== "narrator") ||
|
|
1731
|
+
typeof message.exactText !== "string")
|
|
1732
|
+
throw new FileNativeWorldCreationError("world_corrupt", "World genesis history projection is corrupt");
|
|
1733
|
+
const messageId = message.messageId.startsWith(prefix)
|
|
1734
|
+
? message.messageId.slice(prefix.length)
|
|
1735
|
+
: message.messageId;
|
|
1736
|
+
const role = message.role === "player" ? "player" : "narrator";
|
|
1737
|
+
const expected = index === 0 && role === "narrator"
|
|
1738
|
+
? "message.genesis.narrator"
|
|
1739
|
+
: `message.genesis.${index + 1}.${role}`;
|
|
1740
|
+
if (messageId !== expected)
|
|
1741
|
+
throw new FileNativeWorldCreationError("world_corrupt", "World genesis history identity is invalid");
|
|
1742
|
+
return {
|
|
1743
|
+
messageId,
|
|
1744
|
+
role,
|
|
1745
|
+
exactText: message.exactText,
|
|
1746
|
+
};
|
|
1747
|
+
});
|
|
1748
|
+
let additionalMaterials;
|
|
1749
|
+
try {
|
|
1750
|
+
additionalMaterials = worldNeutralMaterials(worldId, value.additionalMaterials);
|
|
1751
|
+
}
|
|
1752
|
+
catch (error) {
|
|
1753
|
+
throw new FileNativeWorldCreationError("world_corrupt", "World genesis material projection is invalid", error instanceof Error ? { cause: error } : undefined);
|
|
1754
|
+
}
|
|
1755
|
+
return { state, history, additionalMaterials };
|
|
1756
|
+
}
|
|
1926
1757
|
async function readPublicationAt(root) {
|
|
1927
1758
|
return readJson(join(root, publicationFile));
|
|
1928
1759
|
}
|