@remnic/core 9.6.27 → 9.6.29

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 (72) hide show
  1. package/dist/access-admin-ops-surface.d.ts +2 -2
  2. package/dist/access-admin-ops-surface.js +1 -1
  3. package/dist/access-boundary.d.ts +2 -2
  4. package/dist/access-boundary.js +2 -2
  5. package/dist/access-cli.js +9 -9
  6. package/dist/access-http.d.ts +2 -2
  7. package/dist/access-http.js +6 -6
  8. package/dist/access-identity-continuity-surface.d.ts +1 -1
  9. package/dist/access-identity-continuity-surface.js +1 -1
  10. package/dist/access-lcm-surface.d.ts +2 -2
  11. package/dist/access-lcm-surface.js +1 -1
  12. package/dist/access-mcp.d.ts +2 -2
  13. package/dist/access-mcp.js +5 -5
  14. package/dist/access-observe-write-surface.d.ts +2 -2
  15. package/dist/access-observe-write-surface.js +1 -1
  16. package/dist/access-operations-batch.js +3 -3
  17. package/dist/access-operations.d.ts +2 -2
  18. package/dist/access-operations.js +4 -4
  19. package/dist/access-recall-surface.d.ts +4 -3
  20. package/dist/access-recall-surface.js +1 -1
  21. package/dist/{access-service-DK7s3wMj.d.ts → access-service-BofDzaPT.d.ts} +3 -2
  22. package/dist/access-service.d.ts +2 -2
  23. package/dist/access-service.js +1 -1
  24. package/dist/access-surface-catalog.d.ts +2 -2
  25. package/dist/bootstrap.d.ts +1 -1
  26. package/dist/{chunk-43VNWTBB.js → chunk-3RROR5KV.js} +4 -4
  27. package/dist/chunk-3RROR5KV.js.map +1 -0
  28. package/dist/{chunk-Y4RKAQZ7.js → chunk-5AJIDEFT.js} +7 -1
  29. package/dist/chunk-5AJIDEFT.js.map +1 -0
  30. package/dist/{chunk-YDF4HL3W.js → chunk-5BHIGBHQ.js} +30 -37
  31. package/dist/chunk-5BHIGBHQ.js.map +1 -0
  32. package/dist/{chunk-XMNVHYI6.js → chunk-5WJ4LBFR.js} +107 -7
  33. package/dist/chunk-5WJ4LBFR.js.map +1 -0
  34. package/dist/{chunk-N6VIXDT7.js → chunk-C2SEH754.js} +4 -4
  35. package/dist/{chunk-MRQN5Q3I.js → chunk-EAREADWV.js} +2 -2
  36. package/dist/{chunk-GXLFDLX2.js → chunk-IF7THCGJ.js} +3 -3
  37. package/dist/{chunk-ARDRBJHD.js → chunk-SGYTQKC2.js} +3 -3
  38. package/dist/{chunk-QLYLTKUG.js → chunk-XA4PWKBW.js} +3 -3
  39. package/dist/{cli-CLSdmOVU.d.ts → cli-3_x5sJTL.d.ts} +2 -2
  40. package/dist/cli.d.ts +3 -3
  41. package/dist/cli.js +7 -7
  42. package/dist/explicit-capture.d.ts +1 -1
  43. package/dist/index.d.ts +4 -4
  44. package/dist/index.js +9 -9
  45. package/dist/lcm/engine.d.ts +10 -6
  46. package/dist/lcm/engine.js +1 -1
  47. package/dist/lcm/index.d.ts +1 -1
  48. package/dist/lcm/index.js +1 -1
  49. package/dist/mcp-memory-inspector-app.d.ts +2 -2
  50. package/dist/{orchestrator-BzXGNHco.d.ts → orchestrator-CAH5-PKN.d.ts} +20 -3
  51. package/dist/orchestrator.d.ts +1 -1
  52. package/dist/orchestrator.js +9 -9
  53. package/package.json +2 -2
  54. package/src/access-recall-surface.ts +46 -49
  55. package/src/access-service.ts +2 -7
  56. package/src/cli.ts +4 -10
  57. package/src/lcm/engine.ts +17 -3
  58. package/src/lcm/index.ts +6 -1
  59. package/src/lcm-engine.test.ts +131 -1
  60. package/src/orchestration/xray-capture-queue.test.ts +42 -0
  61. package/src/orchestration/xray-capture-queue.ts +78 -0
  62. package/src/orchestrator-xray-capture.test.ts +319 -0
  63. package/src/orchestrator.ts +76 -8
  64. package/dist/chunk-43VNWTBB.js.map +0 -1
  65. package/dist/chunk-XMNVHYI6.js.map +0 -1
  66. package/dist/chunk-Y4RKAQZ7.js.map +0 -1
  67. package/dist/chunk-YDF4HL3W.js.map +0 -1
  68. /package/dist/{chunk-N6VIXDT7.js.map → chunk-C2SEH754.js.map} +0 -0
  69. /package/dist/{chunk-MRQN5Q3I.js.map → chunk-EAREADWV.js.map} +0 -0
  70. /package/dist/{chunk-GXLFDLX2.js.map → chunk-IF7THCGJ.js.map} +0 -0
  71. /package/dist/{chunk-ARDRBJHD.js.map → chunk-SGYTQKC2.js.map} +0 -0
  72. /package/dist/{chunk-QLYLTKUG.js.map → chunk-XA4PWKBW.js.map} +0 -0
@@ -14,6 +14,7 @@
14
14
  import { createHash } from "node:crypto";
15
15
  import * as nodePath from "node:path";
16
16
  import { AccessAuditAdapter, type AccessAuditResult } from "./access-audit.js";
17
+ import { throwIfAborted } from "./abort-error.js";
17
18
  import { resolveNamespaceCapabilities } from "./capabilities.js";
18
19
  import { resolveCodingNamespaceOverlay } from "./coding/coding-namespace.js";
19
20
  import { type BudgetDecision, CrossNamespaceBudget } from "./cross-namespace-budget.js";
@@ -173,7 +174,6 @@ export interface AccessRecallSurfaceDeps {
173
174
  primaryNamespace: string,
174
175
  recallNamespaces?: readonly string[],
175
176
  ): Promise<{ storage: StorageManager; dir: string } | null>;
176
- xrayQueue: Promise<void>;
177
177
  }
178
178
 
179
179
  export class AccessRecallSurface {
@@ -1117,6 +1117,8 @@ export class AccessRecallSurface {
1117
1117
  * regular X-ray API/CLI/MCP surfaces keep their existing payload shape.
1118
1118
  */
1119
1119
  includeRecall?: boolean;
1120
+ /** Cancel the capture before it starts and propagate cancellation to recall. */
1121
+ abortSignal?: AbortSignal;
1120
1122
  }): Promise<{
1121
1123
  snapshotFound: boolean;
1122
1124
  snapshot?: RecallXraySnapshot;
@@ -1195,21 +1197,9 @@ export class AccessRecallSurface {
1195
1197
  const mode = this.deps.normalizeRecallMode(request.mode);
1196
1198
  const disclosure = request.disclosure ?? DEFAULT_RECALL_DISCLOSURE;
1197
1199
 
1198
- // Serialize x-ray invocations behind a per-service mutex so the
1199
- // per-process `getLastXraySnapshot()` slot cannot be clobbered by
1200
- // a concurrent capturing call before this caller reads it back.
1201
- // Budget and principal are now threaded through
1202
- // `RecallInvocationOptions`, so global config mutation is gone
1203
- // (CLAUDE.md rule 47: no shared mutable state across async
1204
- // boundaries). The mutex stays only for the snapshot-slot
1205
- // ordering guarantee.
1206
- const previousQueue = this.deps.xrayQueue;
1207
- let release: () => void = () => {};
1208
- this.deps.xrayQueue = new Promise<void>((resolve) => {
1209
- release = resolve;
1210
- });
1211
- await previousQueue;
1212
- const recallStartedAt = Date.now();
1200
+ // Reset when the orchestrator-owned snapshot lock is actually acquired so
1201
+ // queue wait is not misreported as recall latency.
1202
+ let recallStartedAt = Date.now();
1213
1203
 
1214
1204
  const recallSessionKey = request.sessionKey?.trim() || undefined;
1215
1205
  let xrayResponse: {
@@ -1217,37 +1207,46 @@ export class AccessRecallSurface {
1217
1207
  snapshot?: RecallXraySnapshot;
1218
1208
  } = { snapshotFound: false };
1219
1209
 
1220
- try {
1221
- // Clear any prior snapshot so a capture failure surfaces as
1222
- // `{snapshotFound: false}` rather than returning stale data
1223
- // from an earlier call on the same orchestrator.
1224
- this.deps.orchestrator.clearLastXraySnapshot();
1225
- await this.deps.orchestrator.recall(query, recallSessionKey, {
1226
- xrayCapture: true,
1227
- ...(requestedNamespace ? { namespace: requestedNamespace } : {}),
1228
- ...(budgetOverride !== undefined
1229
- ? { budgetCharsOverride: budgetOverride }
1230
- : {}),
1231
- ...(mode !== undefined ? { mode } : {}),
1232
- // When the caller supplies an authenticated principal, forward
1233
- // it via the dedicated override channel so orchestrator-side
1234
- // ACL decisions use the SAME principal the access-surface
1235
- // pre-check above authorized. Threading an
1236
- // `authenticatedPrincipal` through `sessionKey` would be wrong:
1237
- // `resolvePrincipal(sessionKey)` only maps configured raw
1238
- // session keys and otherwise collapses to `"default"`, which
1239
- // in namespace-enabled deployments produces false denials /
1240
- // wrong-scope serving despite the pre-check passing
1241
- // (CLAUDE.md rule 42).
1242
- ...(authenticatedPrincipal
1243
- ? { principalOverride: authenticatedPrincipal }
1244
- : {}),
1245
- ...(request.currentContextScopes !== undefined
1246
- ? { currentContextScopes: request.currentContextScopes }
1247
- : {}),
1248
- });
1249
-
1250
- const rawSnapshot = this.deps.orchestrator.getLastXraySnapshot();
1210
+ {
1211
+ // Capture through the orchestrator-owned critical section so every
1212
+ // consumer of its mutable snapshot slot shares the same queue. The
1213
+ // returned clone remains owned by this call after the lock is released.
1214
+ const {
1215
+ snapshot: rawSnapshot,
1216
+ recallStartedAt: capturedRecallStartedAt,
1217
+ } = await this.deps.orchestrator.recallWithXrayCapture(
1218
+ query,
1219
+ recallSessionKey,
1220
+ {
1221
+ ...(requestedNamespace ? { namespace: requestedNamespace } : {}),
1222
+ ...(budgetOverride !== undefined
1223
+ ? { budgetCharsOverride: budgetOverride }
1224
+ : {}),
1225
+ ...(mode !== undefined ? { mode } : {}),
1226
+ // When the caller supplies an authenticated principal, forward
1227
+ // it via the dedicated override channel so orchestrator-side
1228
+ // ACL decisions use the SAME principal the access-surface
1229
+ // pre-check above authorized. Threading an
1230
+ // `authenticatedPrincipal` through `sessionKey` would be wrong:
1231
+ // `resolvePrincipal(sessionKey)` only maps configured raw
1232
+ // session keys and otherwise collapses to `"default"`, which
1233
+ // in namespace-enabled deployments produces false denials /
1234
+ // wrong-scope serving despite the pre-check passing
1235
+ // (CLAUDE.md rule 42).
1236
+ ...(authenticatedPrincipal
1237
+ ? { principalOverride: authenticatedPrincipal }
1238
+ : {}),
1239
+ ...(request.currentContextScopes !== undefined
1240
+ ? { currentContextScopes: request.currentContextScopes }
1241
+ : {}),
1242
+ ...(request.abortSignal ? { abortSignal: request.abortSignal } : {}),
1243
+ },
1244
+ );
1245
+ recallStartedAt = capturedRecallStartedAt;
1246
+ // Cancellation covers queueing + recall/capture. Post-capture shaping is
1247
+ // intentionally not wired for mid-I/O abort in this change, but do not
1248
+ // begin that work after the caller has already canceled.
1249
+ throwIfAborted(request.abortSignal, "recall X-ray aborted before postprocessing");
1251
1250
  // Re-check namespace after capture: the recall may have served
1252
1251
  // from a different namespace than the caller requested. Drop
1253
1252
  // the snapshot rather than leak cross-tenant data (CLAUDE.md
@@ -1483,8 +1482,6 @@ export class AccessRecallSurface {
1483
1482
  };
1484
1483
  }
1485
1484
  }
1486
- } finally {
1487
- release();
1488
1485
  }
1489
1486
 
1490
1487
  if (
@@ -2917,6 +2917,8 @@ export class EngramAccessService {
2917
2917
  * regular X-ray API/CLI/MCP surfaces keep their existing payload shape.
2918
2918
  */
2919
2919
  includeRecall?: boolean;
2920
+ /** Cancel the capture before it starts and propagate cancellation to recall. */
2921
+ abortSignal?: AbortSignal;
2920
2922
  }): Promise<{
2921
2923
  snapshotFound: boolean;
2922
2924
  snapshot?: RecallXraySnapshot;
@@ -2926,12 +2928,6 @@ export class EngramAccessService {
2926
2928
  request,
2927
2929
  );
2928
2930
  }
2929
- // Sequence lock for `recallXray` — see comment inside the method.
2930
- // Lives on the instance so every x-ray call on the same service
2931
- // shares it, and so separate services in the same process (e.g.
2932
- // per-tenant) do not block each other.
2933
- private xrayQueue: Promise<void> = Promise.resolve();
2934
-
2935
2931
  async memoryStore(
2936
2932
  request: EngramAccessMemoryStoreRequest,
2937
2933
  hooks?: { enforceWriteQuota?: () => void | Promise<void> },
@@ -5906,4 +5902,3 @@ export class EngramAccessService {
5906
5902
  );
5907
5903
  }
5908
5904
  }
5909
-
package/src/cli.ts CHANGED
@@ -5756,16 +5756,10 @@ export function registerCli(
5756
5756
  args[0],
5757
5757
  (args[1] ?? {}) as Record<string, unknown>,
5758
5758
  );
5759
- // Route the xray capture through `EngramAccessService` so
5760
- // the CLI shares the same `xrayQueue` mutex that the HTTP
5761
- // and MCP surfaces use otherwise the
5762
- // `clearLastXraySnapshot() recall() → getLastXraySnapshot()`
5763
- // sequence races with concurrent callers (e.g., a gateway
5764
- // agent hitting the same orchestrator) and could swap in
5765
- // their snapshot mid-flight, or our capture could overwrite
5766
- // theirs (cursor Medium + codex P1 review on #597). The
5767
- // service enforces CLAUDE.md rules 40 (serialized state) and
5768
- // 47 (no shared mutable state across async boundaries).
5759
+ // Delegate through `EngramAccessService` to the orchestrator's atomic
5760
+ // capture API, so CLI, HTTP, and MCP callers sharing an orchestrator
5761
+ // also share one X-ray ordering domain and receive the snapshot owned
5762
+ // by their invocation.
5769
5763
  const xrayService = new EngramAccessService(orchestrator);
5770
5764
  const response = await xrayService.recallXray({
5771
5765
  query: parsed.query,
package/src/lcm/engine.ts CHANGED
@@ -23,6 +23,15 @@ export interface LcmEngineConfig {
23
23
  messagePartsRecallMaxResults: number;
24
24
  }
25
25
 
26
+ /** An expanded archive row with stable lineage identity; content may be budget-truncated. */
27
+ export interface LcmExpandedMessage {
28
+ id: number;
29
+ session_id: string;
30
+ turn_index: number;
31
+ role: string;
32
+ content: string;
33
+ }
34
+
26
35
  function positiveInteger(value: unknown, fallback: number, min = 1): number {
27
36
  if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
28
37
  return Math.max(min, Math.floor(value));
@@ -526,7 +535,7 @@ export class LcmEngine {
526
535
  fromTurn: number,
527
536
  toTurn: number,
528
537
  maxTokens: number,
529
- ): Promise<Array<{ turn_index: number; role: string; content: string }>> {
538
+ ): Promise<LcmExpandedMessage[]> {
530
539
  if (!this.config.enabled) return [];
531
540
  const normalizedSessionId = normalizeLcmSessionId(sessionId);
532
541
  if (!normalizedSessionId) return [];
@@ -542,6 +551,8 @@ export class LcmEngine {
542
551
 
543
552
  if (totalChars <= maxChars) {
544
553
  return messages.map((m) => ({
554
+ id: m.id,
555
+ session_id: m.session_id,
545
556
  turn_index: m.turn_index,
546
557
  role: m.role,
547
558
  content: m.content,
@@ -549,8 +560,7 @@ export class LcmEngine {
549
560
  }
550
561
 
551
562
  // Keep first and last messages, truncate from middle
552
- const result: Array<{ turn_index: number; role: string; content: string }> =
553
- [];
563
+ const result: LcmExpandedMessage[] = [];
554
564
  let budget = maxChars;
555
565
 
556
566
  // Reserve space for the last message
@@ -567,6 +577,8 @@ export class LcmEngine {
567
577
  const m = messages[i];
568
578
  const truncated = m.content.slice(0, budget);
569
579
  result.push({
580
+ id: m.id,
581
+ session_id: m.session_id,
570
582
  turn_index: m.turn_index,
571
583
  role: m.role,
572
584
  content: truncated,
@@ -576,6 +588,8 @@ export class LcmEngine {
576
588
 
577
589
  // Always append the last message
578
590
  result.push({
591
+ id: lastMsg.id,
592
+ session_id: lastMsg.session_id,
579
593
  turn_index: lastMsg.turn_index,
580
594
  role: lastMsg.role,
581
595
  content: lastMsg.content.slice(0, lastMsgChars + Math.max(0, budget)),
package/src/lcm/index.ts CHANGED
@@ -1,4 +1,9 @@
1
- export { LcmEngine, extractLcmConfig, type LcmEngineConfig } from "./engine.js";
1
+ export {
2
+ LcmEngine,
3
+ extractLcmConfig,
4
+ type LcmEngineConfig,
5
+ type LcmExpandedMessage,
6
+ } from "./engine.js";
2
7
  export { LcmArchive, estimateTokens } from "./archive.js";
3
8
  export { LcmDag, type SummaryNode } from "./dag.js";
4
9
  export { LcmSummarizer, type SummarizeFn } from "./summarizer.js";
@@ -4,7 +4,8 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import test from "node:test";
6
6
 
7
- import { extractLcmConfig, LcmEngine } from "./lcm/engine.js";
7
+ import { LcmArchive } from "./lcm/archive.js";
8
+ import { LcmEngine, extractLcmConfig } from "./lcm/engine.js";
8
9
  import { openLcmDatabase } from "./lcm/schema.js";
9
10
  import type { PluginConfig } from "./types.js";
10
11
 
@@ -546,6 +547,135 @@ test("LCM normalizes session IDs across observe, stats, and clear", async () =>
546
547
  }
547
548
  });
548
549
 
550
+ test("expandContext preserves distinct archive-row identity for duplicate turns", async () => {
551
+ const memoryDir = await mkdtemp(
552
+ path.join(os.tmpdir(), "engram-lcm-expand-identity-"),
553
+ );
554
+
555
+ try {
556
+ const engine = new LcmEngine(
557
+ createPluginConfig(memoryDir),
558
+ async () => "summary",
559
+ );
560
+ await engine.ensureInitialized();
561
+
562
+ const db = openLcmDatabase(memoryDir);
563
+ let expected: ReturnType<LcmArchive["getMessages"]>;
564
+ try {
565
+ const archive = new LcmArchive(db);
566
+ archive.appendMessages("session-expanded", [
567
+ { turnIndex: 7, role: "user", content: "duplicate turn user" },
568
+ {
569
+ turnIndex: 7,
570
+ role: "assistant",
571
+ content: "duplicate turn assistant",
572
+ },
573
+ ]);
574
+ expected = archive.getMessages("session-expanded", 7, 7);
575
+ } finally {
576
+ db.close();
577
+ }
578
+
579
+ assert.equal(expected.length, 2);
580
+ const [firstDuplicate, secondDuplicate] = expected;
581
+ assert.ok(firstDuplicate);
582
+ assert.ok(secondDuplicate);
583
+ assert.notEqual(firstDuplicate.id, secondDuplicate.id);
584
+
585
+ const expanded = await engine.expandContext(
586
+ " session-expanded ",
587
+ 7,
588
+ 7,
589
+ 100,
590
+ );
591
+
592
+ assert.deepEqual(
593
+ expanded,
594
+ expected.map(({ id, session_id, turn_index, role, content }) => ({
595
+ id,
596
+ session_id,
597
+ turn_index,
598
+ role,
599
+ content,
600
+ })),
601
+ );
602
+ assert.deepEqual(
603
+ expanded.map(({ session_id }) => session_id),
604
+ ["session-expanded", "session-expanded"],
605
+ );
606
+ } finally {
607
+ await rm(memoryDir, { recursive: true, force: true });
608
+ }
609
+ });
610
+
611
+ test("expandContext preserves first, middle, and last row identity when truncated", async () => {
612
+ const memoryDir = await mkdtemp(
613
+ path.join(os.tmpdir(), "engram-lcm-expand-truncated-identity-"),
614
+ );
615
+
616
+ try {
617
+ const engine = new LcmEngine(
618
+ createPluginConfig(memoryDir),
619
+ async () => "summary",
620
+ );
621
+ await engine.ensureInitialized();
622
+
623
+ const db = openLcmDatabase(memoryDir);
624
+ let archived: ReturnType<LcmArchive["getMessages"]>;
625
+ try {
626
+ const archive = new LcmArchive(db);
627
+ archive.appendMessages("session-truncated", [
628
+ { turnIndex: 1, role: "user", content: "AAAAAAAA" },
629
+ { turnIndex: 2, role: "assistant", content: "BBBBBBBB" },
630
+ { turnIndex: 3, role: "user", content: "CCCCCCCC" },
631
+ { turnIndex: 4, role: "assistant", content: "DDDDDDDD" },
632
+ ]);
633
+ archived = archive.getMessages("session-truncated", 1, 4);
634
+ } finally {
635
+ db.close();
636
+ }
637
+
638
+ assert.equal(archived.length, 4);
639
+ const [first, middle, , last] = archived;
640
+ assert.ok(first);
641
+ assert.ok(middle);
642
+ assert.ok(last);
643
+
644
+ const expanded = await engine.expandContext(
645
+ " session-truncated ",
646
+ 1,
647
+ 4,
648
+ 5,
649
+ );
650
+
651
+ assert.deepEqual(expanded, [
652
+ {
653
+ id: first.id,
654
+ session_id: "session-truncated",
655
+ turn_index: 1,
656
+ role: "user",
657
+ content: "AAAAAAAA",
658
+ },
659
+ {
660
+ id: middle.id,
661
+ session_id: "session-truncated",
662
+ turn_index: 2,
663
+ role: "assistant",
664
+ content: "BBBBBB",
665
+ },
666
+ {
667
+ id: last.id,
668
+ session_id: "session-truncated",
669
+ turn_index: 4,
670
+ role: "assistant",
671
+ content: "DDDDDD",
672
+ },
673
+ ]);
674
+ } finally {
675
+ await rm(memoryDir, { recursive: true, force: true });
676
+ }
677
+ });
678
+
549
679
  test("close prevents deferred observe work from reinitializing the engine", async () => {
550
680
  const memoryDir = await mkdtemp(
551
681
  path.join(os.tmpdir(), "engram-lcm-engine-close-"),
@@ -0,0 +1,42 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { XrayCaptureQueue } from "./xray-capture-queue.js";
5
+
6
+ test("initial snapshot read failure releases the next queued capture", async () => {
7
+ const queue = new XrayCaptureQueue();
8
+ let failNextRead = true;
9
+ let snapshot: string | null = "prior";
10
+ const operations: string[] = [];
11
+ const state = {
12
+ read(): string | null {
13
+ if (failNextRead) {
14
+ failNextRead = false;
15
+ throw new Error("snapshot clone failed");
16
+ }
17
+ return snapshot;
18
+ },
19
+ clear(): void {
20
+ snapshot = null;
21
+ },
22
+ restore(value: string | null): void {
23
+ snapshot = value;
24
+ },
25
+ };
26
+
27
+ const failed = queue.run(async () => {
28
+ operations.push("failed");
29
+ return "unexpected";
30
+ }, state);
31
+ const next = queue.run(async () => {
32
+ operations.push("next");
33
+ snapshot = "fresh";
34
+ return "next-result";
35
+ }, state);
36
+
37
+ await assert.rejects(failed, /snapshot clone failed/);
38
+ const result = await next;
39
+ assert.deepEqual(operations, ["next"]);
40
+ assert.equal(result.result, "next-result");
41
+ assert.equal(result.snapshot, "fresh");
42
+ });
@@ -0,0 +1,78 @@
1
+ import { abortError, throwIfAborted } from "../abort-error.js";
2
+
3
+ export interface AtomicCaptureState<TSnapshot> {
4
+ read(): TSnapshot | null;
5
+ clear(): void;
6
+ restore(snapshot: TSnapshot | null): void;
7
+ }
8
+
9
+ export interface AtomicCaptureResult<TResult, TSnapshot> {
10
+ result: TResult;
11
+ snapshot: TSnapshot | null;
12
+ recallStartedAt: number;
13
+ }
14
+
15
+ /** Per-owner abortable FIFO for operations that publish through one mutable slot. */
16
+ export class XrayCaptureQueue {
17
+ private tail: Promise<void> = Promise.resolve();
18
+
19
+ async run<TResult, TSnapshot>(
20
+ operation: () => Promise<TResult>,
21
+ state: AtomicCaptureState<TSnapshot>,
22
+ signal?: AbortSignal,
23
+ ): Promise<AtomicCaptureResult<TResult, TSnapshot>> {
24
+ throwIfAborted(signal, "x-ray capture aborted before queueing");
25
+ const previous = this.tail;
26
+ let release: () => void = () => {};
27
+ this.tail = new Promise<void>((resolve) => {
28
+ release = resolve;
29
+ });
30
+
31
+ try {
32
+ await this.waitForTurn(previous, signal);
33
+ } catch (error) {
34
+ // Keep this abandoned node as a barrier until its predecessor settles,
35
+ // so later callers cannot overtake the still-active operation.
36
+ void previous.then(release, release);
37
+ throw error;
38
+ }
39
+
40
+ let previousSnapshot: TSnapshot | null = null;
41
+ let didReadPreviousSnapshot = false;
42
+ try {
43
+ previousSnapshot = state.read();
44
+ didReadPreviousSnapshot = true;
45
+ const recallStartedAt = Date.now();
46
+ state.clear();
47
+ const result = await operation();
48
+ const snapshot = state.read();
49
+ if (!snapshot) state.restore(previousSnapshot);
50
+ return { result, snapshot, recallStartedAt };
51
+ } catch (error) {
52
+ if (didReadPreviousSnapshot) state.restore(previousSnapshot);
53
+ throw error;
54
+ } finally {
55
+ release();
56
+ }
57
+ }
58
+
59
+ private async waitForTurn(previous: Promise<void>, signal?: AbortSignal): Promise<void> {
60
+ throwIfAborted(signal, "x-ray capture aborted while queued");
61
+ if (!signal) {
62
+ await previous;
63
+ return;
64
+ }
65
+
66
+ let onAbort: (() => void) | undefined;
67
+ const aborted = new Promise<never>((_resolve, reject) => {
68
+ onAbort = () => reject(abortError("x-ray capture aborted while queued"));
69
+ signal.addEventListener("abort", onAbort, { once: true });
70
+ });
71
+ try {
72
+ await Promise.race([previous, aborted]);
73
+ throwIfAborted(signal, "x-ray capture aborted while queued");
74
+ } finally {
75
+ if (onAbort) signal.removeEventListener("abort", onAbort);
76
+ }
77
+ }
78
+ }