@remnic/core 9.6.28 → 9.6.30

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 (106) hide show
  1. package/dist/access-admin-ops-surface.d.ts +2 -2
  2. package/dist/access-admin-ops-surface.js +2 -1
  3. package/dist/access-boundary.d.ts +2 -2
  4. package/dist/access-boundary.js +3 -2
  5. package/dist/access-cli.js +16 -15
  6. package/dist/access-cli.js.map +1 -1
  7. package/dist/access-http.d.ts +2 -2
  8. package/dist/access-http.js +7 -6
  9. package/dist/access-identity-continuity-surface.d.ts +1 -1
  10. package/dist/access-identity-continuity-surface.js +2 -1
  11. package/dist/access-lcm-surface.d.ts +2 -2
  12. package/dist/access-lcm-surface.js +2 -1
  13. package/dist/access-mcp.d.ts +2 -2
  14. package/dist/access-mcp.js +6 -5
  15. package/dist/access-observe-write-surface.d.ts +2 -2
  16. package/dist/access-observe-write-surface.js +2 -1
  17. package/dist/access-operations-batch.js +4 -3
  18. package/dist/access-operations.d.ts +2 -2
  19. package/dist/access-operations.js +5 -4
  20. package/dist/access-recall-surface.d.ts +4 -3
  21. package/dist/access-recall-surface.js +2 -1
  22. package/dist/{access-service-DK7s3wMj.d.ts → access-service-BofDzaPT.d.ts} +3 -2
  23. package/dist/access-service.d.ts +2 -2
  24. package/dist/access-service.js +2 -1
  25. package/dist/access-surface-catalog.d.ts +2 -2
  26. package/dist/bootstrap.d.ts +1 -1
  27. package/dist/{chunk-4273YO6I.js → chunk-3VSTTB74.js} +111 -11
  28. package/dist/chunk-3VSTTB74.js.map +1 -0
  29. package/dist/{chunk-4463LUIE.js → chunk-77UPM7P3.js} +22 -11
  30. package/dist/chunk-77UPM7P3.js.map +1 -0
  31. package/dist/{chunk-FV5FVO2J.js → chunk-7O2JL5RY.js} +9 -5
  32. package/dist/chunk-7O2JL5RY.js.map +1 -0
  33. package/dist/{chunk-LZTFCAKE.js → chunk-BFU7H32D.js} +28 -15
  34. package/dist/chunk-BFU7H32D.js.map +1 -0
  35. package/dist/chunk-C37ZRXX7.js +36 -0
  36. package/dist/chunk-C37ZRXX7.js.map +1 -0
  37. package/dist/{chunk-ARDRBJHD.js → chunk-C6TKSI35.js} +3 -3
  38. package/dist/{chunk-N6VIXDT7.js → chunk-DAAJUA6Q.js} +4 -4
  39. package/dist/{chunk-MRQN5Q3I.js → chunk-GQURC4B7.js} +2 -2
  40. package/dist/{chunk-4SBW47WK.js → chunk-IRMLYBMU.js} +28 -15
  41. package/dist/chunk-IRMLYBMU.js.map +1 -0
  42. package/dist/{chunk-QLYLTKUG.js → chunk-MXAGJHTA.js} +3 -3
  43. package/dist/{chunk-YDF4HL3W.js → chunk-OXVKR5OU.js} +35 -39
  44. package/dist/chunk-OXVKR5OU.js.map +1 -0
  45. package/dist/{chunk-GXLFDLX2.js → chunk-PUQZIS6A.js} +3 -3
  46. package/dist/{chunk-PYPOFEMK.js → chunk-R26WO46Z.js} +1 -1
  47. package/dist/chunk-R26WO46Z.js.map +1 -0
  48. package/dist/chunk-VDC6RZI4.js +1 -0
  49. package/dist/{chunk-43VNWTBB.js → chunk-VJRJTRYF.js} +4 -4
  50. package/dist/chunk-VJRJTRYF.js.map +1 -0
  51. package/dist/{chunk-L6W77GWW.js → chunk-XGSOXFD4.js} +25 -13
  52. package/dist/chunk-XGSOXFD4.js.map +1 -0
  53. package/dist/{cli-CLSdmOVU.d.ts → cli-3_x5sJTL.d.ts} +2 -2
  54. package/dist/cli.d.ts +3 -3
  55. package/dist/cli.js +8 -7
  56. package/dist/event-order-recall.js +3 -2
  57. package/dist/evidence-pack.d.ts +2 -0
  58. package/dist/evidence-pack.js +1 -1
  59. package/dist/explicit-capture.d.ts +1 -1
  60. package/dist/explicit-cue-recall.d.ts +3 -0
  61. package/dist/explicit-cue-recall.js +3 -2
  62. package/dist/focused-list-recall.js +3 -2
  63. package/dist/index.d.ts +4 -4
  64. package/dist/index.js +16 -15
  65. package/dist/lcm/index.d.ts +22 -0
  66. package/dist/lcm/index.js +9 -1
  67. package/dist/mcp-memory-inspector-app.d.ts +2 -2
  68. package/dist/{orchestrator-BzXGNHco.d.ts → orchestrator-CAH5-PKN.d.ts} +20 -3
  69. package/dist/orchestrator.d.ts +1 -1
  70. package/dist/orchestrator.js +16 -15
  71. package/dist/response-guidance-recall.js +3 -2
  72. package/dist/targeted-fact-recall.js +3 -2
  73. package/package.json +2 -2
  74. package/src/access-lcm-surface.ts +20 -5
  75. package/src/access-recall-surface.ts +48 -50
  76. package/src/access-service.ts +2 -7
  77. package/src/cli.ts +4 -10
  78. package/src/event-order-recall.ts +5 -3
  79. package/src/evidence-pack.ts +2 -0
  80. package/src/explicit-cue-recall.ts +67 -66
  81. package/src/focused-list-recall.ts +25 -16
  82. package/src/lcm/evidence-identity.ts +59 -0
  83. package/src/lcm/index.ts +7 -0
  84. package/src/lcm-retrieval-lineage.test.ts +236 -0
  85. package/src/orchestration/xray-capture-queue.test.ts +42 -0
  86. package/src/orchestration/xray-capture-queue.ts +78 -0
  87. package/src/orchestrator-xray-capture.test.ts +319 -0
  88. package/src/orchestrator.ts +76 -8
  89. package/src/response-guidance-recall.ts +22 -14
  90. package/src/targeted-fact-recall.ts +25 -16
  91. package/dist/chunk-4273YO6I.js.map +0 -1
  92. package/dist/chunk-43VNWTBB.js.map +0 -1
  93. package/dist/chunk-4463LUIE.js.map +0 -1
  94. package/dist/chunk-4SBW47WK.js.map +0 -1
  95. package/dist/chunk-FV5FVO2J.js.map +0 -1
  96. package/dist/chunk-HHLLAQGZ.js +0 -1
  97. package/dist/chunk-L6W77GWW.js.map +0 -1
  98. package/dist/chunk-LZTFCAKE.js.map +0 -1
  99. package/dist/chunk-PYPOFEMK.js.map +0 -1
  100. package/dist/chunk-YDF4HL3W.js.map +0 -1
  101. /package/dist/{chunk-ARDRBJHD.js.map → chunk-C6TKSI35.js.map} +0 -0
  102. /package/dist/{chunk-N6VIXDT7.js.map → chunk-DAAJUA6Q.js.map} +0 -0
  103. /package/dist/{chunk-MRQN5Q3I.js.map → chunk-GQURC4B7.js.map} +0 -0
  104. /package/dist/{chunk-QLYLTKUG.js.map → chunk-MXAGJHTA.js.map} +0 -0
  105. /package/dist/{chunk-GXLFDLX2.js.map → chunk-PUQZIS6A.js.map} +0 -0
  106. /package/dist/{chunk-HHLLAQGZ.js.map → chunk-VDC6RZI4.js.map} +0 -0
@@ -8,6 +8,7 @@ import {
8
8
  gatherAcrossReadSessions,
9
9
  resolveLcmReadSessionIds,
10
10
  } from "./lcm-fallback-read.js";
11
+ import { isSameLcmRow, lcmEvidenceIdentity } from "./lcm/evidence-identity.js";
11
12
 
12
13
  export interface FocusedListRecallOptions {
13
14
  engine: ExplicitCueRecallEngine | null | undefined;
@@ -134,27 +135,35 @@ async function collectFocusedListItems(
134
135
  result.turn_index + searchWindowAfter,
135
136
  searchWindowTokens,
136
137
  );
138
+ const searchIdentity = lcmEvidenceIdentity(result, result.session_id);
137
139
  const searchHit: EvidencePackItem = {
138
- id: `${result.session_id}:${result.turn_index}`,
140
+ ...searchIdentity,
139
141
  sessionId: result.session_id,
140
142
  turnIndex: result.turn_index,
141
143
  role: result.role,
142
144
  content: result.content,
143
145
  ...(typeof result.score === "number" ? { score: result.score } : {}),
144
146
  };
145
- const candidates: EvidencePackItem[] = expanded.map((message) => ({
146
- id: `${result.session_id}:${message.turn_index}`,
147
- sessionId: result.session_id,
148
- turnIndex: message.turn_index,
149
- role: message.role,
150
- content: message.content,
151
- ...(message.turn_index === result.turn_index &&
152
- typeof result.score === "number"
153
- ? { score: result.score }
154
- : {}),
155
- }));
156
- const hitIndex = candidates.findIndex((candidate) =>
157
- candidate.turnIndex === result.turn_index
147
+ const candidates: EvidencePackItem[] = expanded.map((message) => {
148
+ const matchesSearchHit = isSameLcmRow(
149
+ message,
150
+ result.session_id,
151
+ result,
152
+ result.session_id,
153
+ );
154
+ return {
155
+ ...lcmEvidenceIdentity(message, result.session_id),
156
+ sessionId: message.session_id ?? result.session_id,
157
+ turnIndex: message.turn_index,
158
+ role: message.role,
159
+ content: message.content,
160
+ ...(matchesSearchHit && typeof result.score === "number"
161
+ ? { score: result.score }
162
+ : {}),
163
+ };
164
+ });
165
+ const hitIndex = expanded.findIndex((message) =>
166
+ isSameLcmRow(message, result.session_id, result, result.session_id)
158
167
  );
159
168
  if (hitIndex >= 0) {
160
169
  candidates[hitIndex] = searchHit;
@@ -221,8 +230,8 @@ async function collectFocusedListScanItems(
221
230
  );
222
231
  for (const message of messages) {
223
232
  const candidate = {
224
- id: `${options.sessionId}:${message.turn_index}`,
225
- sessionId: options.sessionId,
233
+ ...lcmEvidenceIdentity(message, options.sessionId),
234
+ sessionId: message.session_id ?? options.sessionId,
226
235
  turnIndex: message.turn_index,
227
236
  role: message.role,
228
237
  content: message.content,
@@ -0,0 +1,59 @@
1
+ export interface LcmRowIdentity {
2
+ id?: number;
3
+ session_id?: string;
4
+ turn_index: number;
5
+ }
6
+
7
+ export interface LcmEvidenceIdentity {
8
+ id: string;
9
+ archiveRowId?: number;
10
+ }
11
+
12
+ export function lcmArchiveRowId(row: Pick<LcmRowIdentity, "id">): number | undefined {
13
+ const value = row.id;
14
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0
15
+ ? value
16
+ : undefined;
17
+ }
18
+
19
+ function resolveSessionId(row: LcmRowIdentity, fallbackSessionId: string): string {
20
+ return row.session_id ?? fallbackSessionId;
21
+ }
22
+
23
+ /**
24
+ * Build the non-rendered evidence identity for an LCM archive row.
25
+ *
26
+ * Older adapters and test doubles may omit the SQLite row id. Those callers
27
+ * retain the historical session+turn identity until they adopt row lineage.
28
+ */
29
+ export function lcmEvidenceIdentity(
30
+ row: LcmRowIdentity,
31
+ fallbackSessionId: string,
32
+ ): LcmEvidenceIdentity {
33
+ const sessionId = resolveSessionId(row, fallbackSessionId);
34
+ const archiveRowId = lcmArchiveRowId(row);
35
+ if (archiveRowId === undefined) {
36
+ return { id: `${sessionId}:${row.turn_index}` };
37
+ }
38
+ return {
39
+ id: JSON.stringify(["lcm-row", sessionId, archiveRowId]),
40
+ archiveRowId,
41
+ };
42
+ }
43
+
44
+ /** Match a search hit to its expanded archive row without conflating siblings. */
45
+ export function isSameLcmRow(
46
+ left: LcmRowIdentity,
47
+ leftFallbackSessionId: string,
48
+ right: LcmRowIdentity,
49
+ rightFallbackSessionId: string,
50
+ ): boolean {
51
+ const leftRowId = lcmArchiveRowId(left);
52
+ const rightRowId = lcmArchiveRowId(right);
53
+ const leftSessionId = resolveSessionId(left, leftFallbackSessionId);
54
+ const rightSessionId = resolveSessionId(right, rightFallbackSessionId);
55
+ if (leftRowId !== undefined && rightRowId !== undefined) {
56
+ return leftRowId === rightRowId && leftSessionId === rightSessionId;
57
+ }
58
+ return leftSessionId === rightSessionId && left.turn_index === right.turn_index;
59
+ }
package/src/lcm/index.ts CHANGED
@@ -4,6 +4,13 @@ export {
4
4
  type LcmEngineConfig,
5
5
  type LcmExpandedMessage,
6
6
  } from "./engine.js";
7
+ export {
8
+ isSameLcmRow,
9
+ lcmArchiveRowId,
10
+ lcmEvidenceIdentity,
11
+ type LcmEvidenceIdentity,
12
+ type LcmRowIdentity,
13
+ } from "./evidence-identity.js";
7
14
  export { LcmArchive, estimateTokens } from "./archive.js";
8
15
  export { LcmDag, type SummaryNode } from "./dag.js";
9
16
  export { LcmSummarizer, type SummarizeFn } from "./summarizer.js";
@@ -0,0 +1,236 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { AccessLcmSurface, type AccessLcmSurfaceDeps } from "./access-lcm-surface.js";
5
+ import {
6
+ AccessRecallSurface,
7
+ type AccessRecallSurfaceDeps,
8
+ } from "./access-recall-surface.js";
9
+ import { buildEventOrderRecallSection } from "./event-order-recall.js";
10
+ import { buildExplicitCueRecallSection, type ExplicitCueRecallEngine } from "./explicit-cue-recall.js";
11
+ import { buildFocusedListRecallSection } from "./focused-list-recall.js";
12
+ import {
13
+ isSameLcmRow,
14
+ lcmEvidenceIdentity,
15
+ } from "./lcm/evidence-identity.js";
16
+ import { buildResponseGuidanceRecallSection } from "./response-guidance-recall.js";
17
+ import { buildTargetedFactRecallSection } from "./targeted-fact-recall.js";
18
+
19
+ interface TestRow {
20
+ id?: number;
21
+ session_id: string;
22
+ turn_index: number;
23
+ role: string;
24
+ content: string;
25
+ }
26
+
27
+ class LineageEngine implements ExplicitCueRecallEngine {
28
+ constructor(
29
+ private readonly rows: TestRow[],
30
+ private readonly searchRowId?: number,
31
+ private readonly searchContent?: string,
32
+ ) {}
33
+
34
+ async expandContext(
35
+ sessionId: string,
36
+ fromTurn: number,
37
+ toTurn: number,
38
+ _maxTokens: number,
39
+ ): Promise<TestRow[]> {
40
+ return this.rows.filter((row) =>
41
+ row.session_id === sessionId &&
42
+ row.turn_index >= fromTurn &&
43
+ row.turn_index <= toTurn
44
+ );
45
+ }
46
+
47
+ async searchContextFull(): Promise<
48
+ Array<TestRow & { score: number }>
49
+ > {
50
+ const row = this.rows.find((candidate) => candidate.id === this.searchRowId);
51
+ return row
52
+ ? [{
53
+ ...row,
54
+ content: this.searchContent ?? row.content,
55
+ score: 99,
56
+ }]
57
+ : [];
58
+ }
59
+
60
+ async getStats(sessionId?: string): Promise<{
61
+ totalMessages: number;
62
+ maxTurnIndex?: number;
63
+ }> {
64
+ const rows = sessionId
65
+ ? this.rows.filter((row) => row.session_id === sessionId)
66
+ : this.rows;
67
+ return {
68
+ totalMessages: rows.length,
69
+ maxTurnIndex: rows.length > 0
70
+ ? Math.max(...rows.map((row) => row.turn_index))
71
+ : undefined,
72
+ };
73
+ }
74
+ }
75
+
76
+ test("LCM evidence identity distinguishes sibling rows and preserves the legacy fallback", () => {
77
+ const first = { id: 41, session_id: "session", turn_index: 7 };
78
+ const second = { id: 42, session_id: "session", turn_index: 7 };
79
+ assert.notEqual(
80
+ lcmEvidenceIdentity(first, "session").id,
81
+ lcmEvidenceIdentity(second, "session").id,
82
+ );
83
+ assert.equal(isSameLcmRow(first, "session", second, "session"), false);
84
+ assert.equal(
85
+ lcmEvidenceIdentity({ turn_index: 7 }, "session").id,
86
+ "session:7",
87
+ );
88
+ assert.equal(
89
+ isSameLcmRow(
90
+ { turn_index: 7 },
91
+ "session",
92
+ { turn_index: 7 },
93
+ "session",
94
+ ),
95
+ true,
96
+ );
97
+ });
98
+
99
+ test("explicit-cue recall keeps distinct archive rows that share a turn", async () => {
100
+ const engine = new LineageEngine([
101
+ { id: 101, session_id: "s", turn_index: 7, role: "user", content: "First row at the referenced turn." },
102
+ { id: 102, session_id: "s", turn_index: 7, role: "assistant", content: "Second row at the referenced turn." },
103
+ ]);
104
+ const recalled = await buildExplicitCueRecallSection({
105
+ engine,
106
+ sessionId: "s",
107
+ query: "Review turn 7",
108
+ maxChars: 4_000,
109
+ });
110
+ assert.match(recalled, /First row at the referenced turn/);
111
+ assert.match(recalled, /Second row at the referenced turn/);
112
+ });
113
+
114
+ test("targeted recall replaces and scores only the matching same-turn row", async () => {
115
+ const engine = new LineageEngine([
116
+ { id: 101, session_id: "s", turn_index: 7, role: "user", content: "I earn approximately $100,000 CAD annually as a senior engineer." },
117
+ { id: 102, session_id: "s", turn_index: 7, role: "user", content: "My recent raise to $110,000 CAD as a senior engineer at Saint Pierre Manufacturing Ltd is now effective." },
118
+ ], 102, "The matching row records my recent raise to $110,000 CAD as a senior engineer at Saint Pierre Manufacturing Ltd.");
119
+ const recalled = await buildTargetedFactRecallSection({
120
+ engine,
121
+ sessionId: "s",
122
+ query: "What is my annual salary as a senior engineer at Saint Pierre Manufacturing Ltd?",
123
+ maxChars: 6_000,
124
+ });
125
+ assert.match(recalled, /earn approximately \$100,000 CAD annually/);
126
+ assert.match(recalled, /matching row records my recent raise to \$110,000 CAD/);
127
+ });
128
+
129
+ test("focused-list recall keeps distinct same-turn calculations while matching the search row", async () => {
130
+ const engine = new LineageEngine([
131
+ { id: 101, session_id: "s", turn_index: 7, role: "user", content: "Can you help me calculate P(both heads) = 1/2 x 1/2 = 1/4 so I can make sure I get it right?" },
132
+ { id: 102, session_id: "s", turn_index: 7, role: "user", content: "I am trying to verify if P(rolling a number greater than 4) = 2/6 = 1/3 is correct." },
133
+ ], 102, "I confirmed P(rolling a number greater than 4) = 2/6 = 1/3.");
134
+ const recalled = await buildFocusedListRecallSection({
135
+ engine,
136
+ sessionId: "s",
137
+ query: "In my questions about tossing coins and rolling dice, how many different probability calculations did I try to confirm?",
138
+ maxChars: 6_000,
139
+ });
140
+ assert.match(recalled, /P\(both heads\)/);
141
+ assert.match(recalled, /P\(rolling a number greater than 4\)/);
142
+ });
143
+
144
+ test("response guidance keeps distinct same-turn rows while preserving its existing filters", async () => {
145
+ const engine = new LineageEngine([
146
+ { id: 101, session_id: "s", turn_index: 7, role: "user", content: "I prefer a structured daily routine with a written priority list each morning." },
147
+ { id: 102, session_id: "s", turn_index: 7, role: "user", content: "My structured daily routine schedules focused work blocks and buffer time between meetings." },
148
+ ], 102, "My structured daily routine schedules focused work blocks and buffer time between meetings.");
149
+ const recalled = await buildResponseGuidanceRecallSection({
150
+ engine,
151
+ sessionId: "s",
152
+ query: "How should I organize my day to stay on track?",
153
+ maxChars: 6_000,
154
+ });
155
+ assert.match(recalled, /written priority list each morning/);
156
+ assert.match(recalled, /focused work blocks/);
157
+ });
158
+
159
+ test("event-order recall keeps distinct same-turn chronological rows", async () => {
160
+ const engine = new LineageEngine([
161
+ { id: 101, session_id: "s", turn_index: 7, role: "user", content: "Patrick suggested a March workshop on workflow optimization." },
162
+ { id: 102, session_id: "s", turn_index: 7, role: "user", content: "Patrick and I planned an interview tips meeting for tomorrow." },
163
+ ]);
164
+ const recalled = await buildEventOrderRecallSection({
165
+ engine,
166
+ sessionId: "s",
167
+ query: "Walk me through my interactions with Patrick in order.",
168
+ maxChars: 6_000,
169
+ });
170
+ assert.match(recalled, /workshop suggestion/);
171
+ assert.match(recalled, /interview tips meeting/);
172
+ });
173
+
174
+ function accessSearchDeps(rows: TestRow[]): AccessLcmSurfaceDeps {
175
+ const orchestrator = {
176
+ config: {
177
+ defaultNamespace: "default",
178
+ namespaces: false,
179
+ },
180
+ lcmEngine: {
181
+ enabled: true,
182
+ searchContextFull: async () => rows,
183
+ },
184
+ };
185
+ return {
186
+ orchestrator,
187
+ lcmSessionIdsForNamespaces: () => ["primary", "fallback"],
188
+ resolveImplicitLcmReadFallbackNamespace: () => "default",
189
+ resolveLcmReadNamespace: () => "default",
190
+ resolveLcmReadSessionIds: () => ["primary", "fallback"],
191
+ resolveLcmReadSessionKey: () => "primary",
192
+ resolveReadableNamespace: () => "default",
193
+ resolveRequestPrincipal: () => "default",
194
+ resolveScopeProfileLcmReadNamespaces: () => null,
195
+ } as unknown as AccessLcmSurfaceDeps;
196
+ }
197
+
198
+ test("access LCM search dedupes repeated rows but retains same-turn siblings", async () => {
199
+ const rows: TestRow[] = [
200
+ { id: 101, session_id: "s", turn_index: 7, role: "user", content: "first sibling" },
201
+ { id: 102, session_id: "s", turn_index: 7, role: "assistant", content: "second sibling" },
202
+ ];
203
+ const response = await new AccessLcmSurface(accessSearchDeps(rows)).lcmSearch({
204
+ query: "sibling",
205
+ sessionKey: "s",
206
+ });
207
+ assert.deepEqual(response.results.map((row) => row.content), [
208
+ "first sibling",
209
+ "second sibling",
210
+ ]);
211
+ });
212
+
213
+ test("raw excerpts dedupe repeated rows but retain same-turn siblings", async () => {
214
+ const rows: TestRow[] = [
215
+ { id: 101, session_id: "s", turn_index: 7, role: "user", content: "first sibling" },
216
+ { id: 102, session_id: "s", turn_index: 7, role: "assistant", content: "second sibling" },
217
+ ];
218
+ const deps = {
219
+ orchestrator: {
220
+ config: { defaultNamespace: "default" },
221
+ lcmEngine: {
222
+ enabled: true,
223
+ searchContextFull: async () => rows,
224
+ },
225
+ },
226
+ } as unknown as AccessRecallSurfaceDeps;
227
+ const excerpts = await new AccessRecallSurface(deps).fetchRawExcerpts("raw", {
228
+ query: "sibling",
229
+ sessionKey: "s",
230
+ lcmSessionIds: ["primary", "fallback"],
231
+ });
232
+ assert.deepEqual(excerpts?.map((row) => row.content), [
233
+ "first sibling",
234
+ "second sibling",
235
+ ]);
236
+ });
@@ -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
+ }