narraeon 0.7.4 → 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.
@@ -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
- * Upgrade an Authority V3 world to the current ledger layout before current
80
- * Authority or play-timeline behavior crosses its seam. Concurrent callers share one
81
- * in-process promise; the durable world lock serializes processes.
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,19 +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 this.playAdvances.layoutCurrent(worldId))) {
111
- await authority.readHead();
112
- this.#currentStorage.add(worldId);
113
- return;
114
- }
115
109
  await this.operations.withWorldAuthorityLock(worldId, async () => {
116
- await this.#storageMigrator.migrate(worldId);
117
- await migrateLegacyArtifactStore(dirname(this.#worldsRoot), worldId);
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);
118
126
  });
119
- this.#currentStorage.add(worldId);
120
127
  }
121
128
  catch (error) {
122
129
  if (error instanceof FileNativeWorldCreationError)
@@ -425,9 +432,9 @@ export class FileNativeWorldStore {
425
432
  * must never pair a mutable state tree with a different response head.
426
433
  */
427
434
  async renderPlayerViewsAtHead(worldId, head) {
428
- const [state, control] = await Promise.all([
429
- this.recoverEndpoint(worldId, head).then(({ state }) => state),
430
- this.readSurface(worldId, "control"),
435
+ const [{ state }, control] = await Promise.all([
436
+ this.recoverEndpoint(worldId, head),
437
+ this.#recoverEndpointControl(worldId, head),
431
438
  ]);
432
439
  const source = control.find(({ path }) => path === "player-views.yaml");
433
440
  return new PlayerViewRenderer().render({
@@ -533,54 +540,6 @@ export class FileNativeWorldStore {
533
540
  async bindWorldRevision(handle) {
534
541
  return this.operations.withWorldRevisionLock(handle, () => this.bindPlayCallChain(handle.worldId));
535
542
  }
536
- /** Replace control while the matching revision epoch retains exclusive ownership. */
537
- async replaceWorldRevisionControl(input) {
538
- for (const file of input.files)
539
- assertRelativePath(file.path);
540
- return this.operations.withWorldRevisionLock(input.handle, async () => {
541
- if ((this.#activeControlUsers.get(input.handle.worldId)?.size ?? 0) > 0)
542
- throw new FileNativeWorldCreationError("operation_conflict", "World control is frozen by another operation");
543
- const root = join(this.#worldsRoot, input.handle.worldId);
544
- const currentPath = join(root, "control");
545
- const runtimeRoot = join(root, "runtime");
546
- const stagingRoot = join(runtimeRoot, `.revision-control-${input.handle.epochId}-next`);
547
- const stagedPath = join(stagingRoot, "control");
548
- const previousPath = join(runtimeRoot, `.revision-control-${input.handle.epochId}-previous`);
549
- let current = await readTree(currentPath);
550
- let currentFingerprint = fingerprint(current);
551
- const nextFingerprint = fingerprint(input.files);
552
- if (currentFingerprint === nextFingerprint) {
553
- await rm(stagingRoot, { recursive: true, force: true });
554
- await rm(previousPath, { recursive: true, force: true });
555
- return { fingerprint: nextFingerprint };
556
- }
557
- const previous = await readTree(previousPath);
558
- const staged = await readTree(stagedPath);
559
- if (current.length === 0 &&
560
- fingerprint(previous) === input.expectedFingerprint) {
561
- if (fingerprint(staged) === nextFingerprint) {
562
- await rename(stagedPath, currentPath);
563
- await rm(previousPath, { recursive: true, force: true });
564
- await rm(stagingRoot, { recursive: true, force: true });
565
- return { fingerprint: nextFingerprint };
566
- }
567
- await rename(previousPath, currentPath);
568
- current = await readTree(currentPath);
569
- currentFingerprint = fingerprint(current);
570
- }
571
- if (currentFingerprint !== input.expectedFingerprint)
572
- throw new FileNativeWorldCreationError("operation_conflict", "World control changed after this revision epoch opened");
573
- assertControlGameplayUnchanged(current, input.files);
574
- await rm(stagingRoot, { recursive: true, force: true });
575
- await rm(previousPath, { recursive: true, force: true });
576
- await writeSurface(stagingRoot, "control", input.files);
577
- await rename(currentPath, previousPath);
578
- await rename(stagedPath, currentPath);
579
- await rm(previousPath, { recursive: true, force: true });
580
- await rm(stagingRoot, { recursive: true, force: true });
581
- return { fingerprint: nextFingerprint };
582
- });
583
- }
584
543
  async previewControlDraft(worldId, prompt) {
585
544
  const { state, draft } = await this.#readControlDraft(worldId);
586
545
  const rendered = new PlayerViewRenderer().render({
@@ -621,20 +580,25 @@ export class FileNativeWorldStore {
621
580
  throw new FileNativeWorldCreationError("operation_conflict", "World control is frozen by a running attempt");
622
581
  const preview = await this.previewControlDraft(worldId, prompt);
623
582
  const root = join(this.#worldsRoot, worldId);
624
- assertControlGameplayUnchanged(await readTree(join(root, "control")), await readTree(join(root, "runtime/control-draft")));
625
- const draft = join(root, "runtime/control-draft");
626
- const replacement = join(root, `runtime/.control-apply-${randomUUID()}`);
627
- await rename(draft, replacement);
628
- const old = join(root, `runtime/.control-old-${randomUUID()}`);
629
- await rename(join(root, "control"), old);
630
- try {
631
- await rename(replacement, join(root, "control"));
632
- await rm(old, { recursive: true, force: true }).catch(() => undefined);
633
- }
634
- catch (error) {
635
- await rename(old, join(root, "control")).catch(() => undefined);
636
- throw error;
637
- }
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
+ });
638
602
  return {
639
603
  controlFingerprint: preview.diagnosticBinding.controlFingerprint,
640
604
  preview,
@@ -702,8 +666,9 @@ export class FileNativeWorldStore {
702
666
  }
703
667
  try {
704
668
  return await this.operations.withWorldAuthorityLock(worldId, async () => {
705
- const recovered = await new WorldLedgerAuthority(root).recover();
706
- const control = await readTree(join(root, "control"));
669
+ const authority = new WorldLedgerAuthority(root);
670
+ const recovered = await authority.recover();
671
+ const control = await authority.recoverControl(recovered.head);
707
672
  return {
708
673
  worldId,
709
674
  parentHead: recovered.head,
@@ -728,7 +693,7 @@ export class FileNativeWorldStore {
728
693
  }
729
694
  }
730
695
  /**
731
- * Bind an immutable historical endpoint with the world's current control
696
+ * Bind an immutable historical endpoint with that endpoint's own control
732
697
  * surface. This is the same semantic input as a fresh context started while
733
698
  * that endpoint was current, without rewinding today's head.
734
699
  */
@@ -736,7 +701,7 @@ export class FileNativeWorldStore {
736
701
  assertIdentity(worldId, "World ID");
737
702
  const [endpoint, control] = await Promise.all([
738
703
  this.recoverEndpoint(worldId, head),
739
- this.readSurface(worldId, "control"),
704
+ this.#recoverEndpointControl(worldId, head),
740
705
  ]);
741
706
  return {
742
707
  worldId,
@@ -795,12 +760,16 @@ export class FileNativeWorldStore {
795
760
  };
796
761
  }
797
762
  catch (error) {
798
- if (error instanceof FileNativeWorldCreationError)
799
- throw error;
800
- // 端点在读取途中被回退丢弃不是损坏:调用方重新取得当前端点即可。
801
- if (error instanceof WorldTurnLedgerError && error.code === "discarded")
802
- throw new FileNativeWorldCreationError("endpoint_discarded", `Authority endpoint was rewound away${head === undefined ? "" : `: ${head}`}`, { cause: error });
803
- throw new FileNativeWorldCreationError("world_corrupt", `Cannot recover immutable Authority endpoint${head === undefined ? "" : `: ${head}`}`, error instanceof Error ? { cause: error } : undefined);
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);
804
773
  }
805
774
  }
806
775
  async deriveWorld(input) {
@@ -846,10 +815,7 @@ export class FileNativeWorldStore {
846
815
  const sourceHead = input.sourceHead === "current"
847
816
  ? (await sourceAuthority.readHead()).head
848
817
  : input.sourceHead;
849
- const [sourceGenesis, control] = await Promise.all([
850
- readGenesisForFork(sourceRoot, input.sourceWorldId),
851
- readTree(join(sourceRoot, "control")),
852
- ]);
818
+ const sourceGenesis = await readGenesisForFork(sourceRoot, input.sourceWorldId);
853
819
  const staging = join(dirname(finalRoot), `.staging-${worldId}-${randomUUID()}`);
854
820
  await mkdir(dirname(finalRoot), { recursive: true, mode: 0o700 });
855
821
  try {
@@ -862,7 +828,9 @@ export class FileNativeWorldStore {
862
828
  });
863
829
  // 分叉不信任来源的投影文件:当前文件由副本上的账本端点重建。
864
830
  await new WorldLedgerAuthority(staging).materializeCurrentFiles();
831
+ // 控制与状态一样取自所选端点:回退已把副本的 control/ 恢复到该端点。
865
832
  const selected = await new WorldLedgerAuthority(staging).recover();
833
+ const selectedControl = await new WorldLedgerAuthority(staging).recoverControl(selected.head);
866
834
  await PackageScriptPermissions.write(staging, input.resetPackageScriptPermissions
867
835
  ? []
868
836
  : await PackageScriptPermissions.read(sourceRoot));
@@ -874,7 +842,7 @@ export class FileNativeWorldStore {
874
842
  operationId: input.operationId,
875
843
  parentEndpoint: "genesis",
876
844
  state: structuredClone(sourceGenesis.state),
877
- control: immutableFiles(control),
845
+ control: immutableFiles(selectedControl),
878
846
  history: structuredClone(sourceGenesis.history),
879
847
  additionalMaterials: structuredClone(sourceGenesis.additionalMaterials),
880
848
  };
@@ -891,7 +859,7 @@ export class FileNativeWorldStore {
891
859
  binding: {
892
860
  worldId,
893
861
  parentHead: sourceHead,
894
- files: [...selected.state, ...control].reduce((files, { path, contents }, index) => {
862
+ files: [...selected.state, ...selectedControl].reduce((files, { path, contents }, index) => {
895
863
  files[`${index < selected.state.length ? "state" : "control"}/${path}`] = contents;
896
864
  return files;
897
865
  }, {}),
@@ -1021,9 +989,6 @@ export class FileNativeWorldStore {
1021
989
  mode: "correction",
1022
990
  historyAppend: [],
1023
991
  operationReserved,
1024
- ...(input.validationControl === undefined
1025
- ? {}
1026
- : { validationControl: input.validationControl }),
1027
992
  });
1028
993
  };
1029
994
  if (input.stateOperationClaim !== undefined &&
@@ -1041,6 +1006,45 @@ export class FileNativeWorldStore {
1041
1006
  throw error;
1042
1007
  }
1043
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
+ }
1044
1048
  /**
1045
1049
  * Persist one already-visible step of the model-directed play call chain.
1046
1050
  * Player text, assistant text and world changes deliberately arrive in
@@ -1094,11 +1098,16 @@ export class FileNativeWorldStore {
1094
1098
  throw new FileNativeWorldCreationError("operation_conflict", "The same play operation ID is occupied by another durable operation");
1095
1099
  const authorityStore = new WorldLedgerAuthority(root);
1096
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
+ }
1097
1106
  if (authority.head !== input.parentHead)
1098
1107
  throw new FileNativeWorldCreationError("operation_conflict", "The parent endpoint of the play operation has changed");
1099
- const [basisState, storedControl] = await Promise.all([
1108
+ const [basisState, basisControl] = await Promise.all([
1100
1109
  authorityStore.recoverState(authority.head),
1101
- readTree(join(root, "control")),
1110
+ authorityStore.recoverControl(authority.head),
1102
1111
  ]);
1103
1112
  const candidateState = new Map(basisState.map((file) => [file.path, file.contents]));
1104
1113
  for (const change of input.stateChanges) {
@@ -1115,7 +1124,7 @@ export class FileNativeWorldStore {
1115
1124
  path: `world/${path}`,
1116
1125
  contents,
1117
1126
  })),
1118
- ...(input.validationControl ?? storedControl).map(({ path, contents }) => ({
1127
+ ...(input.control ?? basisControl).map(({ path, contents }) => ({
1119
1128
  path: `control/${path}`,
1120
1129
  contents,
1121
1130
  })),
@@ -1135,10 +1144,11 @@ export class FileNativeWorldStore {
1135
1144
  historyAppend: input.historyAppend,
1136
1145
  stateChanges: input.stateChanges,
1137
1146
  nextMaterials: input.nextMaterials,
1147
+ ...(input.control === undefined ? {} : { control: input.control }),
1138
1148
  ...(input.narrativeCheckpoint === undefined
1139
1149
  ? {}
1140
1150
  : { narrativeCheckpoint: input.narrativeCheckpoint }),
1141
- ...(input.mode === "correction"
1151
+ ...(isCorrectionCommit(input.mode)
1142
1152
  ? {
1143
1153
  correctionTargets: input.stateChanges.map(({ documentId }) => documentId),
1144
1154
  corrects: input.parentHead,
@@ -1169,6 +1179,27 @@ export class FileNativeWorldStore {
1169
1179
  throw error;
1170
1180
  }
1171
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
+ }
1172
1203
  async readAuthorityHistory(worldId) {
1173
1204
  assertIdentity(worldId, "World ID");
1174
1205
  await this.ensureCurrentStorage(worldId);
@@ -1228,9 +1259,12 @@ export class FileNativeWorldStore {
1228
1259
  throw new FileNativeWorldCreationError("world_corrupt", `Historical endpoint does not exist: ${endpoint}`);
1229
1260
  return {
1230
1261
  endpoint,
1231
- corrects: commit?.mode === "correction" ? (commit.corrects ?? null) : null,
1262
+ corrects: commit !== undefined && isCorrectionCommit(commit.mode)
1263
+ ? (commit.corrects ?? null)
1264
+ : null,
1232
1265
  correctedBy: commits
1233
- .filter((candidate) => candidate.mode === "correction" && candidate.corrects === endpoint)
1266
+ .filter((candidate) => isCorrectionCommit(candidate.mode) &&
1267
+ candidate.corrects === endpoint)
1234
1268
  .map(({ head }) => head),
1235
1269
  };
1236
1270
  }
@@ -1514,7 +1548,8 @@ function authorityCommitMatchesInput(commit, input) {
1514
1548
  nextHash: candidate.nextHash,
1515
1549
  });
1516
1550
  });
1517
- const expectedCorrection = input.mode === "correction"
1551
+ const correction = isCorrectionCommit(input.mode);
1552
+ const expectedCorrection = correction
1518
1553
  ? input.stateChanges.map(({ documentId }) => documentId)
1519
1554
  : undefined;
1520
1555
  return (commit.mode === input.mode &&
@@ -1530,8 +1565,15 @@ function authorityCommitMatchesInput(commit, input) {
1530
1565
  isDeepStrictEqual(commit.nextAdditionalMaterials, input.nextMaterials) &&
1531
1566
  isDeepStrictEqual(commit.narrativeCheckpoint, input.narrativeCheckpoint) &&
1532
1567
  isDeepStrictEqual(commit.correctionTargets, expectedCorrection) &&
1533
- commit.corrects ===
1534
- (input.mode === "correction" ? input.parentHead : undefined));
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);
1535
1577
  }
1536
1578
  function headPosition(head) {
1537
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 === "correction"
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: "Move target must be a safe logical path under world/ with the original codec",
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
- const tree = await readTreeAt(this.#root, position, ["state"]);
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("state/".length),
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
- await applyChanges(root, input.changes);
230
- await commitTurnHead(root, record);
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
- // 启动时调用:序号大于 head 的记录说明上一次推进未完成,逆序恢复其修改前像。
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 === "control_published" &&
338
+ value.apply.phase === "committed" &&
341
339
  value.apply.expectedRevision === value.revision &&
342
340
  value.finishedAt !== null);
343
341
  return (value.lifecycle === "discarded" &&