@duckcodeailabs/dql-agent 1.14.2 → 1.14.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.
Files changed (196) hide show
  1. package/dist/agent-run-engine.d.ts +239 -4
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +2924 -85
  4. package/dist/agent-run-engine.js.map +1 -1
  5. package/dist/agent-run-gates.d.ts.map +1 -1
  6. package/dist/agent-run-gates.js +51 -3
  7. package/dist/agent-run-gates.js.map +1 -1
  8. package/dist/agent-run-planner.d.ts +6 -0
  9. package/dist/agent-run-planner.d.ts.map +1 -1
  10. package/dist/agent-run-planner.js +2 -0
  11. package/dist/agent-run-planner.js.map +1 -1
  12. package/dist/agent-run-store.d.ts.map +1 -1
  13. package/dist/agent-run-store.js +591 -11
  14. package/dist/agent-run-store.js.map +1 -1
  15. package/dist/agentic/analyst-loop.d.ts +9 -1
  16. package/dist/agentic/analyst-loop.d.ts.map +1 -1
  17. package/dist/agentic/analyst-loop.js +204 -4
  18. package/dist/agentic/analyst-loop.js.map +1 -1
  19. package/dist/agentic/assumptions.d.ts +1 -1
  20. package/dist/agentic/assumptions.d.ts.map +1 -1
  21. package/dist/agentic/assumptions.js.map +1 -1
  22. package/dist/agentic/ledger-tools.d.ts.map +1 -1
  23. package/dist/agentic/ledger-tools.js +8 -1
  24. package/dist/agentic/ledger-tools.js.map +1 -1
  25. package/dist/agentic/orchestrator-policy.d.ts +3 -1
  26. package/dist/agentic/orchestrator-policy.d.ts.map +1 -1
  27. package/dist/agentic/orchestrator-policy.js +1 -1
  28. package/dist/agentic/orchestrator-policy.js.map +1 -1
  29. package/dist/agentic/research-agent.d.ts.map +1 -1
  30. package/dist/agentic/research-agent.js +10 -1
  31. package/dist/agentic/research-agent.js.map +1 -1
  32. package/dist/agentic/sql-authorization.d.ts +8 -0
  33. package/dist/agentic/sql-authorization.d.ts.map +1 -1
  34. package/dist/agentic/sql-authorization.js +3 -0
  35. package/dist/agentic/sql-authorization.js.map +1 -1
  36. package/dist/agentic/tool-loop.d.ts +13 -1
  37. package/dist/agentic/tool-loop.d.ts.map +1 -1
  38. package/dist/agentic/tool-loop.js +441 -32
  39. package/dist/agentic/tool-loop.js.map +1 -1
  40. package/dist/analytical-compatibility.d.ts.map +1 -1
  41. package/dist/analytical-compatibility.js +8 -1
  42. package/dist/analytical-compatibility.js.map +1 -1
  43. package/dist/analytical-failure-repair.d.ts.map +1 -1
  44. package/dist/analytical-failure-repair.js +6 -2
  45. package/dist/analytical-failure-repair.js.map +1 -1
  46. package/dist/analytical-frame.d.ts +42 -1
  47. package/dist/analytical-frame.d.ts.map +1 -1
  48. package/dist/analytical-frame.js +490 -26
  49. package/dist/analytical-frame.js.map +1 -1
  50. package/dist/analytical-orchestration.d.ts +1259 -9
  51. package/dist/analytical-orchestration.d.ts.map +1 -1
  52. package/dist/analytical-orchestration.js +970 -84
  53. package/dist/analytical-orchestration.js.map +1 -1
  54. package/dist/answer-loop.d.ts +230 -10
  55. package/dist/answer-loop.d.ts.map +1 -1
  56. package/dist/answer-loop.js +1182 -85
  57. package/dist/answer-loop.js.map +1 -1
  58. package/dist/answer-shape.d.ts +27 -0
  59. package/dist/answer-shape.d.ts.map +1 -1
  60. package/dist/answer-shape.js +55 -0
  61. package/dist/answer-shape.js.map +1 -1
  62. package/dist/ask-observability/index.d.ts +11 -0
  63. package/dist/ask-observability/index.d.ts.map +1 -0
  64. package/dist/ask-observability/index.js +7 -0
  65. package/dist/ask-observability/index.js.map +1 -0
  66. package/dist/ask-observability/instrumentation.d.ts +35 -0
  67. package/dist/ask-observability/instrumentation.d.ts.map +1 -0
  68. package/dist/ask-observability/instrumentation.js +523 -0
  69. package/dist/ask-observability/instrumentation.js.map +1 -0
  70. package/dist/ask-observability/observer.d.ts +62 -0
  71. package/dist/ask-observability/observer.d.ts.map +1 -0
  72. package/dist/ask-observability/observer.js +537 -0
  73. package/dist/ask-observability/observer.js.map +1 -0
  74. package/dist/ask-observability/portable.d.ts +98 -0
  75. package/dist/ask-observability/portable.d.ts.map +1 -0
  76. package/dist/ask-observability/portable.js +1089 -0
  77. package/dist/ask-observability/portable.js.map +1 -0
  78. package/dist/ask-observability/store.d.ts +130 -0
  79. package/dist/ask-observability/store.d.ts.map +1 -0
  80. package/dist/ask-observability/store.js +1114 -0
  81. package/dist/ask-observability/store.js.map +1 -0
  82. package/dist/ask-observability/types.d.ts +380 -0
  83. package/dist/ask-observability/types.d.ts.map +1 -0
  84. package/dist/ask-observability/types.js +2 -0
  85. package/dist/ask-observability/types.js.map +1 -0
  86. package/dist/ask-observability/utils.d.ts +21 -0
  87. package/dist/ask-observability/utils.d.ts.map +1 -0
  88. package/dist/ask-observability/utils.js +102 -0
  89. package/dist/ask-observability/utils.js.map +1 -0
  90. package/dist/ask-runtime/ask-agent-runtime-v2.d.ts +850 -0
  91. package/dist/ask-runtime/ask-agent-runtime-v2.d.ts.map +1 -0
  92. package/dist/ask-runtime/ask-agent-runtime-v2.js +1716 -0
  93. package/dist/ask-runtime/ask-agent-runtime-v2.js.map +1 -0
  94. package/dist/ask-runtime/ask-analyst-runtime.d.ts +95 -0
  95. package/dist/ask-runtime/ask-analyst-runtime.d.ts.map +1 -0
  96. package/dist/ask-runtime/ask-analyst-runtime.js +8280 -0
  97. package/dist/ask-runtime/ask-analyst-runtime.js.map +1 -0
  98. package/dist/conversation/session-store.d.ts +31 -0
  99. package/dist/conversation/session-store.d.ts.map +1 -1
  100. package/dist/conversation/session-store.js +38 -0
  101. package/dist/conversation/session-store.js.map +1 -1
  102. package/dist/conversation/snapshot.d.ts +4 -1
  103. package/dist/conversation/snapshot.d.ts.map +1 -1
  104. package/dist/conversation/snapshot.js +85 -3
  105. package/dist/conversation/snapshot.js.map +1 -1
  106. package/dist/conversation/turn-trust.d.ts.map +1 -1
  107. package/dist/conversation/turn-trust.js +2 -0
  108. package/dist/conversation/turn-trust.js.map +1 -1
  109. package/dist/embeddings/provider.d.ts +1 -0
  110. package/dist/embeddings/provider.d.ts.map +1 -1
  111. package/dist/embeddings/provider.js +18 -0
  112. package/dist/embeddings/provider.js.map +1 -1
  113. package/dist/generated-analytical-proposal.d.ts +37 -1
  114. package/dist/generated-analytical-proposal.d.ts.map +1 -1
  115. package/dist/generated-analytical-proposal.js +246 -3
  116. package/dist/generated-analytical-proposal.js.map +1 -1
  117. package/dist/governed-relational-compiler.d.ts +15 -0
  118. package/dist/governed-relational-compiler.d.ts.map +1 -1
  119. package/dist/governed-relational-compiler.js +99 -1
  120. package/dist/governed-relational-compiler.js.map +1 -1
  121. package/dist/index.d.ts +21 -12
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/index.js +14 -7
  124. package/dist/index.js.map +1 -1
  125. package/dist/intent-controller.d.ts +87 -1
  126. package/dist/intent-controller.d.ts.map +1 -1
  127. package/dist/intent-controller.js +2 -2
  128. package/dist/intent-controller.js.map +1 -1
  129. package/dist/kg/build.js +32 -2
  130. package/dist/kg/build.js.map +1 -1
  131. package/dist/meaning-resolution.d.ts +207 -6
  132. package/dist/meaning-resolution.d.ts.map +1 -1
  133. package/dist/meaning-resolution.js +439 -53
  134. package/dist/meaning-resolution.js.map +1 -1
  135. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  136. package/dist/metadata/analysis-planner.js +78 -7
  137. package/dist/metadata/analysis-planner.js.map +1 -1
  138. package/dist/metadata/catalog.d.ts +14 -0
  139. package/dist/metadata/catalog.d.ts.map +1 -1
  140. package/dist/metadata/catalog.js +29 -2
  141. package/dist/metadata/catalog.js.map +1 -1
  142. package/dist/metadata/meaning-evidence.d.ts +28 -2
  143. package/dist/metadata/meaning-evidence.d.ts.map +1 -1
  144. package/dist/metadata/meaning-evidence.js +373 -16
  145. package/dist/metadata/meaning-evidence.js.map +1 -1
  146. package/dist/plan-execution-adapter.d.ts +9 -1
  147. package/dist/plan-execution-adapter.d.ts.map +1 -1
  148. package/dist/plan-execution-adapter.js +93 -13
  149. package/dist/plan-execution-adapter.js.map +1 -1
  150. package/dist/provider-egress.d.ts +28 -11
  151. package/dist/provider-egress.d.ts.map +1 -1
  152. package/dist/provider-egress.js +72 -22
  153. package/dist/provider-egress.js.map +1 -1
  154. package/dist/providers/claude.d.ts +2 -2
  155. package/dist/providers/claude.d.ts.map +1 -1
  156. package/dist/providers/claude.js +201 -37
  157. package/dist/providers/claude.js.map +1 -1
  158. package/dist/providers/dispatch.d.ts +27 -1
  159. package/dist/providers/dispatch.d.ts.map +1 -1
  160. package/dist/providers/dispatch.js +63 -1
  161. package/dist/providers/dispatch.js.map +1 -1
  162. package/dist/providers/gemini.js +10 -10
  163. package/dist/providers/gemini.js.map +1 -1
  164. package/dist/providers/index.d.ts +2 -2
  165. package/dist/providers/index.d.ts.map +1 -1
  166. package/dist/providers/index.js +1 -1
  167. package/dist/providers/index.js.map +1 -1
  168. package/dist/providers/ollama.js +15 -15
  169. package/dist/providers/ollama.js.map +1 -1
  170. package/dist/providers/openai.d.ts +2 -2
  171. package/dist/providers/openai.d.ts.map +1 -1
  172. package/dist/providers/openai.js +213 -43
  173. package/dist/providers/openai.js.map +1 -1
  174. package/dist/providers/types.d.ts +131 -1
  175. package/dist/providers/types.d.ts.map +1 -1
  176. package/dist/providers/types.js.map +1 -1
  177. package/dist/relationship-proof.d.ts +81 -0
  178. package/dist/relationship-proof.d.ts.map +1 -1
  179. package/dist/relationship-proof.js +228 -4
  180. package/dist/relationship-proof.js.map +1 -1
  181. package/dist/requirement-clauses.d.ts +23 -0
  182. package/dist/requirement-clauses.d.ts.map +1 -0
  183. package/dist/requirement-clauses.js +70 -0
  184. package/dist/requirement-clauses.js.map +1 -0
  185. package/dist/resolved-analytical-plan.d.ts +18 -4
  186. package/dist/resolved-analytical-plan.d.ts.map +1 -1
  187. package/dist/resolved-analytical-plan.js +953 -50
  188. package/dist/resolved-analytical-plan.js.map +1 -1
  189. package/dist/router.d.ts +118 -2
  190. package/dist/router.d.ts.map +1 -1
  191. package/dist/router.js +2481 -232
  192. package/dist/router.js.map +1 -1
  193. package/dist/semantic-bridge/compose.d.ts.map +1 -1
  194. package/dist/semantic-bridge/compose.js +23 -4
  195. package/dist/semantic-bridge/compose.js.map +1 -1
  196. package/package.json +4 -4
@@ -0,0 +1,1114 @@
1
+ import Database from 'better-sqlite3';
2
+ import { existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { ASK_TRACE_SCHEMA_VERSION } from './types.js';
5
+ import { assertSafeTraceValue, durationMs, parseCursor, stableCursor } from './utils.js';
6
+ export const DEFAULT_ASK_TRACE_DETAIL_LIMIT = 500;
7
+ export const DEFAULT_ASK_TRACE_SUMMARY_LIMIT = 2_000;
8
+ export const DEFAULT_ASK_TRACE_DETAIL_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000;
9
+ export const DEFAULT_ASK_TRACE_SUMMARY_MAX_AGE_MS = 180 * 24 * 60 * 60 * 1_000;
10
+ export const DEFAULT_ASK_TRACE_MAX_DB_BYTES = 256 * 1024 * 1024;
11
+ export const DEFAULT_ASK_TRACE_MAX_DETAIL_BYTES = 1 * 1024 * 1024;
12
+ export const DEFAULT_ASK_TRACE_MAX_SPANS = 512;
13
+ export const DEFAULT_ASK_TRACE_MAX_CANDIDATES = 1_024;
14
+ /**
15
+ * Bounded producer limits. The observer enforces detail/candidate/span caps
16
+ * synchronously so a rejected record cannot perturb Ask; adapters may use
17
+ * these public bounds for their own batching without widening persistence.
18
+ */
19
+ export const DEFAULT_ASK_TRACE_QUEUE_CAP = 4_096;
20
+ export const DEFAULT_ASK_TRACE_FLUSH_BATCH = 128;
21
+ export const DEFAULT_ASK_TRACE_FLUSH_INTERVAL_MS = 25;
22
+ /**
23
+ * Separate, local SQLite store. Its failures are intentionally surfaced as a
24
+ * status to the observer/API, never thrown into Ask execution.
25
+ */
26
+ export class AskTraceSqliteStoreV1 {
27
+ db;
28
+ options;
29
+ unavailableReason;
30
+ queue = [];
31
+ queuedByKey = new Map();
32
+ accounting = new Map();
33
+ /** Drops waiting to be folded into the next local persistence transaction. */
34
+ deferredDrops = new Map();
35
+ /** Lifetime drop counts remain available after a batch has persisted them. */
36
+ droppedTotals = new Map();
37
+ /** Final receipts join their final detail batch in one SQLite transaction. */
38
+ pendingFinalizations = new Map();
39
+ /** Refreshed only at lifecycle/flush boundaries, never on an Ask producer call. */
40
+ databaseAtCap = false;
41
+ flushTimer;
42
+ prepared;
43
+ /** Reused multi-row statements remove one native boundary per recorded span. */
44
+ spanBatchStatements = new Map();
45
+ candidateBatchStatements = new Map();
46
+ linkBatchStatements = new Map();
47
+ /** Cached at open/migration so observer creation does not issue a pragma. */
48
+ schemaVersion = 0;
49
+ /** Completed persisted rows, refreshed after retention or at store open. */
50
+ detailedTraceCount = 0;
51
+ summaryTraceCount = 0;
52
+ liveTraceIds = new Set();
53
+ /** Age retention is checked at lifecycle boundaries, not for every Ask. */
54
+ nextRetentionCheckAt = 0;
55
+ constructor(options) {
56
+ this.options = {
57
+ path: options.path,
58
+ readOnly: options.readOnly ?? false,
59
+ maxDetailedTraces: options.maxDetailedTraces ?? DEFAULT_ASK_TRACE_DETAIL_LIMIT,
60
+ maxSummaryTraces: options.maxSummaryTraces ?? DEFAULT_ASK_TRACE_SUMMARY_LIMIT,
61
+ detailMaxAgeMs: options.detailMaxAgeMs ?? DEFAULT_ASK_TRACE_DETAIL_MAX_AGE_MS,
62
+ summaryMaxAgeMs: options.summaryMaxAgeMs ?? DEFAULT_ASK_TRACE_SUMMARY_MAX_AGE_MS,
63
+ maxDbBytes: options.maxDbBytes ?? DEFAULT_ASK_TRACE_MAX_DB_BYTES,
64
+ maxTraceDetailBytes: options.maxTraceDetailBytes ?? DEFAULT_ASK_TRACE_MAX_DETAIL_BYTES,
65
+ maxSpansPerTrace: options.maxSpansPerTrace ?? DEFAULT_ASK_TRACE_MAX_SPANS,
66
+ maxCandidateDecisionsPerTrace: options.maxCandidateDecisionsPerTrace ?? DEFAULT_ASK_TRACE_MAX_CANDIDATES,
67
+ queueCap: options.queueCap ?? DEFAULT_ASK_TRACE_QUEUE_CAP,
68
+ flushBatchSize: options.flushBatchSize ?? DEFAULT_ASK_TRACE_FLUSH_BATCH,
69
+ flushIntervalMs: options.flushIntervalMs ?? DEFAULT_ASK_TRACE_FLUSH_INTERVAL_MS,
70
+ busyTimeoutMs: options.busyTimeoutMs ?? 250,
71
+ };
72
+ this.open();
73
+ }
74
+ status() {
75
+ return {
76
+ available: Boolean(this.db),
77
+ schemaVersion: this.schemaVersion,
78
+ recordingEnabled: Boolean(this.db) && !this.options.readOnly,
79
+ ...(this.options.readOnly ? { readOnly: true } : {}),
80
+ ...(this.unavailableReason ? { reason: this.unavailableReason } : {}),
81
+ };
82
+ }
83
+ begin(envelope) {
84
+ if (!this.db || this.options.readOnly)
85
+ return { accepted: false, dropped: 'unavailable' };
86
+ try {
87
+ if (this.databaseAtCap)
88
+ return { accepted: false, dropped: 'store_cap' };
89
+ assertSafeTraceValue({
90
+ traceId: envelope.traceId,
91
+ runId: envelope.runId,
92
+ surface: envelope.surface,
93
+ mode: envelope.mode,
94
+ threadId: envelope.threadId,
95
+ snapshotId: envelope.snapshotId,
96
+ questionFingerprint: envelope.questionFingerprint,
97
+ });
98
+ // A running trace must have a portable root even if the process exits
99
+ // before the ordinary 25ms queue gets its first chance to flush. The
100
+ // observer immediately updates this same span with its live handle, but
101
+ // this minimal root is committed atomically with trace admission.
102
+ const durableEnvelope = {
103
+ ...envelope,
104
+ spanCount: Math.max(1, envelope.spanCount),
105
+ };
106
+ const root = durableRootSpan(durableEnvelope);
107
+ const rootPayload = storageJson(root.payload);
108
+ const rootBytes = Buffer.byteLength(rootPayload, 'utf8') + 256;
109
+ const inserted = this.db.transaction(() => {
110
+ const insert = this.prepared.insertTrace.run(durableEnvelope.traceId, durableEnvelope.rootSpanId, durableEnvelope.runId, durableEnvelope.surface, durableEnvelope.mode, durableEnvelope.threadId ?? null, durableEnvelope.snapshotId ?? null, durableEnvelope.questionFingerprint, durableEnvelope.status, durableEnvelope.recordingStatus, durableEnvelope.trustState ?? null, durableEnvelope.selectedTier ?? null, durableEnvelope.startedAt, durableEnvelope.parentTraceId ?? null, durableEnvelope.parentRunId ?? null, rootBytes, storageJson(durableEnvelope), durableEnvelope.startedAt);
111
+ if (insert.changes === 0)
112
+ return false;
113
+ this.prepared.insertRootSpan.run(root.traceId, root.spanId, root.parentSpanId ?? null, root.ordinal, root.name, root.stage, root.startedAt, null, null, root.outcome, root.reasonCode, rootPayload);
114
+ return true;
115
+ })();
116
+ if (inserted) {
117
+ this.liveTraceIds.add(envelope.traceId);
118
+ this.accounting.set(envelope.traceId, {
119
+ loadedFromDatabase: false,
120
+ spanCount: 1,
121
+ candidateDecisionCount: 0,
122
+ detailBytes: rootBytes,
123
+ spanBytes: new Map([[`span:${durableEnvelope.traceId}:${durableEnvelope.rootSpanId}`, rootBytes]]),
124
+ candidateBytes: new Map(),
125
+ linkBytes: 0,
126
+ });
127
+ }
128
+ else {
129
+ // A repeated begin (for example a harmless replay) must retain the
130
+ // already-persisted accounting rather than resetting caps to zero.
131
+ this.accounting.delete(envelope.traceId);
132
+ }
133
+ return { accepted: true };
134
+ }
135
+ catch {
136
+ this.trip();
137
+ return { accepted: false, dropped: 'unsafe_payload' };
138
+ }
139
+ }
140
+ appendSpan(span) {
141
+ if (!this.db || this.options.readOnly)
142
+ return { accepted: false, dropped: 'unavailable' };
143
+ try {
144
+ const account = this.accountFor(span.traceId);
145
+ if (!account)
146
+ return { accepted: false, dropped: 'unavailable' };
147
+ if (this.databaseAtCap)
148
+ return this.drop(span.traceId, 'store_cap');
149
+ const key = `span:${span.traceId}:${span.spanId}`;
150
+ const queued = this.queuedByKey.get(key);
151
+ if (!queued && this.queue.length >= this.options.queueCap)
152
+ return this.drop(span.traceId, 'queue_cap');
153
+ // A start/finish pair normally has one immutable typed payload. Its
154
+ // first append stores a checked JSON snapshot; retaining that snapshot
155
+ // for the queued finish avoids validating/serializing the same evidence
156
+ // twice while preventing a later caller mutation from reaching SQLite.
157
+ const reuseSerialized = queued?.kind === 'span' && queued.value.payload === span.payload;
158
+ if (!reuseSerialized)
159
+ assertSafeTraceValue(span.payload);
160
+ const serialized = reuseSerialized ? queued.serialized : storageJson(span.payload);
161
+ const bytes = Buffer.byteLength(serialized, 'utf8') + 256;
162
+ const known = account.spanBytes.get(key);
163
+ let previousBytes = known ?? 0;
164
+ let incremented = false;
165
+ if (known === undefined) {
166
+ if (account.spanCount >= this.options.maxSpansPerTrace)
167
+ return this.drop(span.traceId, 'span_cap');
168
+ account.spanCount += 1;
169
+ incremented = true;
170
+ account.detailBytes += bytes - previousBytes;
171
+ }
172
+ else {
173
+ account.detailBytes += bytes - known;
174
+ }
175
+ if (account.detailBytes > this.options.maxTraceDetailBytes) {
176
+ account.detailBytes -= bytes - previousBytes;
177
+ if (incremented)
178
+ account.spanCount -= 1;
179
+ return this.drop(span.traceId, 'detail_cap');
180
+ }
181
+ account.spanBytes.set(key, bytes);
182
+ return this.enqueue({ kind: 'span', key, value: span, serialized }, span.traceId);
183
+ }
184
+ catch {
185
+ // Unsafe input is rejected locally. It must not poison the unrelated
186
+ // trace store or alter the Ask result.
187
+ return { accepted: false, dropped: 'unsafe_payload' };
188
+ }
189
+ }
190
+ appendCandidate(candidate) {
191
+ if (!this.db || this.options.readOnly)
192
+ return { accepted: false, dropped: 'unavailable' };
193
+ try {
194
+ assertSafeTraceValue(candidate);
195
+ const account = this.accountFor(candidate.traceId);
196
+ if (!account)
197
+ return { accepted: false, dropped: 'unavailable' };
198
+ if (this.databaseAtCap)
199
+ return this.drop(candidate.traceId, 'store_cap');
200
+ const key = `candidate:${candidate.traceId}:${candidate.sequence}`;
201
+ if (!this.queuedByKey.has(key) && this.queue.length >= this.options.queueCap)
202
+ return this.drop(candidate.traceId, 'queue_cap');
203
+ const serialized = storageJson(candidate);
204
+ const bytes = Buffer.byteLength(serialized, 'utf8') + 96;
205
+ const known = account.candidateBytes.get(key);
206
+ let previousBytes = known ?? 0;
207
+ let incremented = false;
208
+ if (known === undefined) {
209
+ if (account.candidateDecisionCount >= this.options.maxCandidateDecisionsPerTrace)
210
+ return this.drop(candidate.traceId, 'candidate_cap');
211
+ account.candidateDecisionCount += 1;
212
+ incremented = true;
213
+ account.detailBytes += bytes - previousBytes;
214
+ }
215
+ else {
216
+ account.detailBytes += bytes - known;
217
+ }
218
+ if (account.detailBytes > this.options.maxTraceDetailBytes) {
219
+ account.detailBytes -= bytes - previousBytes;
220
+ if (incremented)
221
+ account.candidateDecisionCount -= 1;
222
+ return this.drop(candidate.traceId, 'detail_cap');
223
+ }
224
+ account.candidateBytes.set(key, bytes);
225
+ return this.enqueue({ kind: 'candidate', key, value: candidate, serialized }, candidate.traceId);
226
+ }
227
+ catch {
228
+ return { accepted: false, dropped: 'unsafe_payload' };
229
+ }
230
+ }
231
+ appendLink(link) {
232
+ if (!this.db || this.options.readOnly)
233
+ return { accepted: false, dropped: 'unavailable' };
234
+ try {
235
+ assertSafeTraceValue(link);
236
+ const traceId = link.sourceTraceId;
237
+ const account = this.accountFor(traceId);
238
+ if (!account)
239
+ return { accepted: false, dropped: 'unavailable' };
240
+ const key = `link:${traceId}:${link.createdAt}:${this.queue.length}`;
241
+ if (this.queue.length >= this.options.queueCap)
242
+ return this.drop(traceId, 'queue_cap');
243
+ const serialized = storageJson(link);
244
+ const bytes = Buffer.byteLength(serialized, 'utf8') + 96;
245
+ if (account.detailBytes + bytes > this.options.maxTraceDetailBytes)
246
+ return this.drop(traceId, 'detail_cap');
247
+ account.detailBytes += bytes;
248
+ account.linkBytes += bytes;
249
+ return this.enqueue({ kind: 'link', key, value: link, serialized }, traceId);
250
+ }
251
+ catch {
252
+ return { accepted: false, dropped: 'unsafe_payload' };
253
+ }
254
+ }
255
+ finalize(envelope) {
256
+ if (!this.db || this.options.readOnly)
257
+ return { accepted: false, dropped: 'unavailable' };
258
+ try {
259
+ assertSafeTraceValue({
260
+ status: envelope.status,
261
+ terminalOutcome: envelope.terminalOutcome,
262
+ recordingStatus: envelope.recordingStatus,
263
+ trustState: envelope.trustState,
264
+ selectedTier: envelope.selectedTier,
265
+ traceFingerprint: envelope.traceFingerprint,
266
+ });
267
+ const completedAt = envelope.completedAt ?? new Date().toISOString();
268
+ // Do not flush detail and then issue a second autocommit UPDATE for the
269
+ // same Ask. A final receipt joins its last bounded detail batch in one
270
+ // transaction. This preserves the contract that finalization durably
271
+ // persists every accepted record before returning, while avoiding a
272
+ // redundant WAL/fsync boundary on the normal short Ask path.
273
+ this.pendingFinalizations.set(envelope.traceId, {
274
+ envelope: { ...envelope, completedAt },
275
+ });
276
+ this.flushNow();
277
+ const accepted = Boolean(this.db) && !this.pendingFinalizations.has(envelope.traceId);
278
+ if (accepted)
279
+ this.maybeEnforceRetention();
280
+ return accepted ? { accepted: true } : { accepted: false, dropped: 'unavailable' };
281
+ }
282
+ catch {
283
+ this.trip();
284
+ return { accepted: false, dropped: 'unavailable' };
285
+ }
286
+ }
287
+ markPartial(traceId, extraDropped = 1) {
288
+ if (!this.db || this.options.readOnly)
289
+ return;
290
+ this.recordDrop(traceId, Math.max(1, extraDropped));
291
+ }
292
+ get(traceId) {
293
+ if (!this.db)
294
+ return undefined;
295
+ this.flushNow();
296
+ const row = this.db.prepare('SELECT * FROM ask_traces WHERE trace_id = ?').get(traceId);
297
+ return row ? this.dataForRow(row) : undefined;
298
+ }
299
+ getByRun(runId) {
300
+ if (!this.db)
301
+ return undefined;
302
+ this.flushNow();
303
+ const row = this.db.prepare('SELECT * FROM ask_traces WHERE run_id = ?').get(runId);
304
+ return row ? this.dataForRow(row) : undefined;
305
+ }
306
+ list(input = {}) {
307
+ if (!this.db)
308
+ return { traces: [] };
309
+ this.flushNow();
310
+ const limit = Math.max(1, Math.min(100, Math.floor(input.limit ?? 50)));
311
+ const cursor = parseCursor(input.cursor);
312
+ const filterConditions = [];
313
+ const filterParameters = [];
314
+ if (input.status) {
315
+ filterConditions.push('status = ?');
316
+ filterParameters.push(input.status);
317
+ }
318
+ if (input.mode) {
319
+ filterConditions.push('mode = ?');
320
+ filterParameters.push(input.mode);
321
+ }
322
+ if (input.trustState) {
323
+ filterConditions.push('trust_state = ?');
324
+ filterParameters.push(input.trustState);
325
+ }
326
+ if (input.selectedTier) {
327
+ filterConditions.push('selected_tier = ?');
328
+ filterParameters.push(input.selectedTier);
329
+ }
330
+ if (input.surface) {
331
+ filterConditions.push('surface = ?');
332
+ filterParameters.push(input.surface);
333
+ }
334
+ if (input.recordingStatus) {
335
+ filterConditions.push('recording_status = ?');
336
+ filterParameters.push(input.recordingStatus);
337
+ }
338
+ const conditions = [...filterConditions];
339
+ const parameters = [...filterParameters];
340
+ if (cursor) {
341
+ conditions.push('(started_at < ? OR (started_at = ? AND trace_id < ?))');
342
+ parameters.push(cursor.startedAt, cursor.startedAt, cursor.traceId);
343
+ }
344
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
345
+ const rows = this.db.prepare(`
346
+ SELECT * FROM ask_traces ${where}
347
+ ORDER BY started_at DESC, trace_id DESC LIMIT ?
348
+ `).all(...parameters, limit + 1);
349
+ const filterWhere = filterConditions.length ? `WHERE ${filterConditions.join(' AND ')}` : '';
350
+ const total = this.db.prepare(`SELECT COUNT(*) AS count FROM ask_traces ${filterWhere}`)
351
+ .get(...filterParameters);
352
+ const page = rows.slice(0, limit).map((row) => this.summaryForRow(row));
353
+ const last = page.at(-1);
354
+ return {
355
+ traces: page,
356
+ total: Math.max(0, Number(total?.count ?? 0)),
357
+ ...(rows.length > limit && last ? { nextCursor: stableCursor({ startedAt: last.startedAt, traceId: last.traceId }) } : {}),
358
+ };
359
+ }
360
+ recordExportReceipt(traceId, receipt) {
361
+ if (!this.db || this.options.readOnly)
362
+ return;
363
+ try {
364
+ this.prepared.insertExportReceipt.run(traceId, receipt.bundleFingerprint, receipt.profile, receipt.exportedAt, storageJson(receipt));
365
+ }
366
+ catch {
367
+ this.trip();
368
+ }
369
+ }
370
+ /** Number of writes waiting for the bounded local persistence batch. */
371
+ pendingWriteCount() {
372
+ return this.queue.length + this.pendingFinalizations.size;
373
+ }
374
+ /** Deferred drops are folded into the final compact trace receipt. */
375
+ droppedRecordCount(traceId) {
376
+ return this.droppedTotals.get(traceId) ?? 0;
377
+ }
378
+ /**
379
+ * Flush queued trace detail synchronously at a lifecycle/read boundary. Ask
380
+ * event production itself remains batched by `enqueue`; this method exists so
381
+ * a finalized run, CLI inspection, or shutdown never leaves a half-written
382
+ * local receipt behind.
383
+ */
384
+ flushNow() {
385
+ if (!this.db || this.options.readOnly)
386
+ return;
387
+ if (this.flushTimer)
388
+ clearTimeout(this.flushTimer);
389
+ this.flushTimer = undefined;
390
+ while ((this.queue.length > 0 || this.deferredDrops.size > 0 || this.pendingFinalizations.size > 0) && this.db)
391
+ this.flushBatch();
392
+ }
393
+ close() {
394
+ this.flushNow();
395
+ if (this.flushTimer)
396
+ clearTimeout(this.flushTimer);
397
+ this.flushTimer = undefined;
398
+ try {
399
+ this.db?.close();
400
+ }
401
+ catch { /* local trace shutdown is best effort */ }
402
+ this.db = undefined;
403
+ this.prepared = undefined;
404
+ this.spanBatchStatements.clear();
405
+ this.candidateBatchStatements.clear();
406
+ this.linkBatchStatements.clear();
407
+ this.pendingFinalizations.clear();
408
+ this.schemaVersion = 0;
409
+ }
410
+ accountFor(traceId) {
411
+ // Observers only append to a trace they began in this process. Restarted
412
+ // recording traces are finalized during open(), so producer calls never
413
+ // synchronously query SQLite just to recover counters.
414
+ return this.accounting.get(traceId);
415
+ }
416
+ enqueue(write, traceId) {
417
+ const existing = this.queuedByKey.get(write.key);
418
+ if (existing) {
419
+ // Span start -> finish and a repeated candidate decision coalesce into
420
+ // the newest typed state before SQLite sees them. This bounds writes
421
+ // without dropping the final physical outcome.
422
+ if (existing.kind === 'span' && write.kind === 'span') {
423
+ existing.value = write.value;
424
+ existing.serialized = write.serialized;
425
+ return { accepted: true };
426
+ }
427
+ if (existing.kind === 'candidate' && write.kind === 'candidate') {
428
+ existing.value = write.value;
429
+ existing.serialized = write.serialized;
430
+ return { accepted: true };
431
+ }
432
+ if (existing.kind === 'link' && write.kind === 'link') {
433
+ existing.value = write.value;
434
+ existing.serialized = write.serialized;
435
+ return { accepted: true };
436
+ }
437
+ }
438
+ if (this.queue.length >= this.options.queueCap)
439
+ return this.drop(traceId, 'queue_cap');
440
+ this.queue.push(write);
441
+ this.queuedByKey.set(write.key, write);
442
+ this.scheduleFlush();
443
+ return { accepted: true };
444
+ }
445
+ /** Finalization may only join a batch after every earlier trace record is in it. */
446
+ hasQueuedWriteForTrace(traceId) {
447
+ return this.queue.some((item) => (item.kind === 'span'
448
+ ? item.value.traceId === traceId
449
+ : item.kind === 'candidate'
450
+ ? item.value.traceId === traceId
451
+ : item.value.sourceTraceId === traceId));
452
+ }
453
+ scheduleFlush() {
454
+ if (!this.db || this.options.readOnly || this.flushTimer)
455
+ return;
456
+ const flush = () => {
457
+ this.flushTimer = undefined;
458
+ this.flushBatch();
459
+ if (this.queue.length > 0 || this.deferredDrops.size > 0 || this.pendingFinalizations.size > 0)
460
+ this.scheduleFlush();
461
+ };
462
+ // Large bursts flush as soon as the event loop yields; ordinary Ask traces
463
+ // wait up to the documented 25ms coalescing window. Both paths retain the
464
+ // bounded transaction size and never block a trace producer synchronously.
465
+ if (this.queue.length >= this.options.flushBatchSize) {
466
+ this.flushTimer = setTimeout(flush, 0);
467
+ return;
468
+ }
469
+ this.flushTimer = setTimeout(flush, this.options.flushIntervalMs);
470
+ }
471
+ flushBatch() {
472
+ if (!this.db || this.options.readOnly || (this.queue.length === 0 && this.deferredDrops.size === 0 && this.pendingFinalizations.size === 0))
473
+ return;
474
+ const batch = this.queue.splice(0, this.options.flushBatchSize);
475
+ for (const item of batch)
476
+ this.queuedByKey.delete(item.key);
477
+ const drops = [...this.deferredDrops.entries()];
478
+ const finalizations = [...this.pendingFinalizations.entries()].filter(([traceId]) => !this.hasQueuedWriteForTrace(traceId));
479
+ try {
480
+ const finalizationTraceIds = new Set(finalizations.map(([traceId]) => traceId));
481
+ this.db.transaction(() => {
482
+ const touchedTraceIds = new Set();
483
+ const spans = [];
484
+ const candidates = [];
485
+ const links = [];
486
+ for (const item of batch) {
487
+ if (item.kind === 'span') {
488
+ spans.push(item);
489
+ touchedTraceIds.add(item.value.traceId);
490
+ }
491
+ else if (item.kind === 'candidate') {
492
+ candidates.push(item);
493
+ touchedTraceIds.add(item.value.traceId);
494
+ }
495
+ else {
496
+ links.push(item);
497
+ touchedTraceIds.add(item.value.sourceTraceId);
498
+ }
499
+ }
500
+ // Every accepted item still reaches SQLite in this transaction. Grouping
501
+ // only removes repeated JS/native call overhead; it never samples,
502
+ // collapses distinct spans, or defers finalization persistence.
503
+ this.writeSpanBatch(spans);
504
+ this.writeCandidateBatch(candidates);
505
+ this.writeLinkBatch(links);
506
+ const updatedAt = new Date().toISOString();
507
+ for (const [traceId, count] of drops) {
508
+ this.prepared.addDrops.run(count, updatedAt, traceId);
509
+ touchedTraceIds.add(traceId);
510
+ }
511
+ for (const traceId of touchedTraceIds) {
512
+ // A final receipt writes these same counters atomically below. Do
513
+ // not issue an immediately superseded SQLite UPDATE for every Ask.
514
+ if (finalizationTraceIds.has(traceId))
515
+ continue;
516
+ const account = this.accountFor(traceId);
517
+ if (!account)
518
+ continue;
519
+ this.prepared.updateTraceAccounting.run(account.spanCount, account.candidateDecisionCount, account.detailBytes, updatedAt, traceId);
520
+ }
521
+ for (const [traceId, pending] of finalizations) {
522
+ const detail = this.accountFor(traceId);
523
+ const envelope = pending.envelope;
524
+ const dropped = Math.max(envelope.droppedRecordCount, this.droppedRecordCount(traceId));
525
+ const recordingStatus = dropped > 0 ? 'partial' : envelope.recordingStatus;
526
+ const completedAt = envelope.completedAt ?? updatedAt;
527
+ const persistedEnvelope = {
528
+ ...envelope,
529
+ completedAt,
530
+ recordingStatus,
531
+ spanCount: detail?.spanCount ?? envelope.spanCount,
532
+ candidateDecisionCount: detail?.candidateDecisionCount ?? envelope.candidateDecisionCount,
533
+ droppedRecordCount: dropped,
534
+ };
535
+ // The producer owns authoritative in-process counters. Avoid a
536
+ // read-back SELECT after every Ask merely to rediscover counts just
537
+ // flushed from the bounded queue. A restarted trace never reaches
538
+ // this method because startup marks it interrupted before a new
539
+ // observer is admitted.
540
+ this.prepared.finalizeTrace.run(persistedEnvelope.status, persistedEnvelope.recordingStatus, persistedEnvelope.trustState ?? null, persistedEnvelope.selectedTier ?? null, completedAt, persistedEnvelope.durationMs ?? durationMs(persistedEnvelope.startedAt, completedAt), persistedEnvelope.firstIssueSpanId ?? null, persistedEnvelope.traceFingerprint ?? null, persistedEnvelope.spanCount, persistedEnvelope.candidateDecisionCount, persistedEnvelope.droppedRecordCount, detail?.detailBytes ?? 0, storageJson(persistedEnvelope), updatedAt, traceId);
541
+ }
542
+ })();
543
+ for (const [traceId, count] of drops) {
544
+ const pending = this.deferredDrops.get(traceId) ?? 0;
545
+ if (pending <= count)
546
+ this.deferredDrops.delete(traceId);
547
+ else
548
+ this.deferredDrops.set(traceId, pending - count);
549
+ }
550
+ for (const [traceId] of finalizations) {
551
+ this.pendingFinalizations.delete(traceId);
552
+ if (this.liveTraceIds.delete(traceId)) {
553
+ this.detailedTraceCount += 1;
554
+ this.summaryTraceCount += 1;
555
+ }
556
+ }
557
+ // File-system capacity checks are intentionally not part of the hot
558
+ // producer/finalization path. Retention/open and a near-cap condition
559
+ // refresh the cached state before a later trace is admitted.
560
+ }
561
+ catch {
562
+ for (const item of batch) {
563
+ const traceId = item.kind === 'span' ? item.value.traceId : item.kind === 'candidate' ? item.value.traceId : item.value.sourceTraceId;
564
+ this.recordDrop(traceId);
565
+ }
566
+ this.trip();
567
+ }
568
+ }
569
+ writeSpanBatch(items) {
570
+ for (const rows of batches(items, 64)) {
571
+ const statement = this.batchStatement(this.spanBatchStatements, rows.length, 12, `
572
+ INSERT OR REPLACE INTO ask_spans (
573
+ trace_id, span_id, parent_span_id, ordinal, name, stage, started_at, completed_at,
574
+ duration_ms, outcome, reason_code, payload_json
575
+ ) VALUES
576
+ `);
577
+ const values = [];
578
+ for (const { value: span, serialized } of rows) {
579
+ values.push(span.traceId, span.spanId, span.parentSpanId ?? null, span.ordinal, span.name, span.stage, span.startedAt, span.completedAt ?? null, span.durationMs ?? null, span.outcome, span.reasonCode, serialized);
580
+ }
581
+ statement.run(...values);
582
+ }
583
+ }
584
+ writeCandidateBatch(items) {
585
+ for (const rows of batches(items, 64)) {
586
+ const statement = this.batchStatement(this.candidateBatchStatements, rows.length, 14, `
587
+ INSERT OR REPLACE INTO ask_candidate_decisions (
588
+ trace_id, sequence, candidate_id, display_label, role, source, lane, lane_rank,
589
+ fused_rank, reciprocal_rank_score, decision, reason_code, compatibility_code, payload_json
590
+ ) VALUES
591
+ `);
592
+ const values = [];
593
+ for (const { value: candidate, serialized } of rows) {
594
+ values.push(candidate.traceId, candidate.sequence, candidate.candidateId, null, candidate.role, candidate.source, candidate.lane ?? null, candidate.laneRank ?? null, candidate.fusedRank ?? null, candidate.reciprocalRankScore ?? null, candidate.decision, candidate.reasonCode, candidate.compatibilityCode ?? null, serialized);
595
+ }
596
+ statement.run(...values);
597
+ }
598
+ }
599
+ writeLinkBatch(items) {
600
+ for (const rows of batches(items, 64)) {
601
+ const statement = this.batchStatement(this.linkBatchStatements, rows.length, 10, `
602
+ INSERT INTO ask_trace_links (
603
+ source_trace_id, source_run_id, target_trace_id, target_run_id, kind,
604
+ hypothesis_fingerprint, choice_fingerprint, verdict_fingerprint, created_at, payload_json
605
+ ) VALUES
606
+ `);
607
+ const values = [];
608
+ for (const { value: link, serialized } of rows) {
609
+ values.push(link.sourceTraceId, link.sourceRunId, link.targetTraceId ?? null, link.targetRunId ?? null, link.kind, link.hypothesisFingerprint ?? null, link.choiceFingerprint ?? null, link.verdictFingerprint ?? null, link.createdAt, serialized);
610
+ }
611
+ statement.run(...values);
612
+ }
613
+ }
614
+ batchStatement(cache, rowCount, columnCount, prefix) {
615
+ const cached = cache.get(rowCount);
616
+ if (cached)
617
+ return cached;
618
+ const placeholders = `(${Array.from({ length: columnCount }, () => '?').join(', ')})`;
619
+ const statement = this.db.prepare(`${prefix} ${Array.from({ length: rowCount }, () => placeholders).join(', ')}`);
620
+ cache.set(rowCount, statement);
621
+ return statement;
622
+ }
623
+ open() {
624
+ if (this.options.readOnly && !existsSync(this.options.path)) {
625
+ this.unavailableReason = 'store_error';
626
+ return;
627
+ }
628
+ try {
629
+ if (!this.options.readOnly)
630
+ mkdirSync(dirname(this.options.path), { recursive: true });
631
+ this.db = new Database(this.options.path, this.options.readOnly ? { readonly: true, fileMustExist: true } : undefined);
632
+ this.db.pragma('busy_timeout = ' + Math.max(25, Math.min(5_000, this.options.busyTimeoutMs)));
633
+ const existing = Number(this.db.pragma('user_version', { simple: true }) ?? 0);
634
+ // A CLI/read-only observer must not deserialize a newer trace schema
635
+ // optimistically. Leave it untouched and report a typed compatibility
636
+ // state rather than trying to migrate or infer fields.
637
+ if (existing > ASK_TRACE_SCHEMA_VERSION) {
638
+ this.db.close();
639
+ this.db = undefined;
640
+ this.unavailableReason = 'unsupported_schema';
641
+ return;
642
+ }
643
+ if (!this.options.readOnly) {
644
+ this.db.pragma('journal_mode = WAL');
645
+ this.db.pragma('synchronous = NORMAL');
646
+ this.db.pragma('foreign_keys = ON');
647
+ this.migrate(existing);
648
+ this.finalizeInterrupted();
649
+ this.enforceRetention();
650
+ this.refreshCapacityState();
651
+ }
652
+ this.schemaVersion = this.options.readOnly ? existing : ASK_TRACE_SCHEMA_VERSION;
653
+ this.prepareStatements();
654
+ this.refreshRetentionCounts();
655
+ // Store open already performed the age/cap sweep. Avoid repeating the
656
+ // same synchronous retention SQL for the first ordinary Ask.
657
+ this.nextRetentionCheckAt = Date.now() + 60_000;
658
+ }
659
+ catch {
660
+ this.close();
661
+ if (!this.options.readOnly && existsSync(this.options.path)) {
662
+ try {
663
+ renameSync(this.options.path, `${this.options.path}.corrupt.${Date.now()}`);
664
+ this.db = new Database(this.options.path);
665
+ this.db.pragma('journal_mode = WAL');
666
+ this.db.pragma('synchronous = NORMAL');
667
+ this.db.pragma('foreign_keys = ON');
668
+ this.migrate(0);
669
+ this.refreshCapacityState();
670
+ this.schemaVersion = ASK_TRACE_SCHEMA_VERSION;
671
+ this.prepareStatements();
672
+ this.refreshRetentionCounts();
673
+ this.nextRetentionCheckAt = Date.now() + 60_000;
674
+ return;
675
+ }
676
+ catch {
677
+ this.close();
678
+ }
679
+ }
680
+ this.unavailableReason = 'store_error';
681
+ }
682
+ }
683
+ migrate(existing) {
684
+ if (!this.db || existing >= ASK_TRACE_SCHEMA_VERSION)
685
+ return;
686
+ this.db.transaction(() => {
687
+ this.db.exec(`
688
+ CREATE TABLE IF NOT EXISTS ask_traces (
689
+ trace_id TEXT PRIMARY KEY,
690
+ root_span_id TEXT NOT NULL,
691
+ run_id TEXT NOT NULL UNIQUE,
692
+ surface TEXT NOT NULL,
693
+ mode TEXT NOT NULL,
694
+ thread_id TEXT,
695
+ snapshot_id TEXT,
696
+ question_fingerprint TEXT NOT NULL,
697
+ status TEXT NOT NULL,
698
+ recording_status TEXT NOT NULL,
699
+ trust_state TEXT,
700
+ selected_tier TEXT,
701
+ started_at TEXT NOT NULL,
702
+ completed_at TEXT,
703
+ duration_ms INTEGER,
704
+ first_issue_span_id TEXT,
705
+ trace_fingerprint TEXT,
706
+ span_count INTEGER NOT NULL DEFAULT 0,
707
+ candidate_decision_count INTEGER NOT NULL DEFAULT 0,
708
+ dropped_record_count INTEGER NOT NULL DEFAULT 0,
709
+ parent_trace_id TEXT,
710
+ parent_run_id TEXT,
711
+ detail_expired INTEGER NOT NULL DEFAULT 0,
712
+ detail_bytes INTEGER NOT NULL DEFAULT 0,
713
+ summary_json TEXT NOT NULL,
714
+ updated_at TEXT NOT NULL
715
+ );
716
+ CREATE TABLE IF NOT EXISTS ask_spans (
717
+ trace_id TEXT NOT NULL REFERENCES ask_traces(trace_id) ON DELETE CASCADE,
718
+ span_id TEXT NOT NULL,
719
+ parent_span_id TEXT,
720
+ ordinal INTEGER NOT NULL,
721
+ name TEXT NOT NULL,
722
+ stage TEXT NOT NULL,
723
+ started_at TEXT NOT NULL,
724
+ completed_at TEXT,
725
+ duration_ms INTEGER,
726
+ outcome TEXT NOT NULL,
727
+ reason_code TEXT NOT NULL,
728
+ payload_json TEXT NOT NULL,
729
+ PRIMARY KEY (trace_id, span_id)
730
+ );
731
+ CREATE TABLE IF NOT EXISTS ask_span_events (
732
+ trace_id TEXT NOT NULL REFERENCES ask_traces(trace_id) ON DELETE CASCADE,
733
+ span_id TEXT NOT NULL,
734
+ sequence INTEGER NOT NULL,
735
+ at TEXT NOT NULL,
736
+ kind TEXT NOT NULL,
737
+ payload_json TEXT NOT NULL,
738
+ PRIMARY KEY (trace_id, span_id, sequence)
739
+ );
740
+ CREATE TABLE IF NOT EXISTS ask_candidate_decisions (
741
+ trace_id TEXT NOT NULL REFERENCES ask_traces(trace_id) ON DELETE CASCADE,
742
+ sequence INTEGER NOT NULL,
743
+ candidate_id TEXT NOT NULL,
744
+ display_label TEXT,
745
+ role TEXT NOT NULL,
746
+ source TEXT NOT NULL,
747
+ lane TEXT,
748
+ lane_rank INTEGER,
749
+ fused_rank INTEGER,
750
+ reciprocal_rank_score REAL,
751
+ decision TEXT NOT NULL,
752
+ reason_code TEXT NOT NULL,
753
+ compatibility_code TEXT,
754
+ payload_json TEXT NOT NULL,
755
+ PRIMARY KEY (trace_id, sequence)
756
+ );
757
+ CREATE TABLE IF NOT EXISTS ask_trace_links (
758
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
759
+ source_trace_id TEXT NOT NULL,
760
+ source_run_id TEXT NOT NULL,
761
+ target_trace_id TEXT,
762
+ target_run_id TEXT,
763
+ kind TEXT NOT NULL,
764
+ hypothesis_fingerprint TEXT,
765
+ choice_fingerprint TEXT,
766
+ verdict_fingerprint TEXT,
767
+ created_at TEXT NOT NULL,
768
+ payload_json TEXT NOT NULL
769
+ );
770
+ CREATE TABLE IF NOT EXISTS ask_export_receipts (
771
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
772
+ trace_id TEXT NOT NULL REFERENCES ask_traces(trace_id) ON DELETE CASCADE,
773
+ bundle_fingerprint TEXT NOT NULL,
774
+ profile TEXT NOT NULL,
775
+ exported_at TEXT NOT NULL,
776
+ payload_json TEXT NOT NULL
777
+ );
778
+ CREATE INDEX IF NOT EXISTS idx_ask_traces_started ON ask_traces(started_at DESC, trace_id);
779
+ CREATE INDEX IF NOT EXISTS idx_ask_traces_thread ON ask_traces(thread_id);
780
+ CREATE INDEX IF NOT EXISTS idx_ask_traces_snapshot ON ask_traces(snapshot_id);
781
+ CREATE INDEX IF NOT EXISTS idx_ask_traces_parent ON ask_traces(parent_trace_id);
782
+ CREATE INDEX IF NOT EXISTS idx_ask_spans_name_outcome ON ask_spans(name, outcome);
783
+ CREATE INDEX IF NOT EXISTS idx_ask_candidates_identity ON ask_candidate_decisions(candidate_id, role, reason_code);
784
+ CREATE INDEX IF NOT EXISTS idx_ask_links_source_target ON ask_trace_links(source_trace_id, target_trace_id);
785
+ `);
786
+ this.db.pragma(`user_version = ${ASK_TRACE_SCHEMA_VERSION}`);
787
+ })();
788
+ }
789
+ prepareStatements() {
790
+ if (!this.db || this.options.readOnly)
791
+ return;
792
+ this.prepared = {
793
+ insertTrace: this.db.prepare(`
794
+ INSERT INTO ask_traces (
795
+ trace_id, root_span_id, run_id, surface, mode, thread_id, snapshot_id,
796
+ question_fingerprint, status, recording_status, trust_state, selected_tier,
797
+ started_at, completed_at, duration_ms, first_issue_span_id, trace_fingerprint,
798
+ span_count, candidate_decision_count, dropped_record_count, parent_trace_id,
799
+ parent_run_id, detail_expired, detail_bytes, summary_json, updated_at
800
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, 1, 0, 0, ?, ?, 0, ?, ?, ?)
801
+ ON CONFLICT(trace_id) DO NOTHING
802
+ `),
803
+ insertRootSpan: this.db.prepare(`
804
+ INSERT INTO ask_spans (
805
+ trace_id, span_id, parent_span_id, ordinal, name, stage, started_at, completed_at,
806
+ duration_ms, outcome, reason_code, payload_json
807
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
808
+ ON CONFLICT(trace_id, span_id) DO NOTHING
809
+ `),
810
+ updateTraceAccounting: this.db.prepare(`
811
+ UPDATE ask_traces
812
+ SET span_count = ?, candidate_decision_count = ?, detail_bytes = ?, updated_at = ?
813
+ WHERE trace_id = ?
814
+ `),
815
+ addDrops: this.db.prepare(`
816
+ UPDATE ask_traces
817
+ SET recording_status = 'partial', dropped_record_count = dropped_record_count + ?, updated_at = ?
818
+ WHERE trace_id = ?
819
+ `),
820
+ finalizeTrace: this.db.prepare(`
821
+ UPDATE ask_traces SET
822
+ status = ?, recording_status = ?, trust_state = ?, selected_tier = ?, completed_at = ?,
823
+ duration_ms = ?, first_issue_span_id = ?, trace_fingerprint = ?, span_count = ?,
824
+ candidate_decision_count = ?, dropped_record_count = ?, detail_bytes = ?, summary_json = ?, updated_at = ?
825
+ WHERE trace_id = ?
826
+ `),
827
+ insertExportReceipt: this.db.prepare(`
828
+ INSERT INTO ask_export_receipts (trace_id, bundle_fingerprint, profile, exported_at, payload_json)
829
+ VALUES (?, ?, ?, ?, ?)
830
+ `),
831
+ };
832
+ }
833
+ refreshRetentionCounts() {
834
+ if (!this.db)
835
+ return;
836
+ const counts = this.db.prepare(`
837
+ SELECT
838
+ COUNT(*) AS summary_count,
839
+ SUM(CASE WHEN detail_expired = 0 THEN 1 ELSE 0 END) AS detail_count
840
+ FROM ask_traces
841
+ `).get();
842
+ this.summaryTraceCount = Number(counts?.summary_count ?? 0);
843
+ this.detailedTraceCount = Number(counts?.detail_count ?? 0);
844
+ }
845
+ maybeEnforceRetention() {
846
+ if (!this.db || this.options.readOnly)
847
+ return;
848
+ const now = Date.now();
849
+ if (this.detailedTraceCount > this.options.maxDetailedTraces
850
+ || this.summaryTraceCount > this.options.maxSummaryTraces
851
+ || now >= this.nextRetentionCheckAt) {
852
+ this.enforceRetention();
853
+ this.nextRetentionCheckAt = now + 60_000;
854
+ }
855
+ }
856
+ drop(traceId, dropped) {
857
+ this.recordDrop(traceId);
858
+ return { accepted: false, dropped };
859
+ }
860
+ /**
861
+ * Keep producer-side loss accounting entirely in memory. The next bounded
862
+ * batch marks the trace partial; no synchronous UPDATE is issued from an
863
+ * Ask/tool/provider callback.
864
+ */
865
+ recordDrop(traceId, count = 1) {
866
+ this.deferredDrops.set(traceId, (this.deferredDrops.get(traceId) ?? 0) + count);
867
+ this.droppedTotals.set(traceId, (this.droppedTotals.get(traceId) ?? 0) + count);
868
+ this.scheduleFlush();
869
+ }
870
+ summaryForRow(row) {
871
+ const envelope = this.envelopeForRow(row);
872
+ return { ...envelope, detailAvailable: row.detail_expired === 0 };
873
+ }
874
+ envelopeForRow(row) {
875
+ // The V1 table has no terminal-outcome column. It is a safe, additive
876
+ // field in the already persisted summary envelope, so read it defensively
877
+ // without a schema migration and leave older rows unchanged.
878
+ const terminalOutcome = terminalOutcomeFromSummary(row.summary_json);
879
+ return {
880
+ version: 1,
881
+ traceId: row.trace_id,
882
+ rootSpanId: row.root_span_id,
883
+ runId: row.run_id,
884
+ surface: row.surface,
885
+ mode: row.mode,
886
+ ...(row.thread_id ? { threadId: row.thread_id } : {}),
887
+ ...(row.snapshot_id ? { snapshotId: row.snapshot_id } : {}),
888
+ questionFingerprint: row.question_fingerprint,
889
+ status: row.status,
890
+ ...(terminalOutcome ? { terminalOutcome } : {}),
891
+ recordingStatus: (row.detail_expired ? 'detail_expired' : row.recording_status),
892
+ ...(row.trust_state ? { trustState: row.trust_state } : {}),
893
+ ...(row.selected_tier ? { selectedTier: row.selected_tier } : {}),
894
+ startedAt: row.started_at,
895
+ ...(row.completed_at ? { completedAt: row.completed_at } : {}),
896
+ ...(row.duration_ms !== null ? { durationMs: row.duration_ms } : {}),
897
+ ...(row.first_issue_span_id ? { firstIssueSpanId: row.first_issue_span_id } : {}),
898
+ ...(row.trace_fingerprint ? { traceFingerprint: row.trace_fingerprint } : {}),
899
+ spanCount: row.span_count,
900
+ candidateDecisionCount: row.candidate_decision_count,
901
+ droppedRecordCount: row.dropped_record_count,
902
+ ...(row.parent_trace_id ? { parentTraceId: row.parent_trace_id } : {}),
903
+ ...(row.parent_run_id ? { parentRunId: row.parent_run_id } : {}),
904
+ };
905
+ }
906
+ dataForRow(row) {
907
+ const envelope = this.envelopeForRow(row);
908
+ if (row.detail_expired)
909
+ return { envelope, spans: [], candidateDecisions: [], links: [] };
910
+ const spans = (this.db?.prepare(`
911
+ SELECT * FROM ask_spans WHERE trace_id = ? ORDER BY ordinal ASC, span_id ASC
912
+ `).all(row.trace_id) ?? []).map((span) => this.spanForRow(span, row.trace_id));
913
+ const candidates = (this.db?.prepare(`
914
+ SELECT payload_json FROM ask_candidate_decisions WHERE trace_id = ? ORDER BY sequence ASC
915
+ `).all(row.trace_id) ?? []).flatMap((entry) => {
916
+ const parsed = parseJson(entry.payload_json);
917
+ return parsed ? [parsed] : [];
918
+ });
919
+ const links = (this.db?.prepare(`
920
+ SELECT payload_json FROM ask_trace_links WHERE source_trace_id = ? OR target_trace_id = ? ORDER BY id ASC
921
+ `).all(row.trace_id, row.trace_id) ?? []).flatMap((entry) => {
922
+ const parsed = parseJson(entry.payload_json);
923
+ return parsed ? [parsed] : [];
924
+ });
925
+ return { envelope, spans, candidateDecisions: candidates, links };
926
+ }
927
+ spanForRow(row, traceId) {
928
+ return {
929
+ version: 1,
930
+ traceId,
931
+ spanId: String(row.span_id),
932
+ ...(typeof row.parent_span_id === 'string' ? { parentSpanId: row.parent_span_id } : {}),
933
+ ordinal: Number(row.ordinal),
934
+ name: row.name,
935
+ stage: row.stage,
936
+ startedAt: String(row.started_at),
937
+ ...(typeof row.completed_at === 'string' ? { completedAt: row.completed_at } : {}),
938
+ ...(typeof row.duration_ms === 'number' ? { durationMs: row.duration_ms } : {}),
939
+ outcome: row.outcome,
940
+ reasonCode: row.reason_code,
941
+ payload: parseJson(String(row.payload_json)),
942
+ };
943
+ }
944
+ finalizeInterrupted() {
945
+ if (!this.db || this.options.readOnly)
946
+ return;
947
+ const now = new Date().toISOString();
948
+ this.db.transaction(() => {
949
+ const open = this.db.prepare(`SELECT trace_id, started_at FROM ask_traces WHERE status = 'running'`).all();
950
+ for (const trace of open) {
951
+ this.db.prepare(`
952
+ UPDATE ask_spans SET completed_at = ?, duration_ms = MAX(0, CAST((julianday(?) - julianday(started_at)) * 86400000 AS INTEGER)), outcome = 'interrupted', reason_code = 'interrupted'
953
+ WHERE trace_id = ? AND completed_at IS NULL
954
+ `).run(now, now, trace.trace_id);
955
+ this.db.prepare(`
956
+ UPDATE ask_traces SET status = 'interrupted', recording_status = CASE WHEN recording_status = 'recording' THEN 'partial' ELSE recording_status END,
957
+ completed_at = ?, duration_ms = MAX(0, CAST((julianday(?) - julianday(started_at)) * 86400000 AS INTEGER)), updated_at = ?
958
+ WHERE trace_id = ?
959
+ `).run(now, now, now, trace.trace_id);
960
+ }
961
+ })();
962
+ }
963
+ enforceRetention() {
964
+ if (!this.db || this.options.readOnly)
965
+ return;
966
+ try {
967
+ const now = Date.now();
968
+ const detailCutoff = new Date(now - this.options.detailMaxAgeMs).toISOString();
969
+ const summaryCutoff = new Date(now - this.options.summaryMaxAgeMs).toISOString();
970
+ // Detailed evidence expires independently: summaries remain inspectable.
971
+ const expired = this.db.prepare(`
972
+ SELECT trace_id FROM ask_traces WHERE detail_expired = 0 AND started_at < ?
973
+ UNION
974
+ SELECT trace_id FROM ask_traces WHERE detail_expired = 0 AND trace_id NOT IN (
975
+ SELECT trace_id FROM ask_traces ORDER BY started_at DESC, trace_id DESC LIMIT ?
976
+ )
977
+ `).all(detailCutoff, this.options.maxDetailedTraces);
978
+ const expire = this.db.transaction((traceIds) => {
979
+ for (const traceId of traceIds) {
980
+ this.db.prepare('DELETE FROM ask_spans WHERE trace_id = ?').run(traceId);
981
+ this.db.prepare('DELETE FROM ask_span_events WHERE trace_id = ?').run(traceId);
982
+ this.db.prepare('DELETE FROM ask_candidate_decisions WHERE trace_id = ?').run(traceId);
983
+ // Research and continuation links are durable relationship evidence.
984
+ // Detail expiry must not sever them merely because spans aged out.
985
+ this.db.prepare(`UPDATE ask_traces SET detail_expired = 1, recording_status = 'detail_expired', detail_bytes = 0 WHERE trace_id = ?`).run(traceId);
986
+ }
987
+ });
988
+ expire(expired.map((row) => row.trace_id));
989
+ this.db.prepare(`
990
+ DELETE FROM ask_traces WHERE started_at < ? OR trace_id NOT IN (
991
+ SELECT trace_id FROM ask_traces ORDER BY started_at DESC, trace_id DESC LIMIT ?
992
+ )
993
+ `).run(summaryCutoff, this.options.maxSummaryTraces);
994
+ // Once both sides have left summary retention there is no addressable
995
+ // trace left to inspect. Remove only truly orphaned links, preserving a
996
+ // relationship whenever either source or target summary remains.
997
+ this.db.prepare(`
998
+ DELETE FROM ask_trace_links
999
+ WHERE source_trace_id NOT IN (SELECT trace_id FROM ask_traces)
1000
+ AND (target_trace_id IS NULL OR target_trace_id NOT IN (SELECT trace_id FROM ask_traces))
1001
+ `).run();
1002
+ // SQLite's page file may stay large after deletes. The cap still guarantees
1003
+ // future recording stays bounded; incremental vacuum avoids a blocking VACUUM.
1004
+ this.refreshCapacityState();
1005
+ if (this.databaseAtCap) {
1006
+ const oldest = this.db.prepare(`
1007
+ SELECT trace_id FROM ask_traces WHERE detail_expired = 0 ORDER BY started_at ASC LIMIT 50
1008
+ `).all();
1009
+ for (const row of oldest)
1010
+ this.markPartial(row.trace_id);
1011
+ this.db.pragma('incremental_vacuum(64)');
1012
+ this.refreshCapacityState();
1013
+ }
1014
+ this.refreshRetentionCounts();
1015
+ }
1016
+ catch {
1017
+ this.trip();
1018
+ }
1019
+ }
1020
+ trip() {
1021
+ // A store problem must never leak to execution. Keep the existing file for
1022
+ // support investigation and turn subsequent observer calls into no-ops.
1023
+ this.unavailableReason = 'store_error';
1024
+ try {
1025
+ this.db?.close();
1026
+ }
1027
+ catch { /* tracing is strictly fail-open */ }
1028
+ this.db = undefined;
1029
+ this.prepared = undefined;
1030
+ this.spanBatchStatements.clear();
1031
+ this.candidateBatchStatements.clear();
1032
+ this.linkBatchStatements.clear();
1033
+ this.pendingFinalizations.clear();
1034
+ this.schemaVersion = 0;
1035
+ }
1036
+ isDatabaseAtCap() {
1037
+ try {
1038
+ const main = existsSync(this.options.path) ? statSync(this.options.path).size : 0;
1039
+ const walPath = `${this.options.path}-wal`;
1040
+ const wal = existsSync(walPath) ? statSync(walPath).size : 0;
1041
+ return main + wal > this.options.maxDbBytes;
1042
+ }
1043
+ catch {
1044
+ return false;
1045
+ }
1046
+ }
1047
+ /** File-system stats are intentionally confined to lifecycle/flush work. */
1048
+ refreshCapacityState() {
1049
+ this.databaseAtCap = this.isDatabaseAtCap();
1050
+ }
1051
+ }
1052
+ export function defaultAskTraceSqlitePath(projectRoot) {
1053
+ return join(projectRoot, '.dql', 'local', 'ask-observability.sqlite');
1054
+ }
1055
+ /**
1056
+ * The observer writes the same root first into its in-memory span map. Keeping
1057
+ * the store's emergency root intentionally tiny and deterministic means an
1058
+ * interrupted run remains structurally exportable without retaining question
1059
+ * text or any execution payload before normal tracing starts.
1060
+ */
1061
+ function durableRootSpan(envelope) {
1062
+ return {
1063
+ version: 1,
1064
+ traceId: envelope.traceId,
1065
+ spanId: envelope.rootSpanId,
1066
+ ordinal: 0,
1067
+ name: envelope.mode === 'research' ? 'research.run' : 'ask.run',
1068
+ stage: 'request',
1069
+ startedAt: envelope.startedAt,
1070
+ outcome: 'ok',
1071
+ reasonCode: 'started',
1072
+ payload: {
1073
+ kind: 'stage',
1074
+ requestedMode: envelope.mode,
1075
+ fingerprint: envelope.questionFingerprint,
1076
+ },
1077
+ };
1078
+ }
1079
+ function parseJson(value) {
1080
+ try {
1081
+ return JSON.parse(value);
1082
+ }
1083
+ catch {
1084
+ return undefined;
1085
+ }
1086
+ }
1087
+ function terminalOutcomeFromSummary(value) {
1088
+ const parsed = parseJson(value);
1089
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
1090
+ return undefined;
1091
+ const terminalOutcome = parsed.terminalOutcome;
1092
+ return terminalOutcome === 'completed'
1093
+ || terminalOutcome === 'needs_review'
1094
+ || terminalOutcome === 'needs_clarification'
1095
+ || terminalOutcome === 'cancelled'
1096
+ || terminalOutcome === 'blocked'
1097
+ ? terminalOutcome
1098
+ : undefined;
1099
+ }
1100
+ /**
1101
+ * SQLite is an internal, local queue target rather than a portable artifact.
1102
+ * Payloads have already crossed the strict typed/redaction boundary; portable
1103
+ * export re-canonicalizes the parsed trace before it is checksummed. Keeping
1104
+ * normal JSON here avoids sorting every nested object once per physical span
1105
+ * without changing exported/replayed trace semantics.
1106
+ */
1107
+ function storageJson(value) {
1108
+ return JSON.stringify(value);
1109
+ }
1110
+ function* batches(items, size) {
1111
+ for (let index = 0; index < items.length; index += size)
1112
+ yield items.slice(index, index + size);
1113
+ }
1114
+ //# sourceMappingURL=store.js.map