narraeon 0.7.2 → 0.7.5
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 +8 -2
- package/dist/node/runtime/V1Runtime.js +12 -3
- package/dist/node/runtime/archive/WorldArchives.js +17 -5
- package/dist/node/runtime/content/ContentWorkspace.js +2 -2
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +98 -165
- package/dist/node/runtime/content/FileNativeContentTree.js +16 -13
- package/dist/node/runtime/model/FileNativeModelAdapters.js +106 -18
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +104 -21
- package/dist/node/runtime/play/PlayAdvanceLifecycle.js +211 -0
- package/dist/node/runtime/play/PlayCallChain.js +200 -374
- package/dist/node/runtime/setting/SettingAuthoringTransaction.js +312 -167
- package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +5 -0
- package/dist/node/runtime/world/FileNativeWorldStore.js +151 -108
- package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -1
- package/dist/node/runtime/world/WorldDocumentStore.js +1 -1
- package/dist/node/runtime/world/WorldLedgerAuthority.js +37 -3
- package/dist/node/runtime/world/WorldTurnLedger.js +17 -4
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -4
- package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +19 -241
- package/dist/node/runtime/world-revision/WorldRevisionWorkspace.js +8 -27
- package/dist/node/server/createServer.js +44 -20
- package/dist/web/assets/index-9N9kO8kY.js +1408 -0
- package/dist/web/assets/index-BjxBYYQi.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BUCslLEK.css +0 -1
- package/dist/web/assets/index-DkamBH6c.js +0 -1408
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FileNativePlayAdvanceStore } from "../play/FileNativePlayAdvanceStore.js";
|
|
1
2
|
import { FileNativePlayTimelineStore } from "../play/FileNativePlayTimelineStore.js";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
@@ -21,9 +22,11 @@ export class UnsupportedLegacyStorageError extends Error {
|
|
|
21
22
|
export class FileNativeWorldStorageMigrator {
|
|
22
23
|
#worldsRoot;
|
|
23
24
|
#playTimeline;
|
|
25
|
+
#playAdvances;
|
|
24
26
|
constructor(dataRoot) {
|
|
25
27
|
this.#worldsRoot = join(resolve(dataRoot), "worlds-file-native");
|
|
26
28
|
this.#playTimeline = new FileNativePlayTimelineStore(dataRoot);
|
|
29
|
+
this.#playAdvances = new FileNativePlayAdvanceStore(dataRoot);
|
|
27
30
|
}
|
|
28
31
|
async migrate(worldId) {
|
|
29
32
|
assertIdentity(worldId, "World ID");
|
|
@@ -39,6 +42,7 @@ export class FileNativeWorldStorageMigrator {
|
|
|
39
42
|
])
|
|
40
43
|
await rm(residue, { recursive: true, force: true });
|
|
41
44
|
await this.#playTimeline.migrateLayout(worldId);
|
|
45
|
+
await this.#playAdvances.migrateLayout(worldId);
|
|
42
46
|
const head = await ledger.readHead();
|
|
43
47
|
return { outcome: "already_current", authorityCommits: head.sequence };
|
|
44
48
|
}
|
|
@@ -46,6 +50,7 @@ export class FileNativeWorldStorageMigrator {
|
|
|
46
50
|
if (await authority.exists()) {
|
|
47
51
|
const { commits } = await convertAuthorityV3ToLedger(worldRoot);
|
|
48
52
|
await this.#playTimeline.migrateLayout(worldId);
|
|
53
|
+
await this.#playAdvances.migrateLayout(worldId);
|
|
49
54
|
return { outcome: "migrated", authorityCommits: commits };
|
|
50
55
|
}
|
|
51
56
|
throw new UnsupportedLegacyStorageError(worldId);
|
|
@@ -19,8 +19,8 @@ import { readWorldActivity, updateWorldActivity, } from "./WorldActivity.js";
|
|
|
19
19
|
import { FileNativePlayTimelineStore } from "../play/FileNativePlayTimelineStore.js";
|
|
20
20
|
import { FileNativePlayAdvanceStore } from "../play/FileNativePlayAdvanceStore.js";
|
|
21
21
|
import { FileNativeWorldStorageMigrator, UnsupportedLegacyStorageError, } from "./FileNativeWorldStorageMigrator.js";
|
|
22
|
-
import { WorldAuthorityError, WorldLedgerAuthority, } from "./WorldLedgerAuthority.js";
|
|
23
|
-
import { forgetLedger, rewindTo, WorldTurnLedgerError, } from "./WorldTurnLedger.js";
|
|
22
|
+
import { isCorrectionCommit, WorldAuthorityError, WorldLedgerAuthority, } from "./WorldLedgerAuthority.js";
|
|
23
|
+
import { forgetLedger, recoverIncomplete, rewindTo, WorldTurnLedgerError, } from "./WorldTurnLedger.js";
|
|
24
24
|
import { FileNativeWorldOperationCoordinator, WorldOperationBusyError, } from "./WorldOperationCoordinator.js";
|
|
25
25
|
import { projectFileNativeHistorySurface, projectHistoryBackgrounds, historyBackgroundRecord, } from "./FileNativeHistoryProjection.js";
|
|
26
26
|
const publicationFile = "publication.json";
|
|
@@ -76,9 +76,11 @@ export class FileNativeWorldStore {
|
|
|
76
76
|
this.#storageMigrator = new FileNativeWorldStorageMigrator(root);
|
|
77
77
|
}
|
|
78
78
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
79
|
+
* Open a world for this process: upgrade an Authority V3 world to the current
|
|
80
|
+
* ledger layout, then recover any turn that did not finish before the process
|
|
81
|
+
* stopped (ADR-0045). Runs once per world in the world's queue slot, so no
|
|
82
|
+
* in-process advance can interleave with recovery. Concurrent callers share one
|
|
83
|
+
* in-process promise.
|
|
82
84
|
*/
|
|
83
85
|
async ensureCurrentStorage(worldId) {
|
|
84
86
|
assertIdentity(worldId, "World ID");
|
|
@@ -104,18 +106,24 @@ export class FileNativeWorldStore {
|
|
|
104
106
|
async #ensureCurrentStorageSerialized(worldId) {
|
|
105
107
|
try {
|
|
106
108
|
const root = join(this.#worldsRoot, worldId);
|
|
107
|
-
const authority = new WorldLedgerAuthority(root);
|
|
108
|
-
if ((await authority.exists()) &&
|
|
109
|
-
(await this.playTimeline.layoutCurrent(worldId))) {
|
|
110
|
-
await authority.readHead();
|
|
111
|
-
this.#currentStorage.add(worldId);
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
109
|
await this.operations.withWorldAuthorityLock(worldId, async () => {
|
|
115
|
-
|
|
116
|
-
|
|
110
|
+
// A caller queued behind the first open finds the world already open.
|
|
111
|
+
if (this.#currentStorage.has(worldId))
|
|
112
|
+
return;
|
|
113
|
+
const authority = new WorldLedgerAuthority(root);
|
|
114
|
+
if ((await authority.exists()) &&
|
|
115
|
+
(await this.playTimeline.layoutCurrent(worldId)) &&
|
|
116
|
+
(await this.playAdvances.layoutCurrent(worldId))) {
|
|
117
|
+
await authority.readHead();
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
await this.#storageMigrator.migrate(worldId);
|
|
121
|
+
await migrateLegacyArtifactStore(dirname(this.#worldsRoot), worldId);
|
|
122
|
+
}
|
|
123
|
+
// Recovery follows migration; a world with no unfinished turn writes nothing.
|
|
124
|
+
await recoverIncomplete(root);
|
|
125
|
+
this.#currentStorage.add(worldId);
|
|
117
126
|
});
|
|
118
|
-
this.#currentStorage.add(worldId);
|
|
119
127
|
}
|
|
120
128
|
catch (error) {
|
|
121
129
|
if (error instanceof FileNativeWorldCreationError)
|
|
@@ -424,9 +432,9 @@ export class FileNativeWorldStore {
|
|
|
424
432
|
* must never pair a mutable state tree with a different response head.
|
|
425
433
|
*/
|
|
426
434
|
async renderPlayerViewsAtHead(worldId, head) {
|
|
427
|
-
const [state, control] = await Promise.all([
|
|
428
|
-
this.recoverEndpoint(worldId, head)
|
|
429
|
-
this
|
|
435
|
+
const [{ state }, control] = await Promise.all([
|
|
436
|
+
this.recoverEndpoint(worldId, head),
|
|
437
|
+
this.#recoverEndpointControl(worldId, head),
|
|
430
438
|
]);
|
|
431
439
|
const source = control.find(({ path }) => path === "player-views.yaml");
|
|
432
440
|
return new PlayerViewRenderer().render({
|
|
@@ -532,54 +540,6 @@ export class FileNativeWorldStore {
|
|
|
532
540
|
async bindWorldRevision(handle) {
|
|
533
541
|
return this.operations.withWorldRevisionLock(handle, () => this.bindPlayCallChain(handle.worldId));
|
|
534
542
|
}
|
|
535
|
-
/** Replace control while the matching revision epoch retains exclusive ownership. */
|
|
536
|
-
async replaceWorldRevisionControl(input) {
|
|
537
|
-
for (const file of input.files)
|
|
538
|
-
assertRelativePath(file.path);
|
|
539
|
-
return this.operations.withWorldRevisionLock(input.handle, async () => {
|
|
540
|
-
if ((this.#activeControlUsers.get(input.handle.worldId)?.size ?? 0) > 0)
|
|
541
|
-
throw new FileNativeWorldCreationError("operation_conflict", "World control is frozen by another operation");
|
|
542
|
-
const root = join(this.#worldsRoot, input.handle.worldId);
|
|
543
|
-
const currentPath = join(root, "control");
|
|
544
|
-
const runtimeRoot = join(root, "runtime");
|
|
545
|
-
const stagingRoot = join(runtimeRoot, `.revision-control-${input.handle.epochId}-next`);
|
|
546
|
-
const stagedPath = join(stagingRoot, "control");
|
|
547
|
-
const previousPath = join(runtimeRoot, `.revision-control-${input.handle.epochId}-previous`);
|
|
548
|
-
let current = await readTree(currentPath);
|
|
549
|
-
let currentFingerprint = fingerprint(current);
|
|
550
|
-
const nextFingerprint = fingerprint(input.files);
|
|
551
|
-
if (currentFingerprint === nextFingerprint) {
|
|
552
|
-
await rm(stagingRoot, { recursive: true, force: true });
|
|
553
|
-
await rm(previousPath, { recursive: true, force: true });
|
|
554
|
-
return { fingerprint: nextFingerprint };
|
|
555
|
-
}
|
|
556
|
-
const previous = await readTree(previousPath);
|
|
557
|
-
const staged = await readTree(stagedPath);
|
|
558
|
-
if (current.length === 0 &&
|
|
559
|
-
fingerprint(previous) === input.expectedFingerprint) {
|
|
560
|
-
if (fingerprint(staged) === nextFingerprint) {
|
|
561
|
-
await rename(stagedPath, currentPath);
|
|
562
|
-
await rm(previousPath, { recursive: true, force: true });
|
|
563
|
-
await rm(stagingRoot, { recursive: true, force: true });
|
|
564
|
-
return { fingerprint: nextFingerprint };
|
|
565
|
-
}
|
|
566
|
-
await rename(previousPath, currentPath);
|
|
567
|
-
current = await readTree(currentPath);
|
|
568
|
-
currentFingerprint = fingerprint(current);
|
|
569
|
-
}
|
|
570
|
-
if (currentFingerprint !== input.expectedFingerprint)
|
|
571
|
-
throw new FileNativeWorldCreationError("operation_conflict", "World control changed after this revision epoch opened");
|
|
572
|
-
assertControlGameplayUnchanged(current, input.files);
|
|
573
|
-
await rm(stagingRoot, { recursive: true, force: true });
|
|
574
|
-
await rm(previousPath, { recursive: true, force: true });
|
|
575
|
-
await writeSurface(stagingRoot, "control", input.files);
|
|
576
|
-
await rename(currentPath, previousPath);
|
|
577
|
-
await rename(stagedPath, currentPath);
|
|
578
|
-
await rm(previousPath, { recursive: true, force: true });
|
|
579
|
-
await rm(stagingRoot, { recursive: true, force: true });
|
|
580
|
-
return { fingerprint: nextFingerprint };
|
|
581
|
-
});
|
|
582
|
-
}
|
|
583
543
|
async previewControlDraft(worldId, prompt) {
|
|
584
544
|
const { state, draft } = await this.#readControlDraft(worldId);
|
|
585
545
|
const rendered = new PlayerViewRenderer().render({
|
|
@@ -620,20 +580,25 @@ export class FileNativeWorldStore {
|
|
|
620
580
|
throw new FileNativeWorldCreationError("operation_conflict", "World control is frozen by a running attempt");
|
|
621
581
|
const preview = await this.previewControlDraft(worldId, prompt);
|
|
622
582
|
const root = join(this.#worldsRoot, worldId);
|
|
623
|
-
|
|
624
|
-
const
|
|
625
|
-
const
|
|
626
|
-
await
|
|
627
|
-
|
|
628
|
-
await
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
583
|
+
const draft = await readTree(join(root, "runtime/control-draft"));
|
|
584
|
+
const authority = new WorldLedgerAuthority(root);
|
|
585
|
+
const endpoint = await authority.recover();
|
|
586
|
+
assertControlGameplayUnchanged(await authority.recoverControl(endpoint.head), draft);
|
|
587
|
+
// 控制编辑应用是一轮 `control_apply`:只含 control 改动,与其他轮一样可回退。
|
|
588
|
+
await this.#commitHistoryChange({
|
|
589
|
+
operationId: `control-apply-${randomUUID()}`,
|
|
590
|
+
worldId,
|
|
591
|
+
parentHead: endpoint.head,
|
|
592
|
+
nextMaterials: worldNeutralMaterials(worldId, endpoint.additionalMaterials),
|
|
593
|
+
stateChanges: [],
|
|
594
|
+
mode: "control_apply",
|
|
595
|
+
historyAppend: [],
|
|
596
|
+
control: draft,
|
|
597
|
+
});
|
|
598
|
+
await rm(join(root, "runtime/control-draft"), {
|
|
599
|
+
recursive: true,
|
|
600
|
+
force: true,
|
|
601
|
+
});
|
|
637
602
|
return {
|
|
638
603
|
controlFingerprint: preview.diagnosticBinding.controlFingerprint,
|
|
639
604
|
preview,
|
|
@@ -701,8 +666,9 @@ export class FileNativeWorldStore {
|
|
|
701
666
|
}
|
|
702
667
|
try {
|
|
703
668
|
return await this.operations.withWorldAuthorityLock(worldId, async () => {
|
|
704
|
-
const
|
|
705
|
-
const
|
|
669
|
+
const authority = new WorldLedgerAuthority(root);
|
|
670
|
+
const recovered = await authority.recover();
|
|
671
|
+
const control = await authority.recoverControl(recovered.head);
|
|
706
672
|
return {
|
|
707
673
|
worldId,
|
|
708
674
|
parentHead: recovered.head,
|
|
@@ -727,7 +693,7 @@ export class FileNativeWorldStore {
|
|
|
727
693
|
}
|
|
728
694
|
}
|
|
729
695
|
/**
|
|
730
|
-
* Bind an immutable historical endpoint with
|
|
696
|
+
* Bind an immutable historical endpoint with that endpoint's own control
|
|
731
697
|
* surface. This is the same semantic input as a fresh context started while
|
|
732
698
|
* that endpoint was current, without rewinding today's head.
|
|
733
699
|
*/
|
|
@@ -735,7 +701,7 @@ export class FileNativeWorldStore {
|
|
|
735
701
|
assertIdentity(worldId, "World ID");
|
|
736
702
|
const [endpoint, control] = await Promise.all([
|
|
737
703
|
this.recoverEndpoint(worldId, head),
|
|
738
|
-
this
|
|
704
|
+
this.#recoverEndpointControl(worldId, head),
|
|
739
705
|
]);
|
|
740
706
|
return {
|
|
741
707
|
worldId,
|
|
@@ -794,12 +760,16 @@ export class FileNativeWorldStore {
|
|
|
794
760
|
};
|
|
795
761
|
}
|
|
796
762
|
catch (error) {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
763
|
+
throw endpointReadError(error, head);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/** 端点的控制文件;与 recoverEndpoint 的状态取同一个端点,回退与分叉随端点恢复。 */
|
|
767
|
+
async #recoverEndpointControl(worldId, head) {
|
|
768
|
+
try {
|
|
769
|
+
return await new WorldLedgerAuthority(join(this.#worldsRoot, worldId)).recoverControl(head);
|
|
770
|
+
}
|
|
771
|
+
catch (error) {
|
|
772
|
+
throw endpointReadError(error, head);
|
|
803
773
|
}
|
|
804
774
|
}
|
|
805
775
|
async deriveWorld(input) {
|
|
@@ -845,10 +815,7 @@ export class FileNativeWorldStore {
|
|
|
845
815
|
const sourceHead = input.sourceHead === "current"
|
|
846
816
|
? (await sourceAuthority.readHead()).head
|
|
847
817
|
: input.sourceHead;
|
|
848
|
-
const
|
|
849
|
-
readGenesisForFork(sourceRoot, input.sourceWorldId),
|
|
850
|
-
readTree(join(sourceRoot, "control")),
|
|
851
|
-
]);
|
|
818
|
+
const sourceGenesis = await readGenesisForFork(sourceRoot, input.sourceWorldId);
|
|
852
819
|
const staging = join(dirname(finalRoot), `.staging-${worldId}-${randomUUID()}`);
|
|
853
820
|
await mkdir(dirname(finalRoot), { recursive: true, mode: 0o700 });
|
|
854
821
|
try {
|
|
@@ -861,7 +828,9 @@ export class FileNativeWorldStore {
|
|
|
861
828
|
});
|
|
862
829
|
// 分叉不信任来源的投影文件:当前文件由副本上的账本端点重建。
|
|
863
830
|
await new WorldLedgerAuthority(staging).materializeCurrentFiles();
|
|
831
|
+
// 控制与状态一样取自所选端点:回退已把副本的 control/ 恢复到该端点。
|
|
864
832
|
const selected = await new WorldLedgerAuthority(staging).recover();
|
|
833
|
+
const selectedControl = await new WorldLedgerAuthority(staging).recoverControl(selected.head);
|
|
865
834
|
await PackageScriptPermissions.write(staging, input.resetPackageScriptPermissions
|
|
866
835
|
? []
|
|
867
836
|
: await PackageScriptPermissions.read(sourceRoot));
|
|
@@ -873,7 +842,7 @@ export class FileNativeWorldStore {
|
|
|
873
842
|
operationId: input.operationId,
|
|
874
843
|
parentEndpoint: "genesis",
|
|
875
844
|
state: structuredClone(sourceGenesis.state),
|
|
876
|
-
control: immutableFiles(
|
|
845
|
+
control: immutableFiles(selectedControl),
|
|
877
846
|
history: structuredClone(sourceGenesis.history),
|
|
878
847
|
additionalMaterials: structuredClone(sourceGenesis.additionalMaterials),
|
|
879
848
|
};
|
|
@@ -890,7 +859,7 @@ export class FileNativeWorldStore {
|
|
|
890
859
|
binding: {
|
|
891
860
|
worldId,
|
|
892
861
|
parentHead: sourceHead,
|
|
893
|
-
files: [...selected.state, ...
|
|
862
|
+
files: [...selected.state, ...selectedControl].reduce((files, { path, contents }, index) => {
|
|
894
863
|
files[`${index < selected.state.length ? "state" : "control"}/${path}`] = contents;
|
|
895
864
|
return files;
|
|
896
865
|
}, {}),
|
|
@@ -1020,9 +989,6 @@ export class FileNativeWorldStore {
|
|
|
1020
989
|
mode: "correction",
|
|
1021
990
|
historyAppend: [],
|
|
1022
991
|
operationReserved,
|
|
1023
|
-
...(input.validationControl === undefined
|
|
1024
|
-
? {}
|
|
1025
|
-
: { validationControl: input.validationControl }),
|
|
1026
992
|
});
|
|
1027
993
|
};
|
|
1028
994
|
if (input.stateOperationClaim !== undefined &&
|
|
@@ -1040,6 +1006,45 @@ export class FileNativeWorldStore {
|
|
|
1040
1006
|
throw error;
|
|
1041
1007
|
}
|
|
1042
1008
|
}
|
|
1009
|
+
/**
|
|
1010
|
+
* 世界修订应用:state 与 control 的改动作为同一轮 `revision_apply` 提交。
|
|
1011
|
+
* 操作身份由 epoch 决定,同一身份已是当前端点时直接返回该提交(崩溃后重放不重复推进)。
|
|
1012
|
+
*/
|
|
1013
|
+
async commitWorldRevisionApply(input) {
|
|
1014
|
+
await this.ensureCurrentStorage(input.worldId);
|
|
1015
|
+
try {
|
|
1016
|
+
return await this.operations.withWorldRevisionLock(input.worldRevisionLock, async () => {
|
|
1017
|
+
const operationReserved = (await this.getOperationOutcome(input.operationId)).outcome ===
|
|
1018
|
+
"not_started" && (await this.reserveOperation(input.operationId));
|
|
1019
|
+
try {
|
|
1020
|
+
return await this.#commitHistoryChange({
|
|
1021
|
+
...(input.worldClock === undefined
|
|
1022
|
+
? {}
|
|
1023
|
+
: { worldClock: input.worldClock }),
|
|
1024
|
+
operationId: input.operationId,
|
|
1025
|
+
worldId: input.worldId,
|
|
1026
|
+
parentHead: input.parentHead,
|
|
1027
|
+
nextMaterials: worldNeutralMaterials(input.worldId, input.nextMaterials),
|
|
1028
|
+
stateChanges: input.stateChanges,
|
|
1029
|
+
mode: "revision_apply",
|
|
1030
|
+
historyAppend: [],
|
|
1031
|
+
operationReserved,
|
|
1032
|
+
control: input.control,
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
catch (error) {
|
|
1036
|
+
if (operationReserved)
|
|
1037
|
+
await this.releaseOperationReservation(input.operationId);
|
|
1038
|
+
throw error;
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
catch (error) {
|
|
1043
|
+
if (error instanceof WorldOperationBusyError)
|
|
1044
|
+
throw new FileNativeWorldCreationError("operation_conflict", "This world already has a durable state operation in progress");
|
|
1045
|
+
throw error;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1043
1048
|
/**
|
|
1044
1049
|
* Persist one already-visible step of the model-directed play call chain.
|
|
1045
1050
|
* Player text, assistant text and world changes deliberately arrive in
|
|
@@ -1093,11 +1098,16 @@ export class FileNativeWorldStore {
|
|
|
1093
1098
|
throw new FileNativeWorldCreationError("operation_conflict", "The same play operation ID is occupied by another durable operation");
|
|
1094
1099
|
const authorityStore = new WorldLedgerAuthority(root);
|
|
1095
1100
|
const authority = await authorityStore.readHead();
|
|
1101
|
+
if (input.mode === "revision_apply" &&
|
|
1102
|
+
authority.operationId === input.operationId) {
|
|
1103
|
+
// 同一世界修订应用已经是当前端点:崩溃发生在端点发布之后、阶段记录之前。
|
|
1104
|
+
return this.#committedOutcomeAtHead(authorityStore, input, authority.head);
|
|
1105
|
+
}
|
|
1096
1106
|
if (authority.head !== input.parentHead)
|
|
1097
1107
|
throw new FileNativeWorldCreationError("operation_conflict", "The parent endpoint of the play operation has changed");
|
|
1098
|
-
const [basisState,
|
|
1108
|
+
const [basisState, basisControl] = await Promise.all([
|
|
1099
1109
|
authorityStore.recoverState(authority.head),
|
|
1100
|
-
|
|
1110
|
+
authorityStore.recoverControl(authority.head),
|
|
1101
1111
|
]);
|
|
1102
1112
|
const candidateState = new Map(basisState.map((file) => [file.path, file.contents]));
|
|
1103
1113
|
for (const change of input.stateChanges) {
|
|
@@ -1114,7 +1124,7 @@ export class FileNativeWorldStore {
|
|
|
1114
1124
|
path: `world/${path}`,
|
|
1115
1125
|
contents,
|
|
1116
1126
|
})),
|
|
1117
|
-
...(input.
|
|
1127
|
+
...(input.control ?? basisControl).map(({ path, contents }) => ({
|
|
1118
1128
|
path: `control/${path}`,
|
|
1119
1129
|
contents,
|
|
1120
1130
|
})),
|
|
@@ -1134,10 +1144,11 @@ export class FileNativeWorldStore {
|
|
|
1134
1144
|
historyAppend: input.historyAppend,
|
|
1135
1145
|
stateChanges: input.stateChanges,
|
|
1136
1146
|
nextMaterials: input.nextMaterials,
|
|
1147
|
+
...(input.control === undefined ? {} : { control: input.control }),
|
|
1137
1148
|
...(input.narrativeCheckpoint === undefined
|
|
1138
1149
|
? {}
|
|
1139
1150
|
: { narrativeCheckpoint: input.narrativeCheckpoint }),
|
|
1140
|
-
...(input.mode
|
|
1151
|
+
...(isCorrectionCommit(input.mode)
|
|
1141
1152
|
? {
|
|
1142
1153
|
correctionTargets: input.stateChanges.map(({ documentId }) => documentId),
|
|
1143
1154
|
corrects: input.parentHead,
|
|
@@ -1168,6 +1179,27 @@ export class FileNativeWorldStore {
|
|
|
1168
1179
|
throw error;
|
|
1169
1180
|
}
|
|
1170
1181
|
}
|
|
1182
|
+
/**
|
|
1183
|
+
* 操作身份已经是当前端点的提交:核对它与本次输入一致,然后按它的结果返回。
|
|
1184
|
+
* 不一致说明同一身份被用于另一份提交,拒绝而不是覆盖或重复推进。
|
|
1185
|
+
*/
|
|
1186
|
+
async #committedOutcomeAtHead(authorityStore, input, head) {
|
|
1187
|
+
const fact = await authorityStore.authorityFactAt(head);
|
|
1188
|
+
if (!authorityCommitMatchesInput(fact.commit, input))
|
|
1189
|
+
throw new FileNativeWorldCreationError("operation_conflict", "The same operation ID is bound to a different complete commit payload");
|
|
1190
|
+
const outcome = {
|
|
1191
|
+
outcome: "committed",
|
|
1192
|
+
worldId: input.worldId,
|
|
1193
|
+
parentHead: fact.commit.parentHead,
|
|
1194
|
+
head: fact.commit.head,
|
|
1195
|
+
commitDigest: fact.digest,
|
|
1196
|
+
historyAppend: structuredClone(input.historyAppend),
|
|
1197
|
+
nextAdditionalMaterials: structuredClone(input.nextMaterials),
|
|
1198
|
+
mode: input.mode,
|
|
1199
|
+
};
|
|
1200
|
+
this.#rememberOperationOutcome(input.worldId, input.operationId, outcome);
|
|
1201
|
+
return outcome;
|
|
1202
|
+
}
|
|
1171
1203
|
async readAuthorityHistory(worldId) {
|
|
1172
1204
|
assertIdentity(worldId, "World ID");
|
|
1173
1205
|
await this.ensureCurrentStorage(worldId);
|
|
@@ -1227,9 +1259,12 @@ export class FileNativeWorldStore {
|
|
|
1227
1259
|
throw new FileNativeWorldCreationError("world_corrupt", `Historical endpoint does not exist: ${endpoint}`);
|
|
1228
1260
|
return {
|
|
1229
1261
|
endpoint,
|
|
1230
|
-
corrects: commit
|
|
1262
|
+
corrects: commit !== undefined && isCorrectionCommit(commit.mode)
|
|
1263
|
+
? (commit.corrects ?? null)
|
|
1264
|
+
: null,
|
|
1231
1265
|
correctedBy: commits
|
|
1232
|
-
.filter((candidate) => candidate.mode
|
|
1266
|
+
.filter((candidate) => isCorrectionCommit(candidate.mode) &&
|
|
1267
|
+
candidate.corrects === endpoint)
|
|
1233
1268
|
.map(({ head }) => head),
|
|
1234
1269
|
};
|
|
1235
1270
|
}
|
|
@@ -1513,7 +1548,8 @@ function authorityCommitMatchesInput(commit, input) {
|
|
|
1513
1548
|
nextHash: candidate.nextHash,
|
|
1514
1549
|
});
|
|
1515
1550
|
});
|
|
1516
|
-
const
|
|
1551
|
+
const correction = isCorrectionCommit(input.mode);
|
|
1552
|
+
const expectedCorrection = correction
|
|
1517
1553
|
? input.stateChanges.map(({ documentId }) => documentId)
|
|
1518
1554
|
: undefined;
|
|
1519
1555
|
return (commit.mode === input.mode &&
|
|
@@ -1529,8 +1565,15 @@ function authorityCommitMatchesInput(commit, input) {
|
|
|
1529
1565
|
isDeepStrictEqual(commit.nextAdditionalMaterials, input.nextMaterials) &&
|
|
1530
1566
|
isDeepStrictEqual(commit.narrativeCheckpoint, input.narrativeCheckpoint) &&
|
|
1531
1567
|
isDeepStrictEqual(commit.correctionTargets, expectedCorrection) &&
|
|
1532
|
-
commit.corrects ===
|
|
1533
|
-
|
|
1568
|
+
commit.corrects === (correction ? input.parentHead : undefined));
|
|
1569
|
+
}
|
|
1570
|
+
/** 读取某个端点失败的错误映射:端点在读取途中被回退丢弃不是损坏,调用方重新取得当前端点即可。 */
|
|
1571
|
+
function endpointReadError(error, head) {
|
|
1572
|
+
if (error instanceof FileNativeWorldCreationError)
|
|
1573
|
+
return error;
|
|
1574
|
+
if (error instanceof WorldTurnLedgerError && error.code === "discarded")
|
|
1575
|
+
return new FileNativeWorldCreationError("endpoint_discarded", `Authority endpoint was rewound away${head === undefined ? "" : `: ${head}`}`, { cause: error });
|
|
1576
|
+
return new FileNativeWorldCreationError("world_corrupt", `Cannot recover immutable Authority endpoint${head === undefined ? "" : `: ${head}`}`, error instanceof Error ? { cause: error } : undefined);
|
|
1534
1577
|
}
|
|
1535
1578
|
function headPosition(head) {
|
|
1536
1579
|
if (head === "genesis")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { listWorldDocuments } from "../prompt/WorldMaintenanceReport.js";
|
|
2
2
|
import { WorldDocumentStore } from "./WorldDocumentStore.js";
|
|
3
|
+
import { isCorrectionCommit, } from "./WorldLedgerAuthority.js";
|
|
3
4
|
/** Reachable committed bytes only. Cached prefixes are disposable accelerators. */
|
|
4
5
|
export async function readDocumentMaintenanceFacts(authority, head, cache) {
|
|
5
6
|
const exact = cache.get(head);
|
|
@@ -92,7 +93,7 @@ export async function readDocumentMaintenanceFacts(authority, head, cache) {
|
|
|
92
93
|
files.set(change.relativePath, contents);
|
|
93
94
|
const next = snapshot();
|
|
94
95
|
const position = {
|
|
95
|
-
kind: commit.mode
|
|
96
|
+
kind: isCorrectionCommit(commit.mode)
|
|
96
97
|
? "correction"
|
|
97
98
|
: context === undefined
|
|
98
99
|
? "history"
|
|
@@ -171,7 +171,7 @@ export class WorldDocumentStore {
|
|
|
171
171
|
code: "logical_path_invalid",
|
|
172
172
|
logicalPath: command.toLogicalPath,
|
|
173
173
|
documentId: target.entry.descriptor.documentId,
|
|
174
|
-
message:
|
|
174
|
+
message: `Move target must be a safe logical path under ${this.logicalRoot}/ with the original codec`,
|
|
175
175
|
});
|
|
176
176
|
if (working.some(({ file }) => file !== target.working.file &&
|
|
177
177
|
file.path === command.toLogicalPath))
|
|
@@ -3,6 +3,13 @@ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { projectFileNativeHistorySurface, projectHistoryBackgrounds, } from "./FileNativeHistoryProjection.js";
|
|
5
5
|
import { appendTurn, initializeLedger, listTurns, readEndpoint, readFileAt, readHead as readLedgerHead, readTreeAt, readTurnDigest, resolveEndpoint, } from "./WorldTurnLedger.js";
|
|
6
|
+
/**
|
|
7
|
+
* 世界修订应用与连续性修正同属修订谱系:它们记录 `corrects` 与 `correctionTargets`,
|
|
8
|
+
* 维护报告与修订追溯按此归类。
|
|
9
|
+
*/
|
|
10
|
+
export function isCorrectionCommit(mode) {
|
|
11
|
+
return mode === "correction" || mode === "revision_apply";
|
|
12
|
+
}
|
|
6
13
|
export class WorldAuthorityError extends Error {
|
|
7
14
|
code;
|
|
8
15
|
constructor(code, message) {
|
|
@@ -113,14 +120,38 @@ export class WorldLedgerAuthority {
|
|
|
113
120
|
return structuredClone(parsed.items ?? []);
|
|
114
121
|
}
|
|
115
122
|
async #stateAt(position) {
|
|
116
|
-
|
|
123
|
+
return this.#surfaceAt(position, "state");
|
|
124
|
+
}
|
|
125
|
+
async #controlAt(position) {
|
|
126
|
+
return this.#surfaceAt(position, "control");
|
|
127
|
+
}
|
|
128
|
+
async #surfaceAt(position, surface) {
|
|
129
|
+
const prefix = `${surface}/`;
|
|
130
|
+
const tree = await readTreeAt(this.#root, position, [surface]);
|
|
117
131
|
return [...tree]
|
|
118
132
|
.map(([path, contents]) => ({
|
|
119
|
-
path: path.slice(
|
|
133
|
+
path: path.slice(prefix.length),
|
|
120
134
|
contents,
|
|
121
135
|
}))
|
|
122
136
|
.sort((left, right) => left.path.localeCompare(right.path));
|
|
123
137
|
}
|
|
138
|
+
/** 端点的控制文件:与状态同一账本视图,回退与分叉随端点恢复。 */
|
|
139
|
+
async recoverControl(head) {
|
|
140
|
+
return this.#controlAt(await this.#positionOf(head));
|
|
141
|
+
}
|
|
142
|
+
/** 把当前端点的控制文件替换为 next 所需的逐文件改动(写入或删除,未变化的不记录)。 */
|
|
143
|
+
async #controlChanges(position, next) {
|
|
144
|
+
const current = await readTreeAt(this.#root, position, ["control"]);
|
|
145
|
+
const wanted = new Map(next.map((file) => [`control/${file.path}`, file.contents]));
|
|
146
|
+
const changes = [];
|
|
147
|
+
for (const path of current.keys())
|
|
148
|
+
if (!wanted.has(path))
|
|
149
|
+
changes.push({ path, content: null });
|
|
150
|
+
for (const [path, contents] of wanted)
|
|
151
|
+
if (current.get(path) !== contents)
|
|
152
|
+
changes.push({ path, content: contents });
|
|
153
|
+
return changes;
|
|
154
|
+
}
|
|
124
155
|
#checkpointAt(commits) {
|
|
125
156
|
for (let index = commits.length - 1; index >= 0; index -= 1) {
|
|
126
157
|
const commit = commits[index];
|
|
@@ -246,7 +277,8 @@ export class WorldLedgerAuthority {
|
|
|
246
277
|
}
|
|
247
278
|
/**
|
|
248
279
|
* 在当前端点之上追加一轮。调用方先校验内容;这里校验状态的前像哈希,
|
|
249
|
-
*
|
|
280
|
+
* 并一次性计算本轮需要替换的全部文件(状态、控制、历史、历史背景与材料清单)。
|
|
281
|
+
* `control` 是提交后的完整控制文件集合:与端点的差异作为本轮改动,逐文件记录前像。
|
|
250
282
|
*/
|
|
251
283
|
async appendCommit(input) {
|
|
252
284
|
const current = await this.readHead();
|
|
@@ -289,6 +321,8 @@ export class WorldLedgerAuthority {
|
|
|
289
321
|
path: `runtime/history-backgrounds/${file.path}`,
|
|
290
322
|
content: file.contents,
|
|
291
323
|
});
|
|
324
|
+
if (input.control !== undefined)
|
|
325
|
+
changes.push(...(await this.#controlChanges(current.sequence, input.control)));
|
|
292
326
|
changes.push({
|
|
293
327
|
path: materialsFile,
|
|
294
328
|
content: `${JSON.stringify({ head, items: input.nextMaterials }, null, 2)}\n`,
|
|
@@ -194,6 +194,10 @@ async function captureBefore(root, paths) {
|
|
|
194
194
|
// 第一步:写入本轮记录(序号为 head + 1)。记录原子落盘,之后任何一步崩溃都可由 recoverIncomplete 回滚。
|
|
195
195
|
export async function beginTurn(root, input) {
|
|
196
196
|
const head = await readHead(root);
|
|
197
|
+
// head + 1 上还留着记录,说明上一轮没有发布端点(中断,或回滚失败)。
|
|
198
|
+
// 先恢复,否则新记录会把半写入的文件当作前像保存。
|
|
199
|
+
if ((await readTextOrNull(join(root, turnFileName(head + 1)))) !== null)
|
|
200
|
+
await recoverIncomplete(root);
|
|
197
201
|
const record = {
|
|
198
202
|
sequence: head + 1,
|
|
199
203
|
mode: input.mode,
|
|
@@ -226,12 +230,21 @@ export async function commitTurnHead(root, record) {
|
|
|
226
230
|
}
|
|
227
231
|
export async function appendTurn(root, input) {
|
|
228
232
|
const record = await beginTurn(root, input);
|
|
229
|
-
|
|
230
|
-
|
|
233
|
+
try {
|
|
234
|
+
await applyChanges(root, input.changes);
|
|
235
|
+
await commitTurnHead(root, record);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
// 本轮未发布:用启动恢复的同一规则回滚。回滚本身失败时保留原始错误;
|
|
239
|
+
// 残留的记录留在 head + 1 上,下一次推进开始前会先恢复(见 beginTurn)。
|
|
240
|
+
await recoverIncomplete(root).catch(() => undefined);
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
231
243
|
return record;
|
|
232
244
|
}
|
|
233
|
-
//
|
|
234
|
-
//
|
|
245
|
+
// 世界首次打开时调用(FileNativeWorldStore),进程内推进失败时也调用:序号大于 head 的记录
|
|
246
|
+
// 说明对应推进未完成,逆序恢复其修改前像。被丢弃的轮记录(读取时已忽略)在这里清理。
|
|
247
|
+
// 返回是否回滚了未完成的轮。
|
|
235
248
|
export async function recoverIncomplete(root) {
|
|
236
249
|
const head = await readHead(root);
|
|
237
250
|
const turns = await readAllRecords(root);
|
|
@@ -320,9 +320,7 @@ function validApply(value) {
|
|
|
320
320
|
nonEmptyString(value.operationId) &&
|
|
321
321
|
typeof value.expectedRevision === "string" &&
|
|
322
322
|
treeRevisionPattern.test(value.expectedRevision) &&
|
|
323
|
-
(value.phase === "prepared" ||
|
|
324
|
-
value.phase === "state_committed" ||
|
|
325
|
-
value.phase === "control_published") &&
|
|
323
|
+
(value.phase === "prepared" || value.phase === "committed") &&
|
|
326
324
|
(value.phase === "prepared"
|
|
327
325
|
? value.committedHead === null
|
|
328
326
|
: nonEmptyString(value.committedHead))));
|
|
@@ -337,7 +335,7 @@ function validEpochLifecycle(value) {
|
|
|
337
335
|
value.apply.expectedRevision === value.revision);
|
|
338
336
|
if (value.lifecycle === "applied")
|
|
339
337
|
return (isRecord(value.apply) &&
|
|
340
|
-
value.apply.phase === "
|
|
338
|
+
value.apply.phase === "committed" &&
|
|
341
339
|
value.apply.expectedRevision === value.revision &&
|
|
342
340
|
value.finishedAt !== null);
|
|
343
341
|
return (value.lifecycle === "discarded" &&
|