@duckcodeailabs/dql-agent 1.14.2 → 1.14.3-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-run-engine.d.ts +63 -2
- package/dist/agent-run-engine.d.ts.map +1 -1
- package/dist/agent-run-engine.js +864 -29
- package/dist/agent-run-engine.js.map +1 -1
- package/dist/agent-run-planner.d.ts +6 -0
- package/dist/agent-run-planner.d.ts.map +1 -1
- package/dist/agent-run-planner.js +2 -0
- package/dist/agent-run-planner.js.map +1 -1
- package/dist/agent-run-store.d.ts.map +1 -1
- package/dist/agent-run-store.js +48 -3
- package/dist/agent-run-store.js.map +1 -1
- package/dist/agentic/analyst-loop.d.ts +7 -1
- package/dist/agentic/analyst-loop.d.ts.map +1 -1
- package/dist/agentic/analyst-loop.js +163 -0
- package/dist/agentic/analyst-loop.js.map +1 -1
- package/dist/agentic/assumptions.d.ts +1 -1
- package/dist/agentic/assumptions.d.ts.map +1 -1
- package/dist/agentic/assumptions.js.map +1 -1
- package/dist/agentic/research-agent.d.ts.map +1 -1
- package/dist/agentic/research-agent.js +10 -1
- package/dist/agentic/research-agent.js.map +1 -1
- package/dist/agentic/sql-authorization.d.ts +8 -0
- package/dist/agentic/sql-authorization.d.ts.map +1 -1
- package/dist/agentic/sql-authorization.js +3 -0
- package/dist/agentic/sql-authorization.js.map +1 -1
- package/dist/analytical-compatibility.d.ts.map +1 -1
- package/dist/analytical-compatibility.js +8 -1
- package/dist/analytical-compatibility.js.map +1 -1
- package/dist/analytical-failure-repair.d.ts.map +1 -1
- package/dist/analytical-failure-repair.js +6 -2
- package/dist/analytical-failure-repair.js.map +1 -1
- package/dist/analytical-frame.d.ts +23 -1
- package/dist/analytical-frame.d.ts.map +1 -1
- package/dist/analytical-frame.js +322 -16
- package/dist/analytical-frame.js.map +1 -1
- package/dist/analytical-orchestration.d.ts +314 -3
- package/dist/analytical-orchestration.d.ts.map +1 -1
- package/dist/analytical-orchestration.js +486 -43
- package/dist/analytical-orchestration.js.map +1 -1
- package/dist/answer-loop.d.ts +64 -8
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +528 -67
- package/dist/answer-loop.js.map +1 -1
- package/dist/ask-observability/index.d.ts +11 -0
- package/dist/ask-observability/index.d.ts.map +1 -0
- package/dist/ask-observability/index.js +7 -0
- package/dist/ask-observability/index.js.map +1 -0
- package/dist/ask-observability/instrumentation.d.ts +35 -0
- package/dist/ask-observability/instrumentation.d.ts.map +1 -0
- package/dist/ask-observability/instrumentation.js +472 -0
- package/dist/ask-observability/instrumentation.js.map +1 -0
- package/dist/ask-observability/observer.d.ts +62 -0
- package/dist/ask-observability/observer.d.ts.map +1 -0
- package/dist/ask-observability/observer.js +537 -0
- package/dist/ask-observability/observer.js.map +1 -0
- package/dist/ask-observability/portable.d.ts +98 -0
- package/dist/ask-observability/portable.d.ts.map +1 -0
- package/dist/ask-observability/portable.js +527 -0
- package/dist/ask-observability/portable.js.map +1 -0
- package/dist/ask-observability/store.d.ts +130 -0
- package/dist/ask-observability/store.d.ts.map +1 -0
- package/dist/ask-observability/store.js +1114 -0
- package/dist/ask-observability/store.js.map +1 -0
- package/dist/ask-observability/types.d.ts +357 -0
- package/dist/ask-observability/types.d.ts.map +1 -0
- package/dist/ask-observability/types.js +2 -0
- package/dist/ask-observability/types.js.map +1 -0
- package/dist/ask-observability/utils.d.ts +21 -0
- package/dist/ask-observability/utils.d.ts.map +1 -0
- package/dist/ask-observability/utils.js +102 -0
- package/dist/ask-observability/utils.js.map +1 -0
- package/dist/generated-analytical-proposal.d.ts +37 -1
- package/dist/generated-analytical-proposal.d.ts.map +1 -1
- package/dist/generated-analytical-proposal.js +211 -3
- package/dist/generated-analytical-proposal.js.map +1 -1
- package/dist/governed-relational-compiler.d.ts +15 -0
- package/dist/governed-relational-compiler.d.ts.map +1 -1
- package/dist/governed-relational-compiler.js +99 -1
- package/dist/governed-relational-compiler.js.map +1 -1
- package/dist/index.d.ts +9 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/intent-controller.d.ts +10 -0
- package/dist/intent-controller.d.ts.map +1 -1
- package/dist/intent-controller.js +1 -1
- package/dist/intent-controller.js.map +1 -1
- package/dist/meaning-resolution.d.ts +78 -6
- package/dist/meaning-resolution.d.ts.map +1 -1
- package/dist/meaning-resolution.js +331 -38
- package/dist/meaning-resolution.js.map +1 -1
- package/dist/metadata/analysis-planner.d.ts.map +1 -1
- package/dist/metadata/analysis-planner.js +21 -1
- package/dist/metadata/analysis-planner.js.map +1 -1
- package/dist/metadata/catalog.d.ts.map +1 -1
- package/dist/metadata/catalog.js +1 -1
- package/dist/metadata/catalog.js.map +1 -1
- package/dist/metadata/meaning-evidence.d.ts +9 -2
- package/dist/metadata/meaning-evidence.d.ts.map +1 -1
- package/dist/metadata/meaning-evidence.js +261 -11
- package/dist/metadata/meaning-evidence.js.map +1 -1
- package/dist/plan-execution-adapter.d.ts +9 -1
- package/dist/plan-execution-adapter.d.ts.map +1 -1
- package/dist/plan-execution-adapter.js +93 -13
- package/dist/plan-execution-adapter.js.map +1 -1
- package/dist/provider-egress.d.ts +24 -10
- package/dist/provider-egress.d.ts.map +1 -1
- package/dist/provider-egress.js +22 -17
- package/dist/provider-egress.js.map +1 -1
- package/dist/providers/claude.d.ts.map +1 -1
- package/dist/providers/claude.js +10 -9
- package/dist/providers/claude.js.map +1 -1
- package/dist/providers/dispatch.d.ts +27 -1
- package/dist/providers/dispatch.d.ts.map +1 -1
- package/dist/providers/dispatch.js +63 -1
- package/dist/providers/dispatch.js.map +1 -1
- package/dist/providers/gemini.js +10 -10
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/index.d.ts +2 -2
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +1 -1
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/ollama.js +15 -15
- package/dist/providers/ollama.js.map +1 -1
- package/dist/providers/openai.js +22 -22
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/types.d.ts +52 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/relationship-proof.d.ts +81 -0
- package/dist/relationship-proof.d.ts.map +1 -1
- package/dist/relationship-proof.js +228 -4
- package/dist/relationship-proof.js.map +1 -1
- package/dist/resolved-analytical-plan.d.ts +18 -4
- package/dist/resolved-analytical-plan.d.ts.map +1 -1
- package/dist/resolved-analytical-plan.js +764 -47
- package/dist/resolved-analytical-plan.js.map +1 -1
- package/dist/router.d.ts +10 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +1785 -213
- package/dist/router.js.map +1 -1
- package/dist/semantic-bridge/compose.d.ts.map +1 -1
- package/dist/semantic-bridge/compose.js +23 -4
- package/dist/semantic-bridge/compose.js.map +1 -1
- 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
|