narraeon 0.7.0 → 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.
@@ -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 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);
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
- 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
- }
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
- 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
- }
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 persisted = currentContext === null
739
+ const live = currentContext === null
731
740
  ? null
732
- : await this.#worlds.playTimeline.readCurrent(request.worldId);
733
- const frozenBinding = persisted?.value.modelBinding;
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 ? (persisted?.value.transcript ?? []) : [],
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 [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);
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
- /** 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
+ }