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.
- package/dist/node/protocol/v1.js +8 -2
- package/dist/node/runtime/V1Runtime.js +104 -71
- package/dist/node/runtime/archive/WorldArchives.js +17 -5
- package/dist/node/runtime/artifact/FileNativeArtifactStore.js +59 -12
- package/dist/node/runtime/config/dataRootCleanup.js +52 -0
- package/dist/node/runtime/content/ContentWorkspace.js +2 -2
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +34 -2
- package/dist/node/runtime/model/FileNativeModelAdapters.js +106 -18
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +70 -3
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +236 -26
- package/dist/node/runtime/play/PlayCallChain.js +172 -54
- package/dist/node/runtime/play/PlayPromptRun.js +157 -3
- package/dist/node/runtime/play/PlayTraceRetention.js +2 -2
- package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +10 -0
- package/dist/node/runtime/world/FileNativeWorldStore.js +11 -4
- package/dist/node/runtime/world/WorldLedgerAuthority.js +13 -10
- package/dist/node/runtime/world/WorldOperationCoordinator.js +4 -0
- package/dist/node/runtime/world/WorldQueue.js +14 -1
- package/dist/node/runtime/world/WorldTurnLedger.js +48 -20
- package/dist/node/server/createServer.js +44 -15
- package/dist/node/server/startWebServer.js +2 -5
- 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/node/runtime/config/dataRootLock.js +0 -108
- package/dist/web/assets/index-BUCslLEK.css +0 -1
- package/dist/web/assets/index-DkamBH6c.js +0 -1408
package/dist/node/protocol/v1.js
CHANGED
|
@@ -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
|
-
|
|
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": {
|
|
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
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
extensionControls.revision)
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
|
748
|
+
const live = currentContext === null
|
|
731
749
|
? null
|
|
732
|
-
: await this.#
|
|
733
|
-
const frozenBinding =
|
|
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 ? (
|
|
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
|
|
850
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 },
|