narraeon 0.2.0 → 0.3.1
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/v1.js +6 -0
- package/dist/node/runtime/FileNativePhysicalClone.js +86 -0
- package/dist/node/runtime/V1Runtime.js +7 -6
- package/dist/node/runtime/model/FileNativeModelAdapters.js +40 -13
- package/dist/node/runtime/model/ModelHost.js +7 -0
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +96 -7
- package/dist/node/runtime/play/PlayCallChain.js +309 -53
- package/dist/node/runtime/play/PlayFollowupRequests.js +42 -22
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +5 -5
- 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 +454 -6
- package/dist/node/runtime/world/FileNativeWorldStore.js +423 -850
- 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-DHV_VWl3.js +0 -1192
- package/dist/web/assets/index-DjLCkP99.css +0 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export function fileNativeHistoryMessageIdFromProjectionPath(path) {
|
|
3
|
+
const match = /^(\d{8})-(\d+)-(player|narrator)-[a-f0-9]{12}\.md$/u.exec(path);
|
|
4
|
+
if (match === null)
|
|
5
|
+
return null;
|
|
6
|
+
const sequence = Number.parseInt(match[1], 10);
|
|
7
|
+
const index = Number.parseInt(match[2], 10);
|
|
8
|
+
const role = match[3];
|
|
9
|
+
return sequence === 0
|
|
10
|
+
? index === 1 && role === "narrator"
|
|
11
|
+
? "message.genesis.narrator"
|
|
12
|
+
: `message.genesis.${String(index)}.${role}`
|
|
13
|
+
: `message.${String(sequence)}.${String(index)}.${role}`;
|
|
14
|
+
}
|
|
15
|
+
export function projectFileNativeHistorySurface(messages, invalidIdentity) {
|
|
16
|
+
return messages.map((message) => {
|
|
17
|
+
const genesis = /(?:^|\.)message\.genesis(?:\.([1-9][0-9]*))?\.(player|narrator)$/u.exec(message.messageId);
|
|
18
|
+
const committed = /(?:^|\.)message\.([1-9][0-9]*)\.([1-9][0-9]*)\.(player|narrator)$/u.exec(message.messageId);
|
|
19
|
+
const sequence = genesis !== null ? 0 : Number.parseInt(committed?.[1] ?? "", 10);
|
|
20
|
+
const index = genesis !== null
|
|
21
|
+
? Number.parseInt(genesis[1] ?? "1", 10)
|
|
22
|
+
: Number.parseInt(committed?.[2] ?? "", 10);
|
|
23
|
+
const role = genesis?.[2] ?? committed?.[3];
|
|
24
|
+
if (!Number.isSafeInteger(sequence) ||
|
|
25
|
+
sequence < 0 ||
|
|
26
|
+
!Number.isSafeInteger(index) ||
|
|
27
|
+
index < 1 ||
|
|
28
|
+
role !== message.role)
|
|
29
|
+
return invalidIdentity(message.messageId);
|
|
30
|
+
const suffix = createHash("sha256")
|
|
31
|
+
.update(message.messageId)
|
|
32
|
+
.digest("hex")
|
|
33
|
+
.slice(0, 12);
|
|
34
|
+
return {
|
|
35
|
+
path: `${String(sequence).padStart(8, "0")}-${String(index).padStart(2, "0")}-${message.role}-${suffix}.md`,
|
|
36
|
+
contents: message.exactText,
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -2,6 +2,8 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { link, mkdir, open, readFile, readdir, rename, rm, writeFile, } from "node:fs/promises";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { authorityV3Directory, continuityHeadFile, FileNativeAuthorityV3, } from "./FileNativeAuthorityV3.js";
|
|
6
|
+
import { projectFileNativeHistorySurface } from "./FileNativeHistoryProjection.js";
|
|
5
7
|
const currentAuthorityHeadFile = "play-authority-head.json";
|
|
6
8
|
const releasedAuthorityFile = "play-authority.json";
|
|
7
9
|
const releasedCallChainFile = "play-call-chain.json";
|
|
@@ -25,15 +27,32 @@ export class FileNativeWorldStorageMigrator {
|
|
|
25
27
|
assertIdentity(worldId, "World ID");
|
|
26
28
|
const worldRoot = join(this.#worldsRoot, worldId);
|
|
27
29
|
const runtimeRoot = join(worldRoot, "runtime");
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
30
|
-
|
|
30
|
+
const currentAuthority = new FileNativeAuthorityV3(worldRoot);
|
|
31
|
+
if (await currentAuthority.exists()) {
|
|
32
|
+
const head = await currentAuthority.readHead();
|
|
31
33
|
return {
|
|
32
34
|
outcome: "already_current",
|
|
33
|
-
authorityCommits:
|
|
35
|
+
authorityCommits: head.sequence,
|
|
34
36
|
callChainContexts: 0,
|
|
35
37
|
};
|
|
36
38
|
}
|
|
39
|
+
const current = await readOptionalJson(join(runtimeRoot, currentAuthorityHeadFile));
|
|
40
|
+
if (current !== null) {
|
|
41
|
+
assertCurrentAuthorityHead(current);
|
|
42
|
+
const commits = await readCurrentAuthority(worldRoot, current);
|
|
43
|
+
if ((commits.at(-1)?.operationId ?? null) !== current.operationId)
|
|
44
|
+
throw new Error("Current Authority head operation does not match its immutable tip");
|
|
45
|
+
const contexts = await this.#timeline.readAllContexts(worldId);
|
|
46
|
+
assertCurrentTimelineAuthorityRefs(contexts, commits);
|
|
47
|
+
const checkpoint = await migrateMaterializedCheckpoint(runtimeRoot, commits);
|
|
48
|
+
return this.#migrateCurrentAuthority({
|
|
49
|
+
worldId,
|
|
50
|
+
worldRoot,
|
|
51
|
+
commits,
|
|
52
|
+
checkpoint,
|
|
53
|
+
callChainContexts: contexts.length,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
37
56
|
const publication = await readJson(join(worldRoot, "publication.json"));
|
|
38
57
|
if (!isRecord(publication) ||
|
|
39
58
|
publication.schemaVersion !== 1 ||
|
|
@@ -59,6 +78,10 @@ export class FileNativeWorldStorageMigrator {
|
|
|
59
78
|
await this.#timeline.migrateReleasedRecord(worldId, value, sha256Hex(callChainSource));
|
|
60
79
|
}
|
|
61
80
|
crashAtStorageMigrationEdge("after_timeline");
|
|
81
|
+
const migratedContexts = await this.#timeline.readAllContexts(worldId);
|
|
82
|
+
if (migratedContexts.length !== callChainContexts)
|
|
83
|
+
throw new Error("Released play timeline migration changed its context closure");
|
|
84
|
+
assertCurrentTimelineAuthorityRefs(migratedContexts, currentCommits);
|
|
62
85
|
for (const commit of currentCommits)
|
|
63
86
|
await publishImmutableJson(join(runtimeRoot, "play-commits", `${currentCommitDigest(commit)}.json`), commit);
|
|
64
87
|
const checkpoint = await migrateMaterializedCheckpoint(runtimeRoot, currentCommits);
|
|
@@ -103,12 +126,327 @@ export class FileNativeWorldStorageMigrator {
|
|
|
103
126
|
};
|
|
104
127
|
await publishJson(join(runtimeRoot, currentAuthorityHeadFile), head);
|
|
105
128
|
crashAtStorageMigrationEdge("after_current_head");
|
|
129
|
+
return this.#migrateCurrentAuthority({
|
|
130
|
+
worldId,
|
|
131
|
+
worldRoot,
|
|
132
|
+
commits: currentCommits,
|
|
133
|
+
checkpoint,
|
|
134
|
+
callChainContexts,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
async #migrateCurrentAuthority(input) {
|
|
138
|
+
return migrateCurrentAuthorityToV3({
|
|
139
|
+
...input,
|
|
140
|
+
operationsRoot: this.#operationsRoot,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function migrateCurrentAuthorityToV3(input) {
|
|
145
|
+
const runtimeRoot = join(input.worldRoot, "runtime");
|
|
146
|
+
const genesis = await readCurrentGenesis(input.worldRoot, input.worldId);
|
|
147
|
+
const normalized = normalizeAuthorityIdentity(genesis.history, input.commits);
|
|
148
|
+
const stagingWorld = join(runtimeRoot, ".authority-v3-migration");
|
|
149
|
+
const stagedRuntime = join(stagingWorld, "runtime");
|
|
150
|
+
const targetAuthority = join(runtimeRoot, authorityV3Directory);
|
|
151
|
+
await rm(stagingWorld, { recursive: true, force: true });
|
|
152
|
+
const staged = new FileNativeAuthorityV3(stagingWorld);
|
|
153
|
+
try {
|
|
154
|
+
const genesisMaterials = normalizeMaterials(genesis.additionalMaterials, normalized.messageIds, input.worldId);
|
|
155
|
+
await staged.initialize({
|
|
156
|
+
operationId: genesis.operationId,
|
|
157
|
+
state: genesis.state,
|
|
158
|
+
history: normalized.genesisHistory,
|
|
159
|
+
additionalMaterials: genesisMaterials,
|
|
160
|
+
});
|
|
161
|
+
const expectedEndpoints = new Map([
|
|
162
|
+
[
|
|
163
|
+
"genesis",
|
|
164
|
+
{
|
|
165
|
+
state: structuredClone(genesis.state),
|
|
166
|
+
history: structuredClone(normalized.genesisHistory),
|
|
167
|
+
additionalMaterials: structuredClone(genesisMaterials),
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
]);
|
|
171
|
+
const migrated = [];
|
|
172
|
+
for (const source of input.commits) {
|
|
173
|
+
const historyAppend = source.historyAppend.map((message) => normalizeHistoryMessage(message, normalized.messageIds));
|
|
174
|
+
const nextMaterials = normalizeMaterials(source.nextAdditionalMaterials, normalized.messageIds, input.worldId);
|
|
175
|
+
const basisHead = source.timelineRevision?.restoresHead ?? source.parentHead;
|
|
176
|
+
const basis = expectedEndpoints.get(basisHead);
|
|
177
|
+
if (basis === undefined)
|
|
178
|
+
throw new Error("Released Authority transition references an unknown result basis");
|
|
179
|
+
if (source.timelineRevision !== undefined) {
|
|
180
|
+
const restored = await staged.recover(source.timelineRevision.restoresHead);
|
|
181
|
+
const expectedHistory = source.timelineRevision.replacementHistory.map((message) => normalizeHistoryMessage(message, normalized.messageIds));
|
|
182
|
+
if (!isDeepStrictEqual(canonicalState(restored.state), canonicalLegacyState(source.timelineRevision.replacementState)) ||
|
|
183
|
+
!isDeepStrictEqual(restored.history, expectedHistory) ||
|
|
184
|
+
!isDeepStrictEqual(restored.additionalMaterials, nextMaterials))
|
|
185
|
+
throw new Error("Released timeline revision does not exactly restore its logical parent");
|
|
186
|
+
}
|
|
187
|
+
const prepared = await staged.prepareAppend({
|
|
188
|
+
operationId: source.operationId,
|
|
189
|
+
parentHead: source.parentHead,
|
|
190
|
+
...(source.timelineRevision === undefined
|
|
191
|
+
? {}
|
|
192
|
+
: { timelineParentHead: source.timelineRevision.restoresHead }),
|
|
193
|
+
mode: source.mode,
|
|
194
|
+
historyAppend: historyAppend.map(({ role, exactText }) => ({
|
|
195
|
+
role,
|
|
196
|
+
exactText,
|
|
197
|
+
})),
|
|
198
|
+
stateChanges: source.stateChanges,
|
|
199
|
+
nextMaterials,
|
|
200
|
+
...(source.correctionTargets === undefined
|
|
201
|
+
? {}
|
|
202
|
+
: { correctionTargets: source.correctionTargets }),
|
|
203
|
+
...(source.corrects === undefined ? {} : { corrects: source.corrects }),
|
|
204
|
+
...(source.timelineRevision === undefined
|
|
205
|
+
? {}
|
|
206
|
+
: {
|
|
207
|
+
timelineRevision: {
|
|
208
|
+
restoresHead: source.timelineRevision.restoresHead,
|
|
209
|
+
replacesHead: source.timelineRevision.replacesHead,
|
|
210
|
+
requestFingerprint: source.timelineRevision.requestFingerprint,
|
|
211
|
+
},
|
|
212
|
+
}),
|
|
213
|
+
});
|
|
214
|
+
if (!isDeepStrictEqual(prepared.commit.historyAppend, historyAppend) ||
|
|
215
|
+
prepared.commit.head !== source.head)
|
|
216
|
+
throw new Error("Migrated Authority fact changed released semantics");
|
|
217
|
+
await staged.publishPrepared(prepared);
|
|
218
|
+
const expected = applyReleasedTransition(basis, source, historyAppend, nextMaterials);
|
|
219
|
+
const recovered = await staged.recover(source.head);
|
|
220
|
+
if (!isDeepStrictEqual(canonicalState(recovered.state), canonicalState(expected.state)) ||
|
|
221
|
+
!isDeepStrictEqual(recovered.history, expected.history) ||
|
|
222
|
+
!isDeepStrictEqual(recovered.additionalMaterials, expected.additionalMaterials))
|
|
223
|
+
throw new Error(`Migrated Authority endpoint changed released semantics: ${source.head}`);
|
|
224
|
+
expectedEndpoints.set(source.head, expected);
|
|
225
|
+
migrated.push({
|
|
226
|
+
source,
|
|
227
|
+
fact: prepared.commit,
|
|
228
|
+
digest: prepared.commitDigest,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
const finalHead = await staged.readHead();
|
|
232
|
+
if (finalHead.sequence !== input.commits.length ||
|
|
233
|
+
finalHead.head !== (input.commits.at(-1)?.head ?? "genesis"))
|
|
234
|
+
throw new Error("Migrated Authority head does not match released history");
|
|
235
|
+
await staged.readHistory();
|
|
236
|
+
const checkpointFact = input.checkpoint.sequence === 0
|
|
237
|
+
? null
|
|
238
|
+
: migrated[input.checkpoint.sequence - 1];
|
|
239
|
+
if ((input.checkpoint.sequence === 0 &&
|
|
240
|
+
input.checkpoint.head !== "genesis") ||
|
|
241
|
+
(input.checkpoint.sequence > 0 &&
|
|
242
|
+
checkpointFact?.fact.head !== input.checkpoint.head))
|
|
243
|
+
throw new Error("Released materialized checkpoint is not in migrated Authority");
|
|
244
|
+
const checkpointEndpoint = await staged.recover(input.checkpoint.head);
|
|
245
|
+
await rm(targetAuthority, { recursive: true, force: true });
|
|
246
|
+
await rename(join(stagedRuntime, authorityV3Directory), targetAuthority);
|
|
247
|
+
await syncDirectory(runtimeRoot);
|
|
248
|
+
await publishJson(join(runtimeRoot, "play-genesis-timeline.json"), {
|
|
249
|
+
schemaVersion: 1,
|
|
250
|
+
worldId: input.worldId,
|
|
251
|
+
history: normalized.genesisHistory,
|
|
252
|
+
});
|
|
253
|
+
// V2 filenames hash world-scoped message IDs. Rebuild this derived surface
|
|
254
|
+
// from the normalized checkpoint before publishing the V3 checkpoint/head.
|
|
255
|
+
await replaceMaterializedHistory(join(input.worldRoot, "history"), projectFileNativeHistorySurface(checkpointEndpoint.history, (messageId) => {
|
|
256
|
+
throw new Error(`Migrated history-message identity cannot form a materialization path: ${messageId}`);
|
|
257
|
+
}));
|
|
258
|
+
await publishJson(join(runtimeRoot, "additional-materials.json"), {
|
|
259
|
+
head: input.checkpoint.head,
|
|
260
|
+
items: checkpointEndpoint.additionalMaterials,
|
|
261
|
+
});
|
|
262
|
+
await publishJson(join(runtimeRoot, "materialized-head.json"), {
|
|
263
|
+
schemaVersion: 3,
|
|
264
|
+
head: input.checkpoint.head,
|
|
265
|
+
sequence: input.checkpoint.sequence,
|
|
266
|
+
commitDigest: checkpointFact?.digest ?? null,
|
|
267
|
+
});
|
|
268
|
+
await mkdir(input.operationsRoot, { recursive: true, mode: 0o700 });
|
|
269
|
+
for (const { source, fact, digest } of migrated) {
|
|
270
|
+
await publishJson(operationOutcomePath(input.operationsRoot, source.operationId), {
|
|
271
|
+
outcome: input.checkpoint.sequence >= fact.sequence
|
|
272
|
+
? "committed"
|
|
273
|
+
: "committed_materialization_pending",
|
|
274
|
+
worldId: input.worldId,
|
|
275
|
+
parentHead: fact.auditParent.head,
|
|
276
|
+
head: fact.head,
|
|
277
|
+
commitDigest: digest,
|
|
278
|
+
historyAppend: fact.historyAppend.map(({ role, exactText }) => ({
|
|
279
|
+
role,
|
|
280
|
+
exactText,
|
|
281
|
+
})),
|
|
282
|
+
nextAdditionalMaterials: fact.nextAdditionalMaterials,
|
|
283
|
+
mode: fact.mode,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
await rm(stagingWorld, { recursive: true, force: true });
|
|
287
|
+
crashAtStorageMigrationEdge("before_authority_v3_head");
|
|
288
|
+
await publishJson(join(runtimeRoot, continuityHeadFile), finalHead);
|
|
289
|
+
crashAtStorageMigrationEdge("after_authority_v3_head");
|
|
106
290
|
return {
|
|
107
291
|
outcome: "migrated",
|
|
108
|
-
authorityCommits:
|
|
109
|
-
callChainContexts,
|
|
292
|
+
authorityCommits: migrated.length,
|
|
293
|
+
callChainContexts: input.callChainContexts,
|
|
110
294
|
};
|
|
111
295
|
}
|
|
296
|
+
finally {
|
|
297
|
+
await rm(stagingWorld, { recursive: true, force: true }).catch(() => undefined);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function applyReleasedTransition(basis, source, historyAppend, nextMaterials) {
|
|
301
|
+
const state = new Map(basis.state.map(({ path, contents }) => [path, contents]));
|
|
302
|
+
for (const change of source.stateChanges) {
|
|
303
|
+
const previous = state.get(change.relativePath);
|
|
304
|
+
if ((previous === undefined ? null : `sha256:${sha256Hex(previous)}`) !==
|
|
305
|
+
change.expectedPreviousHash ||
|
|
306
|
+
`sha256:${sha256Hex(change.canonicalNextBytes)}` !== change.nextHash)
|
|
307
|
+
throw new Error(`Released state transition hash conflicts: ${change.relativePath}`);
|
|
308
|
+
state.set(change.relativePath, change.canonicalNextBytes);
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
state: [...state].map(([path, contents]) => ({ path, contents })),
|
|
312
|
+
history: [
|
|
313
|
+
...structuredClone(basis.history),
|
|
314
|
+
...structuredClone(historyAppend),
|
|
315
|
+
],
|
|
316
|
+
additionalMaterials: structuredClone([...nextMaterials]),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function assertCurrentTimelineAuthorityRefs(contexts, commits) {
|
|
320
|
+
// Released clone-and-remap forks retained their inherited page/model trace
|
|
321
|
+
// prefix while absorbing the selected source history into the target
|
|
322
|
+
// genesis. Older contexts can therefore carry source-world commit aliases
|
|
323
|
+
// that never belonged to the target Authority. They are immutable display
|
|
324
|
+
// history; only the current context is a continuation boundary owned by the
|
|
325
|
+
// target world and must resolve against its Authority.
|
|
326
|
+
const context = contexts.at(-1);
|
|
327
|
+
if (context === undefined)
|
|
328
|
+
return;
|
|
329
|
+
const heads = new Set(["genesis", ...commits.map(({ head }) => head)]);
|
|
330
|
+
if (!heads.has(context.baselineHead) || !heads.has(context.parentHead))
|
|
331
|
+
throw new Error("Released play timeline references an unknown Authority endpoint");
|
|
332
|
+
for (const event of context.events) {
|
|
333
|
+
if ("committedHead" in event &&
|
|
334
|
+
event.committedHead !== undefined &&
|
|
335
|
+
!heads.has(event.committedHead))
|
|
336
|
+
throw new Error("Released play event references an unknown Authority endpoint");
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async function readCurrentAuthority(worldRoot, head) {
|
|
340
|
+
const reverse = [];
|
|
341
|
+
let digest = head.commitDigest;
|
|
342
|
+
let expectedHead = head.head;
|
|
343
|
+
let expectedSequence = head.sequence;
|
|
344
|
+
while (digest !== null) {
|
|
345
|
+
const value = await readJson(join(worldRoot, "runtime", "play-commits", `${digest}.json`));
|
|
346
|
+
assertCurrentPlayCommit(value);
|
|
347
|
+
if (currentCommitDigest(value) !== digest ||
|
|
348
|
+
value.head !== expectedHead ||
|
|
349
|
+
value.sequence !== expectedSequence)
|
|
350
|
+
throw new Error("Current Authority endpoint does not match its commit");
|
|
351
|
+
reverse.push(value);
|
|
352
|
+
digest = value.parentCommitDigest;
|
|
353
|
+
expectedHead = value.parentHead;
|
|
354
|
+
expectedSequence -= 1;
|
|
355
|
+
}
|
|
356
|
+
if (expectedHead !== "genesis" || expectedSequence !== 0)
|
|
357
|
+
throw new Error("Current Authority chain does not terminate at genesis");
|
|
358
|
+
return reverse.reverse();
|
|
359
|
+
}
|
|
360
|
+
async function readCurrentGenesis(worldRoot, worldId) {
|
|
361
|
+
const value = await readJson(join(worldRoot, "runtime", "genesis.json"));
|
|
362
|
+
if (!isRecord(value) ||
|
|
363
|
+
value.schemaVersion !== 1 ||
|
|
364
|
+
value.type !== "file_native_genesis" ||
|
|
365
|
+
value.worldId !== worldId ||
|
|
366
|
+
(typeof value.operationId !== "string" && value.operationId !== null) ||
|
|
367
|
+
!Array.isArray(value.state) ||
|
|
368
|
+
!Array.isArray(value.history) ||
|
|
369
|
+
!value.history.every(isReleasedHistoryMessage) ||
|
|
370
|
+
!Array.isArray(value.additionalMaterials) ||
|
|
371
|
+
!value.additionalMaterials.every(isMaterialSelection))
|
|
372
|
+
throw new Error("Current world genesis has an invalid shape");
|
|
373
|
+
const state = value.state.map((file) => {
|
|
374
|
+
if (!isRecord(file) ||
|
|
375
|
+
typeof file.path !== "string" ||
|
|
376
|
+
!validRelativePath(file.path) ||
|
|
377
|
+
typeof file.sha256 !== "string" ||
|
|
378
|
+
typeof file.canonicalBytes !== "string" ||
|
|
379
|
+
`sha256:${sha256Hex(file.canonicalBytes)}` !== file.sha256)
|
|
380
|
+
throw new Error("Current world genesis state is corrupt");
|
|
381
|
+
return { path: file.path, contents: file.canonicalBytes };
|
|
382
|
+
});
|
|
383
|
+
return {
|
|
384
|
+
operationId: value.operationId,
|
|
385
|
+
state,
|
|
386
|
+
history: structuredClone(value.history),
|
|
387
|
+
additionalMaterials: structuredClone(value.additionalMaterials),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function normalizeAuthorityIdentity(genesis, commits) {
|
|
391
|
+
const messageIds = new Map();
|
|
392
|
+
const bind = (source, target) => {
|
|
393
|
+
if (messageIds.has(source) && messageIds.get(source) !== target)
|
|
394
|
+
throw new Error("Released Authority reuses one message identity");
|
|
395
|
+
messageIds.set(source, target);
|
|
396
|
+
messageIds.set(target, target);
|
|
397
|
+
};
|
|
398
|
+
for (const [index, message] of genesis.entries())
|
|
399
|
+
bind(message.messageId, index === 0 && message.role === "narrator"
|
|
400
|
+
? "message.genesis.narrator"
|
|
401
|
+
: `message.genesis.${index + 1}.${message.role}`);
|
|
402
|
+
for (const commit of commits)
|
|
403
|
+
for (const [index, message] of commit.historyAppend.entries())
|
|
404
|
+
bind(message.messageId, `message.${commit.sequence}.${index + 1}.${message.role}`);
|
|
405
|
+
return {
|
|
406
|
+
genesisHistory: genesis.map((message) => normalizeHistoryMessage(message, messageIds)),
|
|
407
|
+
messageIds,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
function normalizeHistoryMessage(message, messageIds) {
|
|
411
|
+
const messageId = messageIds.get(message.messageId);
|
|
412
|
+
if (messageId === undefined)
|
|
413
|
+
throw new Error(`Released Authority references an unknown history message: ${message.messageId}`);
|
|
414
|
+
return { ...structuredClone(message), messageId };
|
|
415
|
+
}
|
|
416
|
+
function normalizeMaterials(materials, messageIds, worldId) {
|
|
417
|
+
return materials.map((material) => {
|
|
418
|
+
if (material.kind === "history_message") {
|
|
419
|
+
const message = messageIds.get(material.message);
|
|
420
|
+
if (message === undefined)
|
|
421
|
+
throw new Error(`Released material references an unknown history message: ${material.message}`);
|
|
422
|
+
return { ...material, message };
|
|
423
|
+
}
|
|
424
|
+
if (material.kind === "history_commit") {
|
|
425
|
+
const prefix = `${worldId}.`;
|
|
426
|
+
const commit = material.commit.startsWith(prefix)
|
|
427
|
+
? material.commit.slice(prefix.length)
|
|
428
|
+
: material.commit;
|
|
429
|
+
if (!/^(?:genesis|commit:[1-9][0-9]*)$/u.test(commit))
|
|
430
|
+
throw new Error(`Released material references a foreign Authority endpoint: ${material.commit}`);
|
|
431
|
+
return {
|
|
432
|
+
...material,
|
|
433
|
+
commit,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
return structuredClone(material);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
function canonicalState(files) {
|
|
440
|
+
return files
|
|
441
|
+
.map(({ path, contents }) => ({
|
|
442
|
+
path,
|
|
443
|
+
sha256: `sha256:${sha256Hex(contents)}`,
|
|
444
|
+
canonicalBytes: contents,
|
|
445
|
+
}))
|
|
446
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
447
|
+
}
|
|
448
|
+
function canonicalLegacyState(files) {
|
|
449
|
+
return [...structuredClone(files)].sort((left, right) => left.path.localeCompare(right.path));
|
|
112
450
|
}
|
|
113
451
|
async function readReleasedAuthority(worldRoot) {
|
|
114
452
|
const runtimeRoot = join(worldRoot, "runtime");
|
|
@@ -193,6 +531,33 @@ async function migrateMaterializedCheckpoint(runtimeRoot, commits) {
|
|
|
193
531
|
sequence: 0,
|
|
194
532
|
commitDigest: null,
|
|
195
533
|
};
|
|
534
|
+
if (isRecord(value) &&
|
|
535
|
+
hasExactKeys(value, [
|
|
536
|
+
"schemaVersion",
|
|
537
|
+
"head",
|
|
538
|
+
"sequence",
|
|
539
|
+
"commitDigest",
|
|
540
|
+
]) &&
|
|
541
|
+
value.schemaVersion === 3 &&
|
|
542
|
+
typeof value.head === "string" &&
|
|
543
|
+
Number.isSafeInteger(value.sequence) &&
|
|
544
|
+
Number(value.sequence) >= 0 &&
|
|
545
|
+
(value.commitDigest === null ||
|
|
546
|
+
(typeof value.commitDigest === "string" &&
|
|
547
|
+
/^[a-f0-9]{64}$/u.test(value.commitDigest)))) {
|
|
548
|
+
const sequence = Number(value.sequence);
|
|
549
|
+
const expected = sequence === 0 ? undefined : commits[sequence - 1];
|
|
550
|
+
if ((sequence === 0 &&
|
|
551
|
+
(value.head !== "genesis" || value.commitDigest !== null)) ||
|
|
552
|
+
(sequence > 0 && expected?.head !== value.head))
|
|
553
|
+
throw new Error("Unmarked V3 materialized checkpoint is not in released Authority");
|
|
554
|
+
return {
|
|
555
|
+
schemaVersion: 2,
|
|
556
|
+
head: value.head,
|
|
557
|
+
sequence,
|
|
558
|
+
commitDigest: expected === undefined ? null : currentCommitDigest(expected),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
196
561
|
if (isCurrentMaterializedCheckpoint(value)) {
|
|
197
562
|
const expected = value.sequence === 0 ? undefined : commits[value.sequence - 1];
|
|
198
563
|
if ((value.sequence === 0 &&
|
|
@@ -458,6 +823,58 @@ function assertCurrentAuthorityHead(value) {
|
|
|
458
823
|
value.operationId === null))
|
|
459
824
|
throw new Error("Current Authority head has an invalid shape");
|
|
460
825
|
}
|
|
826
|
+
function assertCurrentPlayCommit(value) {
|
|
827
|
+
if (!isRecord(value))
|
|
828
|
+
throw new Error("Current play commit has an invalid shape");
|
|
829
|
+
const correction = value.mode === "correction";
|
|
830
|
+
const timelineRevision = value.mode === "timeline_revision";
|
|
831
|
+
if (!hasExactKeys(value, [
|
|
832
|
+
"schemaVersion",
|
|
833
|
+
"sequence",
|
|
834
|
+
"operationId",
|
|
835
|
+
"parentHead",
|
|
836
|
+
"parentCommitDigest",
|
|
837
|
+
"head",
|
|
838
|
+
"mode",
|
|
839
|
+
"historyAppend",
|
|
840
|
+
"stateChanges",
|
|
841
|
+
"nextAdditionalMaterials",
|
|
842
|
+
...(correction ? ["correctionTargets", "corrects"] : []),
|
|
843
|
+
...(timelineRevision ? ["timelineRevision"] : []),
|
|
844
|
+
]) ||
|
|
845
|
+
value.schemaVersion !== 2 ||
|
|
846
|
+
!Number.isSafeInteger(value.sequence) ||
|
|
847
|
+
Number(value.sequence) < 1 ||
|
|
848
|
+
typeof value.operationId !== "string" ||
|
|
849
|
+
value.operationId.trim() === "" ||
|
|
850
|
+
typeof value.parentHead !== "string" ||
|
|
851
|
+
!/^(?:genesis|commit:[1-9][0-9]*)$/u.test(value.parentHead) ||
|
|
852
|
+
(value.parentCommitDigest !== null &&
|
|
853
|
+
(typeof value.parentCommitDigest !== "string" ||
|
|
854
|
+
!/^[a-f0-9]{64}$/u.test(value.parentCommitDigest))) ||
|
|
855
|
+
typeof value.head !== "string" ||
|
|
856
|
+
value.head !== `commit:${String(value.sequence)}` ||
|
|
857
|
+
(value.mode !== "play" &&
|
|
858
|
+
value.mode !== "correction" &&
|
|
859
|
+
value.mode !== "timeline_revision") ||
|
|
860
|
+
!Array.isArray(value.historyAppend) ||
|
|
861
|
+
!value.historyAppend.every(isReleasedHistoryMessage) ||
|
|
862
|
+
!Array.isArray(value.stateChanges) ||
|
|
863
|
+
!value.stateChanges.every(isReleasedStateChange) ||
|
|
864
|
+
!Array.isArray(value.nextAdditionalMaterials) ||
|
|
865
|
+
!value.nextAdditionalMaterials.every(isMaterialSelection))
|
|
866
|
+
throw new Error("Current play commit has an invalid shape");
|
|
867
|
+
if (correction &&
|
|
868
|
+
(!Array.isArray(value.correctionTargets) ||
|
|
869
|
+
!value.correctionTargets.every((target) => typeof target === "string" && target.trim() !== "") ||
|
|
870
|
+
typeof value.corrects !== "string" ||
|
|
871
|
+
value.corrects !== value.parentHead))
|
|
872
|
+
throw new Error("Current correction commit has an invalid shape");
|
|
873
|
+
if (timelineRevision &&
|
|
874
|
+
(value.stateChanges.length !== 0 ||
|
|
875
|
+
!isReleasedTimelineRevision(value.timelineRevision)))
|
|
876
|
+
throw new Error("Current timeline revision has an invalid shape");
|
|
877
|
+
}
|
|
461
878
|
function isCurrentMaterializedCheckpoint(value) {
|
|
462
879
|
return (isRecord(value) &&
|
|
463
880
|
hasExactKeys(value, [
|
|
@@ -557,6 +974,37 @@ async function readDirectoryFileNames(root) {
|
|
|
557
974
|
throw error;
|
|
558
975
|
}
|
|
559
976
|
}
|
|
977
|
+
async function replaceMaterializedHistory(root, files) {
|
|
978
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
979
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
980
|
+
if (entries.some((entry) => !entry.isFile()))
|
|
981
|
+
throw new Error("Materialized history projection must be a flat file tree");
|
|
982
|
+
const current = new Map(await Promise.all(entries.map(async (entry) => [entry.name, await readFile(join(root, entry.name), "utf8")])));
|
|
983
|
+
const expected = new Set();
|
|
984
|
+
for (const file of files) {
|
|
985
|
+
if (!validRelativePath(file.path) || file.path.includes("/"))
|
|
986
|
+
throw new Error(`Migrated history projection has an invalid path: ${file.path}`);
|
|
987
|
+
if (expected.has(file.path))
|
|
988
|
+
throw new Error(`Migrated history projection has a duplicate path: ${file.path}`);
|
|
989
|
+
expected.add(file.path);
|
|
990
|
+
if (current.get(file.path) === file.contents)
|
|
991
|
+
continue;
|
|
992
|
+
const temporary = join(root, `${file.path}.${randomUUID()}.tmp`);
|
|
993
|
+
await writeFile(temporary, file.contents, {
|
|
994
|
+
encoding: "utf8",
|
|
995
|
+
mode: 0o600,
|
|
996
|
+
});
|
|
997
|
+
await syncFile(temporary);
|
|
998
|
+
await rename(temporary, join(root, file.path));
|
|
999
|
+
await syncDirectory(root);
|
|
1000
|
+
}
|
|
1001
|
+
for (const entry of entries) {
|
|
1002
|
+
if (expected.has(entry.name))
|
|
1003
|
+
continue;
|
|
1004
|
+
await rm(join(root, entry.name), { force: true });
|
|
1005
|
+
await syncDirectory(root);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
560
1008
|
async function publishJson(path, value) {
|
|
561
1009
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
562
1010
|
const temporary = `${path}.${randomUUID()}.tmp`;
|