narraeon 0.7.1 → 0.7.2
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/runtime/V1Runtime.js +93 -69
- package/dist/node/runtime/artifact/FileNativeArtifactStore.js +59 -12
- package/dist/node/runtime/config/dataRootCleanup.js +52 -0
- 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 +5 -0
- package/dist/node/runtime/world/FileNativeWorldStore.js +10 -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 +3 -1
- package/dist/node/server/startWebServer.js +2 -5
- package/package.json +1 -1
- package/dist/node/runtime/config/dataRootLock.js +0 -108
|
@@ -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" ||
|
|
@@ -415,9 +420,12 @@ export class V1Runtime {
|
|
|
415
420
|
: await this.#playPresets.bindRevision(request.presetId, request.revision);
|
|
416
421
|
if (request.worldId === undefined)
|
|
417
422
|
return buildPlayPresetWorkbenchSnapshot(binding, undefined, request.draft);
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
423
|
+
const { worldId } = request;
|
|
424
|
+
return this.#readingCurrentEndpoint(async () => {
|
|
425
|
+
const head = await this.#worlds.currentHead(worldId);
|
|
426
|
+
const playerViews = await this.#worlds.renderPlayerViewsAtHead(worldId, head);
|
|
427
|
+
return buildPlayPresetWorkbenchSnapshot(binding, { worldId, head, playerViews }, request.draft);
|
|
428
|
+
});
|
|
421
429
|
}
|
|
422
430
|
case "prompt.preview": {
|
|
423
431
|
const [package_, connection, playPreset] = await Promise.all([
|
|
@@ -509,36 +517,37 @@ export class V1Runtime {
|
|
|
509
517
|
return this.worldArchives.exportWorld(request);
|
|
510
518
|
case "world.archive.import":
|
|
511
519
|
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
|
-
|
|
520
|
+
case "world.read":
|
|
521
|
+
return this.#readingCurrentEndpoint(async () => {
|
|
522
|
+
const head = await this.#worlds.currentHead(request.worldId);
|
|
523
|
+
const playerViews = await this.#worlds.renderPlayerViewsAtHead(request.worldId, head);
|
|
524
|
+
const extensionControls = await this.#currentExtensionControls(request.worldId);
|
|
525
|
+
const [playerViewPanels, playTimeline, revisionEpoch] = await Promise.all([
|
|
526
|
+
this.#frontendPlayerViewPanels(request.worldId, head, playerViews, extensionControls),
|
|
527
|
+
this.#worlds.playTimeline.readPage(request.worldId, 40),
|
|
528
|
+
this.#worldRevisions.activeEpoch(request.worldId),
|
|
529
|
+
]);
|
|
530
|
+
return {
|
|
531
|
+
worldId: request.worldId,
|
|
532
|
+
head,
|
|
533
|
+
state: [],
|
|
534
|
+
control: [],
|
|
535
|
+
history: [],
|
|
536
|
+
runtime: { head, surfaces: "lazy" },
|
|
537
|
+
playerViews,
|
|
538
|
+
committedMessages: [],
|
|
539
|
+
artifacts: [],
|
|
540
|
+
extensions: [],
|
|
541
|
+
playerViewPanels,
|
|
542
|
+
extensionControls,
|
|
543
|
+
suppressedPlayerViewIds: await this.#suppressedPlayerViewIds(extensionControls),
|
|
544
|
+
artifactDebug: [],
|
|
545
|
+
playCallChain: null,
|
|
546
|
+
playTimeline,
|
|
547
|
+
worldRevision: revisionEpoch,
|
|
548
|
+
archiveNeedsFreshContext: await this.#worlds.archiveNeedsFreshContext(request.worldId),
|
|
549
|
+
};
|
|
550
|
+
});
|
|
542
551
|
case "world.surface.read":
|
|
543
552
|
return request.surface === "runtime"
|
|
544
553
|
? this.#worlds.readSurface(request.worldId, "runtime")
|
|
@@ -549,33 +558,33 @@ export class V1Runtime {
|
|
|
549
558
|
const result = await this.#worlds.extensionControls(request.worldId, await this.#playPresets.bindCurrent(), this.#locale, request);
|
|
550
559
|
return result;
|
|
551
560
|
}
|
|
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
|
-
|
|
561
|
+
case "world.play-decorations.read":
|
|
562
|
+
return this.#readingCurrentEndpoint(async () => {
|
|
563
|
+
for (;;) {
|
|
564
|
+
const extensionControls = await this.#currentExtensionControls(request.worldId);
|
|
565
|
+
const head = await this.#reconcileArtifacts(request.worldId);
|
|
566
|
+
const [projection, artifactDebug] = await Promise.all([
|
|
567
|
+
this.#frontendDecorations(request.worldId),
|
|
568
|
+
this.#frontendDebug(request.worldId),
|
|
569
|
+
]);
|
|
570
|
+
const playerViews = await this.#worlds.renderPlayerViewsAtHead(request.worldId, head);
|
|
571
|
+
const playerViewPanels = await this.#frontendPlayerViewPanels(request.worldId, head, playerViews, extensionControls);
|
|
572
|
+
const suppressedPlayerViewIds = await this.#suppressedPlayerViewIds(extensionControls);
|
|
573
|
+
// Never attach a newer controls revision to a projection read before
|
|
574
|
+
// that choice was accepted. Concurrent toggles restart this read.
|
|
575
|
+
if ((await this.#worlds.extensionControlsRevision(request.worldId)) !== extensionControls.revision)
|
|
576
|
+
continue;
|
|
577
|
+
return {
|
|
578
|
+
head,
|
|
579
|
+
...projection,
|
|
580
|
+
artifactDebug,
|
|
581
|
+
playerViews,
|
|
582
|
+
playerViewPanels,
|
|
583
|
+
extensionControls,
|
|
584
|
+
suppressedPlayerViewIds,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
});
|
|
579
588
|
case "artifacts.read":
|
|
580
589
|
await this.#reconcileArtifacts(request.worldId);
|
|
581
590
|
return (await this.#frontendDecorations(request.worldId, request.channel)).artifacts;
|
|
@@ -727,10 +736,10 @@ export class V1Runtime {
|
|
|
727
736
|
nextFreshContext: null,
|
|
728
737
|
};
|
|
729
738
|
const { modelHost, hostBinding, playPreset, modelBinding } = await this.#continuousBinding();
|
|
730
|
-
const
|
|
739
|
+
const live = currentContext === null
|
|
731
740
|
? null
|
|
732
|
-
: await this.#
|
|
733
|
-
const frozenBinding =
|
|
741
|
+
: await this.#playCallChains.currentContinuation(request.worldId);
|
|
742
|
+
const frozenBinding = live?.modelBinding;
|
|
734
743
|
const continueContext = currentContext !== null &&
|
|
735
744
|
!currentContext.stale &&
|
|
736
745
|
frozenBinding?.provider === modelBinding.provider;
|
|
@@ -768,7 +777,7 @@ export class V1Runtime {
|
|
|
768
777
|
toolUniverse: tools,
|
|
769
778
|
allowedTools: tools.map(({ name }) => name),
|
|
770
779
|
toolStrategy: preview.compilation.toolStrategy,
|
|
771
|
-
appended: continueContext ? (
|
|
780
|
+
appended: continueContext ? (live?.transcript ?? []) : [],
|
|
772
781
|
operationId: continueContext
|
|
773
782
|
? currentContext.chainId
|
|
774
783
|
: "next-fresh-context-preview",
|
|
@@ -845,12 +854,27 @@ export class V1Runtime {
|
|
|
845
854
|
async #matchingModelHost(binding) {
|
|
846
855
|
return new FileNativeModelHost(await this.#models.bindMatching(binding), fetch, this.#failureLog);
|
|
847
856
|
}
|
|
857
|
+
/**
|
|
858
|
+
* Reads that take the current endpoint first and then read at it (ADR-0044):
|
|
859
|
+
* a rewind published in between discards that endpoint, so the read starts
|
|
860
|
+
* again from the new current endpoint instead of failing.
|
|
861
|
+
*/
|
|
862
|
+
async #readingCurrentEndpoint(read) {
|
|
863
|
+
for (let attempt = 1;; attempt += 1) {
|
|
864
|
+
try {
|
|
865
|
+
return await read();
|
|
866
|
+
}
|
|
867
|
+
catch (error) {
|
|
868
|
+
if (!(error instanceof FileNativeWorldCreationError &&
|
|
869
|
+
error.code === "endpoint_discarded") ||
|
|
870
|
+
attempt >= currentEndpointReadAttempts)
|
|
871
|
+
throw error;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
848
875
|
async #reconcileArtifacts(worldId) {
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
this.#worlds.currentHeadOperationId(worldId),
|
|
852
|
-
]);
|
|
853
|
-
await this.#artifacts.reconcileHead(worldId, head, currentOperationId ?? undefined);
|
|
876
|
+
const { head, operationId } = await this.#worlds.currentEndpoint(worldId);
|
|
877
|
+
await this.#artifacts.reconcileHead(worldId, head, operationId ?? undefined);
|
|
854
878
|
return head;
|
|
855
879
|
}
|
|
856
880
|
async #frontendDecorations(worldId, channel) {
|
|
@@ -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
|
+
}
|