narraeon 0.7.1 → 0.7.4

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.
Files changed (28) hide show
  1. package/dist/node/protocol/v1.js +8 -2
  2. package/dist/node/runtime/V1Runtime.js +104 -71
  3. package/dist/node/runtime/archive/WorldArchives.js +17 -5
  4. package/dist/node/runtime/artifact/FileNativeArtifactStore.js +59 -12
  5. package/dist/node/runtime/config/dataRootCleanup.js +52 -0
  6. package/dist/node/runtime/content/ContentWorkspace.js +2 -2
  7. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +34 -2
  8. package/dist/node/runtime/model/FileNativeModelAdapters.js +106 -18
  9. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +70 -3
  10. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +236 -26
  11. package/dist/node/runtime/play/PlayCallChain.js +172 -54
  12. package/dist/node/runtime/play/PlayPromptRun.js +157 -3
  13. package/dist/node/runtime/play/PlayTraceRetention.js +2 -2
  14. package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +10 -0
  15. package/dist/node/runtime/world/FileNativeWorldStore.js +11 -4
  16. package/dist/node/runtime/world/WorldLedgerAuthority.js +13 -10
  17. package/dist/node/runtime/world/WorldOperationCoordinator.js +4 -0
  18. package/dist/node/runtime/world/WorldQueue.js +14 -1
  19. package/dist/node/runtime/world/WorldTurnLedger.js +48 -20
  20. package/dist/node/server/createServer.js +44 -15
  21. package/dist/node/server/startWebServer.js +2 -5
  22. package/dist/web/assets/index-9N9kO8kY.js +1408 -0
  23. package/dist/web/assets/index-BjxBYYQi.css +1 -0
  24. package/dist/web/index.html +2 -2
  25. package/package.json +1 -1
  26. package/dist/node/runtime/config/dataRootLock.js +0 -108
  27. package/dist/web/assets/index-BUCslLEK.css +0 -1
  28. package/dist/web/assets/index-DkamBH6c.js +0 -1408
@@ -4,10 +4,12 @@ import { maxPortableContentArchiveBase64Characters } from "./contentTree.js";
4
4
  export const v1Protocol = "narraeon.runtime/v1";
5
5
  export class V1ProtocolError extends Error {
6
6
  code;
7
- constructor(code, message, options) {
7
+ details;
8
+ constructor(code, message, options = {}) {
8
9
  super(message, options);
9
10
  this.name = "V1ProtocolError";
10
11
  this.code = code;
12
+ this.details = options.details;
11
13
  }
12
14
  }
13
15
  export function parseV1Envelope(value) {
@@ -37,7 +39,11 @@ const requiredFields = {
37
39
  "world.package-scripts.set": { worldId: "string", enabled: "boolean" },
38
40
  "content.gameplay.set": { packageId: "string", scheme: "string" },
39
41
  "content.read": { packageId: "string" },
40
- "content.replace": { packageId: "string", files: "array" },
42
+ "content.replace": {
43
+ packageId: "string",
44
+ files: "array",
45
+ baseTreeRevision: "string",
46
+ },
41
47
  "content.copy": { packageId: "string" },
42
48
  "content.delete": { packageId: "string" },
43
49
  "content.rename": { packageId: "string", name: "string" },
@@ -7,7 +7,7 @@ import { join } from "node:path";
7
7
  import { maxPortableContentArchiveBase64Characters, maxPortableContentArchiveBytes, } from "../protocol/contentTree.js";
8
8
  import { V1ProtocolError, v1Protocol, } from "../protocol/v1.js";
9
9
  import { defaultAppLocale, } from "../protocol/appPreferences.js";
10
- import { ContentPackageImportError, ContentWorkspace, InvalidContentTreeError, } from "./content/ContentWorkspace.js";
10
+ import { ContentPackageImportError, ContentTreeConflictError, ContentWorkspace, InvalidContentTreeError, } from "./content/ContentWorkspace.js";
11
11
  import { contentTreeFingerprint } from "./content/ContentTreeFingerprint.js";
12
12
  import { inspectContentPackageCurrentTree } from "./content/FileNativeContentTree.js";
13
13
  import { FileNativeModelHost } from "./model/FileNativeModelAdapters.js";
@@ -34,6 +34,8 @@ import { FileNativeWorldRevisionStore } from "./world-revision/FileNativeWorldRe
34
34
  import { WorldRevisionSession } from "./world-revision/WorldRevisionSession.js";
35
35
  import { WorldRevisionWorkspace, WorldRevisionWorkspaceError, } from "./world-revision/WorldRevisionWorkspace.js";
36
36
  import { ConversationChanges } from "./ConversationChanges.js";
37
+ // 先取得当前端点再按它读取的请求,在端点被回退丢弃时最多重做这么多次(#94)。
38
+ const currentEndpointReadAttempts = 3;
37
39
  export class V1Runtime {
38
40
  worldArchives;
39
41
  #conversationChanges = new ConversationChanges();
@@ -163,6 +165,9 @@ export class V1Runtime {
163
165
  await this.#errorLog?.recordRequestFailure(request, error);
164
166
  if (error instanceof WorldArchiveError)
165
167
  throw new V1ProtocolError(error.code, error.message, { cause: error });
168
+ if (error instanceof FileNativeWorldCreationError &&
169
+ error.code === "endpoint_discarded")
170
+ throw new V1ProtocolError(error.code, error.message, { cause: error });
166
171
  if (request.type === "world.archive.export" &&
167
172
  error instanceof FileNativeWorldCreationError &&
168
173
  (error.code === "operation_conflict" ||
@@ -210,6 +215,10 @@ export class V1Runtime {
210
215
  : {}),
211
216
  }, error);
212
217
  }
218
+ /** Archive routes sit outside `handle`, so they record their own failures. */
219
+ async recordArchiveFailure(target, error) {
220
+ await this.#errorLog?.recordRequestFailure(target, error);
221
+ }
213
222
  subscribeConversation(target, changed) {
214
223
  return this.#conversationChanges.subscribe(`${target.kind}:${target.id}`, changed);
215
224
  }
@@ -319,13 +328,18 @@ export class V1Runtime {
319
328
  return this.#content.readCurrentTreeContentPackage(request.packageId);
320
329
  case "content.replace":
321
330
  try {
322
- return await this.#content.replaceCurrentTreeContentPackage(request.packageId, request.files);
331
+ return await this.#content.replaceCurrentTreeContentPackage(request.packageId, request.files, { baseTreeRevision: request.baseTreeRevision });
323
332
  }
324
333
  catch (error) {
325
334
  if (error instanceof InvalidContentTreeError)
326
335
  throw new V1ProtocolError("invalid_request", error.message, {
327
336
  cause: error,
328
337
  });
338
+ if (error instanceof ContentTreeConflictError)
339
+ throw new V1ProtocolError("content_conflict", error.message, {
340
+ cause: error,
341
+ details: { current: error.current },
342
+ });
329
343
  throw error;
330
344
  }
331
345
  case "content.copy":
@@ -415,9 +429,12 @@ export class V1Runtime {
415
429
  : await this.#playPresets.bindRevision(request.presetId, request.revision);
416
430
  if (request.worldId === undefined)
417
431
  return buildPlayPresetWorkbenchSnapshot(binding, undefined, request.draft);
418
- const head = await this.#worlds.currentHead(request.worldId);
419
- const playerViews = await this.#worlds.renderPlayerViewsAtHead(request.worldId, head);
420
- return buildPlayPresetWorkbenchSnapshot(binding, { worldId: request.worldId, head, playerViews }, request.draft);
432
+ const { worldId } = request;
433
+ return this.#readingCurrentEndpoint(async () => {
434
+ const head = await this.#worlds.currentHead(worldId);
435
+ const playerViews = await this.#worlds.renderPlayerViewsAtHead(worldId, head);
436
+ return buildPlayPresetWorkbenchSnapshot(binding, { worldId, head, playerViews }, request.draft);
437
+ });
421
438
  }
422
439
  case "prompt.preview": {
423
440
  const [package_, connection, playPreset] = await Promise.all([
@@ -509,36 +526,37 @@ export class V1Runtime {
509
526
  return this.worldArchives.exportWorld(request);
510
527
  case "world.archive.import":
511
528
  return this.worldArchives.importWorld(request);
512
- case "world.read": {
513
- const head = await this.#worlds.currentHead(request.worldId);
514
- const playerViews = await this.#worlds.renderPlayerViewsAtHead(request.worldId, head);
515
- const extensionControls = await this.#currentExtensionControls(request.worldId);
516
- const [playerViewPanels, playTimeline, revisionEpoch] = await Promise.all([
517
- this.#frontendPlayerViewPanels(request.worldId, head, playerViews, extensionControls),
518
- this.#worlds.playTimeline.readPage(request.worldId, 40),
519
- this.#worldRevisions.activeEpoch(request.worldId),
520
- ]);
521
- return {
522
- worldId: request.worldId,
523
- head,
524
- state: [],
525
- control: [],
526
- history: [],
527
- runtime: { head, surfaces: "lazy" },
528
- playerViews,
529
- committedMessages: [],
530
- artifacts: [],
531
- extensions: [],
532
- playerViewPanels,
533
- extensionControls,
534
- suppressedPlayerViewIds: await this.#suppressedPlayerViewIds(extensionControls),
535
- artifactDebug: [],
536
- playCallChain: null,
537
- playTimeline,
538
- worldRevision: revisionEpoch,
539
- archiveNeedsFreshContext: await this.#worlds.archiveNeedsFreshContext(request.worldId),
540
- };
541
- }
529
+ case "world.read":
530
+ return this.#readingCurrentEndpoint(async () => {
531
+ const head = await this.#worlds.currentHead(request.worldId);
532
+ const playerViews = await this.#worlds.renderPlayerViewsAtHead(request.worldId, head);
533
+ const extensionControls = await this.#currentExtensionControls(request.worldId);
534
+ const [playerViewPanels, playTimeline, revisionEpoch] = await Promise.all([
535
+ this.#frontendPlayerViewPanels(request.worldId, head, playerViews, extensionControls),
536
+ this.#worlds.playTimeline.readPage(request.worldId, 40),
537
+ this.#worldRevisions.activeEpoch(request.worldId),
538
+ ]);
539
+ return {
540
+ worldId: request.worldId,
541
+ head,
542
+ state: [],
543
+ control: [],
544
+ history: [],
545
+ runtime: { head, surfaces: "lazy" },
546
+ playerViews,
547
+ committedMessages: [],
548
+ artifacts: [],
549
+ extensions: [],
550
+ playerViewPanels,
551
+ extensionControls,
552
+ suppressedPlayerViewIds: await this.#suppressedPlayerViewIds(extensionControls),
553
+ artifactDebug: [],
554
+ playCallChain: null,
555
+ playTimeline,
556
+ worldRevision: revisionEpoch,
557
+ archiveNeedsFreshContext: await this.#worlds.archiveNeedsFreshContext(request.worldId),
558
+ };
559
+ });
542
560
  case "world.surface.read":
543
561
  return request.surface === "runtime"
544
562
  ? this.#worlds.readSurface(request.worldId, "runtime")
@@ -549,33 +567,33 @@ export class V1Runtime {
549
567
  const result = await this.#worlds.extensionControls(request.worldId, await this.#playPresets.bindCurrent(), this.#locale, request);
550
568
  return result;
551
569
  }
552
- case "world.play-decorations.read": {
553
- for (;;) {
554
- const extensionControls = await this.#currentExtensionControls(request.worldId);
555
- const head = await this.#reconcileArtifacts(request.worldId);
556
- const [projection, artifactDebug] = await Promise.all([
557
- this.#frontendDecorations(request.worldId),
558
- this.#frontendDebug(request.worldId),
559
- ]);
560
- const playerViews = await this.#worlds.renderPlayerViewsAtHead(request.worldId, head);
561
- const playerViewPanels = await this.#frontendPlayerViewPanels(request.worldId, head, playerViews, extensionControls);
562
- const suppressedPlayerViewIds = await this.#suppressedPlayerViewIds(extensionControls);
563
- // Never attach a newer controls revision to a projection read before
564
- // that choice was accepted. Concurrent toggles restart this read.
565
- if ((await this.#worlds.extensionControlsRevision(request.worldId)) !==
566
- extensionControls.revision)
567
- continue;
568
- return {
569
- head,
570
- ...projection,
571
- artifactDebug,
572
- playerViews,
573
- playerViewPanels,
574
- extensionControls,
575
- suppressedPlayerViewIds,
576
- };
577
- }
578
- }
570
+ case "world.play-decorations.read":
571
+ return this.#readingCurrentEndpoint(async () => {
572
+ for (;;) {
573
+ const extensionControls = await this.#currentExtensionControls(request.worldId);
574
+ const head = await this.#reconcileArtifacts(request.worldId);
575
+ const [projection, artifactDebug] = await Promise.all([
576
+ this.#frontendDecorations(request.worldId),
577
+ this.#frontendDebug(request.worldId),
578
+ ]);
579
+ const playerViews = await this.#worlds.renderPlayerViewsAtHead(request.worldId, head);
580
+ const playerViewPanels = await this.#frontendPlayerViewPanels(request.worldId, head, playerViews, extensionControls);
581
+ const suppressedPlayerViewIds = await this.#suppressedPlayerViewIds(extensionControls);
582
+ // Never attach a newer controls revision to a projection read before
583
+ // that choice was accepted. Concurrent toggles restart this read.
584
+ if ((await this.#worlds.extensionControlsRevision(request.worldId)) !== extensionControls.revision)
585
+ continue;
586
+ return {
587
+ head,
588
+ ...projection,
589
+ artifactDebug,
590
+ playerViews,
591
+ playerViewPanels,
592
+ extensionControls,
593
+ suppressedPlayerViewIds,
594
+ };
595
+ }
596
+ });
579
597
  case "artifacts.read":
580
598
  await this.#reconcileArtifacts(request.worldId);
581
599
  return (await this.#frontendDecorations(request.worldId, request.channel)).artifacts;
@@ -727,10 +745,10 @@ export class V1Runtime {
727
745
  nextFreshContext: null,
728
746
  };
729
747
  const { modelHost, hostBinding, playPreset, modelBinding } = await this.#continuousBinding();
730
- const persisted = currentContext === null
748
+ const live = currentContext === null
731
749
  ? null
732
- : await this.#worlds.playTimeline.readCurrent(request.worldId);
733
- const frozenBinding = persisted?.value.modelBinding;
750
+ : await this.#playCallChains.currentContinuation(request.worldId);
751
+ const frozenBinding = live?.modelBinding;
734
752
  const continueContext = currentContext !== null &&
735
753
  !currentContext.stale &&
736
754
  frozenBinding?.provider === modelBinding.provider;
@@ -768,7 +786,7 @@ export class V1Runtime {
768
786
  toolUniverse: tools,
769
787
  allowedTools: tools.map(({ name }) => name),
770
788
  toolStrategy: preview.compilation.toolStrategy,
771
- appended: continueContext ? (persisted?.value.transcript ?? []) : [],
789
+ appended: continueContext ? (live?.transcript ?? []) : [],
772
790
  operationId: continueContext
773
791
  ? currentContext.chainId
774
792
  : "next-fresh-context-preview",
@@ -845,12 +863,27 @@ export class V1Runtime {
845
863
  async #matchingModelHost(binding) {
846
864
  return new FileNativeModelHost(await this.#models.bindMatching(binding), fetch, this.#failureLog);
847
865
  }
866
+ /**
867
+ * Reads that take the current endpoint first and then read at it (ADR-0044):
868
+ * a rewind published in between discards that endpoint, so the read starts
869
+ * again from the new current endpoint instead of failing.
870
+ */
871
+ async #readingCurrentEndpoint(read) {
872
+ for (let attempt = 1;; attempt += 1) {
873
+ try {
874
+ return await read();
875
+ }
876
+ catch (error) {
877
+ if (!(error instanceof FileNativeWorldCreationError &&
878
+ error.code === "endpoint_discarded") ||
879
+ attempt >= currentEndpointReadAttempts)
880
+ throw error;
881
+ }
882
+ }
883
+ }
848
884
  async #reconcileArtifacts(worldId) {
849
- const [head, currentOperationId] = await Promise.all([
850
- this.#worlds.currentHead(worldId),
851
- this.#worlds.currentHeadOperationId(worldId),
852
- ]);
853
- await this.#artifacts.reconcileHead(worldId, head, currentOperationId ?? undefined);
885
+ const { head, operationId } = await this.#worlds.currentEndpoint(worldId);
886
+ await this.#artifacts.reconcileHead(worldId, head, operationId ?? undefined);
854
887
  return head;
855
888
  }
856
889
  async #frontendDecorations(worldId, channel) {
@@ -108,7 +108,23 @@ export class WorldArchives {
108
108
  }
109
109
  });
110
110
  }
111
+ /**
112
+ * Confirms that `download` can serve this export without consuming it, so the
113
+ * browser can check before it starts the one-time download.
114
+ */
115
+ async checkDownload(archiveId) {
116
+ const { root } = await this.#readExport(archiveId);
117
+ await stat(join(root, "archive.zip"));
118
+ }
111
119
  async download(archiveId) {
120
+ const { root, metadata } = await this.#readExport(archiveId);
121
+ return {
122
+ metadata,
123
+ stream: createReadStream(join(root, "archive.zip")),
124
+ cleanup: () => rm(root, { recursive: true, force: true }),
125
+ };
126
+ }
127
+ async #readExport(archiveId) {
112
128
  identity(archiveId);
113
129
  const root = join(this.#root, "exports", archiveId);
114
130
  const metadata = JSON.parse(await readFile(join(root, "download.json"), "utf8"));
@@ -117,11 +133,7 @@ export class WorldArchives {
117
133
  await rm(root, { recursive: true, force: true });
118
134
  throw new WorldArchiveError("invalid_archive", "The download has expired; export the world again");
119
135
  }
120
- return {
121
- metadata,
122
- stream: createReadStream(join(root, "archive.zip")),
123
- cleanup: () => rm(root, { recursive: true, force: true }),
124
- };
136
+ return { root, metadata };
125
137
  }
126
138
  async importWorld(input) {
127
139
  identity(input.operationId);
@@ -23,8 +23,13 @@ export class FileNativeArtifactStore {
23
23
  #dataRoot;
24
24
  #worldsRoot;
25
25
  #extensionControls;
26
- /** Records with `save: "none"` are never written to disk. */
26
+ /**
27
+ * Records with `save: "none"` are never written to disk. Superseded ones are
28
+ * dropped at once, cleared ones once their clearing operation closes.
29
+ */
27
30
  #memoryRecords = new Map();
31
+ /** Highest sequence handed out per world in this process, so removals never reuse one. */
32
+ #sequenceHighWater = new Map();
28
33
  #mutationContext = new AsyncLocalStorage();
29
34
  constructor(dataRoot) {
30
35
  this.#dataRoot = resolve(dataRoot);
@@ -109,9 +114,7 @@ export class FileNativeArtifactStore {
109
114
  throw new ArtifactStoreInvariantError("Artifact request attempt is missing an operation checkpoint");
110
115
  if (!sameContext(operation.context, context))
111
116
  throw new ArtifactStoreInvariantError("Artifact request attempt does not match the frozen operation");
112
- if (operation.status === "core_failed" ||
113
- operation.status === "completed" ||
114
- operation.artifactProjectionStatus === "superseded")
117
+ if (isClosedOperation(operation))
115
118
  return;
116
119
  const mounts = [
117
120
  ...new Set(Object.values(context.frozenPresentations ?? {})
@@ -730,6 +733,12 @@ export class FileNativeArtifactStore {
730
733
  };
731
734
  assertRawRecord(finalized);
732
735
  if (record.save === "none") {
736
+ // A superseded record has no reader left: the debug listing shows only
737
+ // records that still exist, so it is not kept.
738
+ if (record.status === "superseded") {
739
+ await this.#removeRecord(finalized);
740
+ return;
741
+ }
733
742
  const records = this.#memoryRecords.get(record.operationId) ?? [];
734
743
  const index = records.findIndex((candidate) => candidate.recordId === record.recordId);
735
744
  if (index < 0)
@@ -744,18 +753,50 @@ export class FileNativeArtifactStore {
744
753
  async #removeRecord(record) {
745
754
  if (record.save === "none") {
746
755
  const records = (this.#memoryRecords.get(record.operationId) ?? []).filter((candidate) => candidate.recordId !== record.recordId);
747
- this.#memoryRecords.set(record.operationId, records);
756
+ if (records.length === 0)
757
+ this.#memoryRecords.delete(record.operationId);
758
+ else
759
+ this.#memoryRecords.set(record.operationId, records);
748
760
  return;
749
761
  }
750
762
  await rm(join(this.#artifactsRoot(record.worldId), "records", artifactSequenceFileName(record.sequence)), { force: true });
751
763
  }
764
+ /**
765
+ * A cleared save:none record stays only while its clearing operation can still
766
+ * roll the clear back (see beginRequestAttempt). Once that operation is closed
767
+ * nothing reads the record, so it leaves memory.
768
+ */
769
+ async #releaseClosedClears(worldId) {
770
+ const cleared = [...this.#memoryRecords.values()]
771
+ .flat()
772
+ .filter((record) => record.worldId === worldId && record.status === "cleared");
773
+ if (cleared.length === 0)
774
+ return;
775
+ const operations = new Map((await this.#operationsForWorld(worldId)).map((operation) => [
776
+ operation.context.operationId,
777
+ operation,
778
+ ]));
779
+ for (const record of cleared) {
780
+ const clearing = record.clearedBy === undefined
781
+ ? undefined
782
+ : operations.get(record.clearedBy.operationId);
783
+ if (clearing !== undefined && !isClosedOperation(clearing))
784
+ continue;
785
+ await this.#removeRecord(record);
786
+ }
787
+ }
752
788
  async #operationsForWorld(worldId) {
753
789
  return [
754
790
  ...(await readOperationFiles(this.#artifactsRoot(worldId))).values(),
755
791
  ].filter((operation) => operation.context.worldId === worldId);
756
792
  }
793
+ /**
794
+ * The next sequence is one past the largest on disk or handed out in this
795
+ * process. The high-water mark outlives removed save:none records, so their
796
+ * sequences are never reused.
797
+ */
757
798
  async #nextSequence(worldId) {
758
- let maximum = 0;
799
+ let maximum = this.#sequenceHighWater.get(worldId) ?? 0;
759
800
  try {
760
801
  for (const name of await readdir(join(this.#artifactsRoot(worldId), "records"))) {
761
802
  if (!name.endsWith(".json"))
@@ -772,11 +813,9 @@ export class FileNativeArtifactStore {
772
813
  if (!isMissing(error))
773
814
  throw error;
774
815
  }
775
- for (const records of this.#memoryRecords.values())
776
- for (const record of records)
777
- if (record.worldId === worldId)
778
- maximum = Math.max(maximum, record.sequence);
779
- return maximum + 1;
816
+ const next = maximum + 1;
817
+ this.#sequenceHighWater.set(worldId, next);
818
+ return next;
780
819
  }
781
820
  async #readOperation(worldId, operationId) {
782
821
  try {
@@ -807,7 +846,9 @@ export class FileNativeArtifactStore {
807
846
  return action();
808
847
  return worldQueueFor(this.#dataRoot).run(worldId, () => this.#mutationContext.run(new Set([...(active ?? []), worldId]), async () => {
809
848
  await migrateLegacyArtifactStore(this.#dataRoot, worldId);
810
- return action();
849
+ const result = await action();
850
+ await this.#releaseClosedClears(worldId);
851
+ return result;
811
852
  }));
812
853
  }
813
854
  #artifactsRoot(worldId) {
@@ -1079,6 +1120,12 @@ function isTerminalExtensionStatus(status) {
1079
1120
  "superseded",
1080
1121
  ].includes(status);
1081
1122
  }
1123
+ /** A closed operation registers no further request attempts, so its clears are final. */
1124
+ function isClosedOperation(operation) {
1125
+ return (operation.status === "core_failed" ||
1126
+ operation.status === "completed" ||
1127
+ operation.artifactProjectionStatus === "superseded");
1128
+ }
1082
1129
  function isStaleOperation(operation) {
1083
1130
  return (operation === null ||
1084
1131
  operation.artifactProjectionStatus === "superseded" ||
@@ -0,0 +1,52 @@
1
+ import { mkdir, readdir, rm, rmdir } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ /** Persistent lock files and directories left by retired lock mechanisms. */
4
+ const legacyLockPaths = [
5
+ "server.pid",
6
+ "runtime-locks",
7
+ join("operations", "world-locks"),
8
+ join("operations", "authority-locks"),
9
+ join("operations", "correction-claims"),
10
+ ];
11
+ /**
12
+ * Startup housekeeping for a data directory. One service process per data
13
+ * directory is a product premise (ADR-0044), not something startup enforces:
14
+ * a pid lock cannot tell a crashed container's pid 1 from the restarted one.
15
+ */
16
+ export async function prepareDataRoot(dataRoot) {
17
+ const root = resolve(dataRoot);
18
+ await mkdir(root, { recursive: true, mode: 0o700 });
19
+ await removeLegacyLocks(root);
20
+ }
21
+ async function removeLegacyLocks(root) {
22
+ for (const relative of legacyLockPaths)
23
+ await rm(join(root, relative), { recursive: true, force: true });
24
+ await removeLegacyOperationReceipts(root);
25
+ }
26
+ /**
27
+ * Operation receipts written before #76 (including archive import receipts) have
28
+ * no readers. The world-revision epoch markers in the same directory are kept.
29
+ */
30
+ async function removeLegacyOperationReceipts(root) {
31
+ const directory = join(root, "operations");
32
+ const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
33
+ if (isNodeError(error) && error.code === "ENOENT")
34
+ return null;
35
+ throw error;
36
+ });
37
+ if (entries === null)
38
+ return;
39
+ for (const entry of entries)
40
+ if (entry.isFile() && entry.name.endsWith(".json"))
41
+ await rm(join(directory, entry.name), { force: true });
42
+ try {
43
+ await rmdir(directory);
44
+ }
45
+ catch (error) {
46
+ if (!isNodeError(error) || error.code !== "ENOTEMPTY")
47
+ throw error;
48
+ }
49
+ }
50
+ function isNodeError(error) {
51
+ return error instanceof Error && "code" in error;
52
+ }
@@ -7,7 +7,7 @@ import { defaultContentWorkspaceLimits, maximumPortableContentArchiveBytes, } fr
7
7
  import { CurrentTreeContentLibrary, InvalidContentTreeError, normalizeCurrentContentTreeFiles, } from "./CurrentTreeContentLibrary.js";
8
8
  import { sampleContentPackageForLocale } from "../../shared/sample-packages/index.js";
9
9
  import { inspectContentPackageCurrentTree, isContentPackageWorldDocumentPath, worldDocumentTextRequirement, } from "./FileNativeContentTree.js";
10
- export { CurrentTreeContentPackageBusyError, CurrentTreeContentLibraryError, CurrentTreeContentPackageNotFoundError, InvalidContentTreeError, } from "./CurrentTreeContentLibrary.js";
10
+ export { ContentTreeConflictError, CurrentTreeContentPackageBusyError, CurrentTreeContentLibraryError, CurrentTreeContentPackageNotFoundError, InvalidContentTreeError, } from "./CurrentTreeContentLibrary.js";
11
11
  const portableTreeUtf8Decoder = new TextDecoder("utf-8", {
12
12
  fatal: true,
13
13
  ignoreBOM: true,
@@ -78,7 +78,7 @@ export class ContentWorkspace {
78
78
  validateCurrentTreeContentPackage(files) {
79
79
  normalizeCurrentContentTreeFiles(files, this.#limits);
80
80
  }
81
- replaceCurrentTreeContentPackage(localId, files, options = {}) {
81
+ replaceCurrentTreeContentPackage(localId, files, options) {
82
82
  return this.#currentTreeLibrary.replaceCurrentTree(localId, files, options);
83
83
  }
84
84
  setCurrentTreeContentGameplayScheme(localId, scheme, options = {}) {
@@ -1,5 +1,5 @@
1
1
  import { PackageScriptPermissions } from "../extension/PackageScriptPermissions.js";
2
- import { randomUUID } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import { readFileSync, unlinkSync } from "node:fs";
4
4
  import { open, mkdir, readFile, readdir, rename, rm, stat, unlink, } from "node:fs/promises";
5
5
  import { linkExclusive } from "../FileNativeExclusiveLink.js";
@@ -31,6 +31,18 @@ export class CurrentTreeContentPackageBusyError extends CurrentTreeContentLibrar
31
31
  this.name = "CurrentTreeContentPackageBusyError";
32
32
  }
33
33
  }
34
+ /**
35
+ * The current tree changed after the caller's base revision. Nothing was
36
+ * written; `current` is the tree the caller must reload before saving again.
37
+ */
38
+ export class ContentTreeConflictError extends CurrentTreeContentLibraryError {
39
+ current;
40
+ constructor(current) {
41
+ super("The content package changed after this draft was opened; load the latest tree before saving");
42
+ this.name = "ContentTreeConflictError";
43
+ this.current = current;
44
+ }
45
+ }
34
46
  export class InvalidContentTreeError extends Error {
35
47
  constructor(message) {
36
48
  super(message);
@@ -216,12 +228,19 @@ export class CurrentTreeContentLibrary {
216
228
  }
217
229
  });
218
230
  }
219
- replaceCurrentTree(localId, files, options = {}) {
231
+ /**
232
+ * Replace the whole tree only if it is still the revision the draft was
233
+ * based on. The comparison runs under the package lease, so a publish that
234
+ * landed after the draft opened cannot be overwritten.
235
+ */
236
+ replaceCurrentTree(localId, files, options) {
220
237
  return this.#mutate(async () => {
221
238
  const lease = await this.acquireOperationLease(localId, {
222
239
  wait: options.waitForLease !== false,
223
240
  });
224
241
  try {
242
+ if (lease.package.treeRevision !== options.baseTreeRevision)
243
+ throw new ContentTreeConflictError(lease.package);
225
244
  return await lease.replace(files, options);
226
245
  }
227
246
  finally {
@@ -490,6 +509,7 @@ export class CurrentTreeContentLibrary {
490
509
  function detail(localId, title, files, inspection) {
491
510
  return {
492
511
  ...summary(localId, title, readGameplay(files), inspection),
512
+ treeRevision: treeRevisionOf(files),
493
513
  files: files.map((file) => ({ ...file })),
494
514
  documents: structuredClone(inspection.documents),
495
515
  issues: structuredClone(inspection.issues),
@@ -497,6 +517,18 @@ function detail(localId, title, files, inspection) {
497
517
  control: structuredClone(inspection.control),
498
518
  };
499
519
  }
520
+ /**
521
+ * Digest of every file's path, encoding and contents, independent of file
522
+ * order. Equal trees always share a revision; a tree that returns to earlier
523
+ * content is equal to it, so a draft based on that content loses nothing.
524
+ */
525
+ function treeRevisionOf(files) {
526
+ const hash = createHash("sha256");
527
+ for (const file of [...files].sort((left, right) => compareStrings(left.path, right.path))) {
528
+ hash.update(JSON.stringify([file.path, file.encoding ?? null, file.contents]));
529
+ }
530
+ return hash.digest("hex");
531
+ }
500
532
  function summary(localId, title, gameplay, inspection) {
501
533
  return {
502
534
  gameplay: { ...gameplay },