@obsunified/collector 2.2.1 → 2.3.0
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/auth.d.ts +1 -1
- package/dist/auth.js +1 -1
- package/dist/{chunk-KMEZKGSO.js → chunk-2FALTDCE.js} +12 -4
- package/dist/chunk-2FALTDCE.js.map +1 -0
- package/dist/{chunk-ZSF5XP3B.js → chunk-RASSTGNB.js} +30 -22
- package/dist/chunk-RASSTGNB.js.map +1 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.js +2892 -2240
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-C5-8Mly5.d.ts → ingest-auth-BmP5-f96.d.ts} +32 -1
- package/dist/{openai-GSJZGFGL.js → openai-VDKVVZAV.js} +2 -2
- package/package.json +4 -3
- package/src/__tests__/autosre-closed-loop.test.ts +700 -0
- package/src/__tests__/db-parity.integration.test.ts +961 -0
- package/src/__tests__/internal-contracts-routes.test.ts +451 -0
- package/src/auth/dashboard-auth.test.ts +61 -0
- package/src/auth/dashboard-auth.ts +17 -2
- package/src/framework/env.ts +105 -0
- package/src/index.ts +13 -4
- package/src/lib/ai-store/calls.ts +43 -23
- package/src/lib/ai-store/evaluations.test.ts +3 -3
- package/src/lib/ai-store.ts +12 -1
- package/src/lib/analyses-store.test.ts +6 -6
- package/src/lib/autosre-findings.ts +169 -14
- package/src/lib/autosre-remediation-gate.ts +156 -26
- package/src/lib/db-parity.ts +883 -0
- package/src/lib/eval-cases-store.test.ts +1 -1
- package/src/lib/eval-cases-store.ts +16 -4
- package/src/lib/evidence-references.ts +30 -22
- package/src/lib/projects-store.test.ts +38 -1
- package/src/lib/projects-store.ts +23 -4
- package/src/lib/sql-db-postgres.integration.test.ts +75 -2
- package/src/lib/stage6.test.ts +1 -1
- package/src/lib/usage-store.ts +9 -3
- package/src/migrations/052_remediation_callback.sql +10 -0
- package/src/migrations/053_remediation_callback_delivery.sql +7 -0
- package/src/migrations/parity.test.ts +105 -3
- package/src/migrations-postgres/052_remediation_callback.sql +10 -0
- package/src/migrations-postgres/053_remediation_callback_delivery.sql +7 -0
- package/src/plugins/ai-call-by-id.test.ts +73 -0
- package/src/plugins/ai-receiver.ts +15 -0
- package/src/plugins/autosre-routes.ts +409 -147
- package/src/plugins/connected-routes/deep-link-shapes.test.ts +44 -0
- package/src/plugins/connected-routes/manifest.test.ts +224 -0
- package/src/plugins/connected-routes/manifest.ts +131 -28
- package/src/plugins/connected-routes-links.test.ts +96 -0
- package/src/plugins/connected-routes.test.ts +13 -11
- package/src/plugins/connected-routes.ts +53 -20
- package/src/plugins/eval-cases-routes.ts +33 -10
- package/src/plugins/evidence-retrieval-routes.ts +89 -24
- package/dist/chunk-KMEZKGSO.js.map +0 -1
- package/dist/chunk-ZSF5XP3B.js.map +0 -1
- /package/dist/{openai-GSJZGFGL.js.map → openai-VDKVVZAV.js.map} +0 -0
|
@@ -53,6 +53,48 @@ export async function ingestAICallBatch(
|
|
|
53
53
|
await db.batch(batch);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
function mapAICallRow(row: AICallRow): AICallRecord {
|
|
57
|
+
return {
|
|
58
|
+
projectId: row.project_id ?? "default",
|
|
59
|
+
callId: row.call_id,
|
|
60
|
+
traceId: row.trace_id,
|
|
61
|
+
spanId: row.span_id,
|
|
62
|
+
serviceName: row.service_name,
|
|
63
|
+
modelName: row.model_name,
|
|
64
|
+
provider: row.provider,
|
|
65
|
+
callType: row.call_type,
|
|
66
|
+
requestJson: row.request_json,
|
|
67
|
+
responseJson: row.response_json,
|
|
68
|
+
promptTokens: row.prompt_tokens,
|
|
69
|
+
completionTokens: row.completion_tokens,
|
|
70
|
+
totalCostUsd: row.total_cost_usd,
|
|
71
|
+
latencyMs: row.latency_ms,
|
|
72
|
+
isError: row.is_error === 1 || row.is_error === true,
|
|
73
|
+
errorMessage: row.error_message,
|
|
74
|
+
occurredAt: row.occurred_at,
|
|
75
|
+
receivedAt: row.received_at,
|
|
76
|
+
expiresAt: row.expires_at,
|
|
77
|
+
sessionId: row.session_id ?? null,
|
|
78
|
+
interactionId: row.interaction_id ?? null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One call by id, scoped to the project; `null` when absent. */
|
|
83
|
+
export async function getAICallById(
|
|
84
|
+
db: SqlDb,
|
|
85
|
+
projectId: string,
|
|
86
|
+
callId: string,
|
|
87
|
+
): Promise<AICallRecord | null> {
|
|
88
|
+
if (!projectId) throw new Error("AIStore.getAICall: projectId is required");
|
|
89
|
+
const row = await db
|
|
90
|
+
.prepare(
|
|
91
|
+
`SELECT * FROM ai_calls WHERE project_id = ? AND call_id = ? LIMIT 1`,
|
|
92
|
+
)
|
|
93
|
+
.bind(projectId, callId)
|
|
94
|
+
.first<AICallRow>();
|
|
95
|
+
return row ? mapAICallRow(row) : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
56
98
|
export async function getAICallsOverview(
|
|
57
99
|
db: SqlDb,
|
|
58
100
|
options: AICallsOverviewOptions,
|
|
@@ -109,29 +151,7 @@ export async function getAICallsOverview(
|
|
|
109
151
|
.bind(...summaryParams)
|
|
110
152
|
.first<AICallSummaryRow>()) || {};
|
|
111
153
|
|
|
112
|
-
const calls: AICallRecord[] = (results.results || []).map(
|
|
113
|
-
projectId: row.project_id ?? "default",
|
|
114
|
-
callId: row.call_id,
|
|
115
|
-
traceId: row.trace_id,
|
|
116
|
-
spanId: row.span_id,
|
|
117
|
-
serviceName: row.service_name,
|
|
118
|
-
modelName: row.model_name,
|
|
119
|
-
provider: row.provider,
|
|
120
|
-
callType: row.call_type,
|
|
121
|
-
requestJson: row.request_json,
|
|
122
|
-
responseJson: row.response_json,
|
|
123
|
-
promptTokens: row.prompt_tokens,
|
|
124
|
-
completionTokens: row.completion_tokens,
|
|
125
|
-
totalCostUsd: row.total_cost_usd,
|
|
126
|
-
latencyMs: row.latency_ms,
|
|
127
|
-
isError: row.is_error === 1 || row.is_error === true,
|
|
128
|
-
errorMessage: row.error_message,
|
|
129
|
-
occurredAt: row.occurred_at,
|
|
130
|
-
receivedAt: row.received_at,
|
|
131
|
-
expiresAt: row.expires_at,
|
|
132
|
-
sessionId: row.session_id ?? null,
|
|
133
|
-
interactionId: row.interaction_id ?? null,
|
|
134
|
-
}));
|
|
154
|
+
const calls: AICallRecord[] = (results.results || []).map(mapAICallRow);
|
|
135
155
|
|
|
136
156
|
return {
|
|
137
157
|
calls,
|
|
@@ -25,20 +25,20 @@ describe("mapEvaluationRows", () => {
|
|
|
25
25
|
evidenceId: "ai-evaluation:eval-1",
|
|
26
26
|
entityKind: "eval",
|
|
27
27
|
entityId: "eval-1",
|
|
28
|
-
route: "#/
|
|
28
|
+
route: "#/evaluations/eval-1",
|
|
29
29
|
source: "ai_span_evaluations.llm_judge",
|
|
30
30
|
citations: [
|
|
31
31
|
{
|
|
32
32
|
label: "span span-1",
|
|
33
33
|
entityKind: "span",
|
|
34
34
|
entityId: "trace-1:span-1",
|
|
35
|
-
route: "#/traces
|
|
35
|
+
route: "#/traces?trace=trace-1&span=span-1",
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
label: "trace trace-1",
|
|
39
39
|
entityKind: "trace",
|
|
40
40
|
entityId: "trace-1",
|
|
41
|
-
route: "#/traces
|
|
41
|
+
route: "#/traces?trace=trace-1",
|
|
42
42
|
},
|
|
43
43
|
],
|
|
44
44
|
}),
|
package/src/lib/ai-store.ts
CHANGED
|
@@ -10,7 +10,11 @@ import type {
|
|
|
10
10
|
AISpansOverviewOptions,
|
|
11
11
|
AISpansOverviewResponse,
|
|
12
12
|
} from "@obsunified/types";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
getAICallById,
|
|
15
|
+
getAICallsOverview,
|
|
16
|
+
ingestAICallBatch,
|
|
17
|
+
} from "./ai-store/calls";
|
|
14
18
|
import {
|
|
15
19
|
getAIEvaluation,
|
|
16
20
|
ingestAIEvaluations,
|
|
@@ -37,6 +41,13 @@ export class AIStore {
|
|
|
37
41
|
return getAICallsOverview(this.db, options);
|
|
38
42
|
}
|
|
39
43
|
|
|
44
|
+
async getAICall(
|
|
45
|
+
projectId: string,
|
|
46
|
+
callId: string,
|
|
47
|
+
): Promise<AICallRecord | null> {
|
|
48
|
+
return getAICallById(this.db, projectId, callId);
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
async purgeExpired(): Promise<number> {
|
|
41
52
|
return purgeExpiredAIData(this.db);
|
|
42
53
|
}
|
|
@@ -118,7 +118,7 @@ describe("AnalysesStore.getLatestResult evidence references", () => {
|
|
|
118
118
|
"analysis:investigate.latency:tail_offenders:0:span:trace-1:span-1",
|
|
119
119
|
entityKind: "span",
|
|
120
120
|
entityId: "trace-1:span-1",
|
|
121
|
-
route: "#/traces
|
|
121
|
+
route: "#/traces?trace=trace-1&span=span-1",
|
|
122
122
|
source: "analysis.payload.evidence.tail_offenders",
|
|
123
123
|
confidence: 0.95,
|
|
124
124
|
citations: [
|
|
@@ -126,7 +126,7 @@ describe("AnalysesStore.getLatestResult evidence references", () => {
|
|
|
126
126
|
label: "trace trace-1",
|
|
127
127
|
entityKind: "trace",
|
|
128
128
|
entityId: "trace-1",
|
|
129
|
-
route: "#/traces
|
|
129
|
+
route: "#/traces?trace=trace-1",
|
|
130
130
|
},
|
|
131
131
|
],
|
|
132
132
|
}),
|
|
@@ -134,7 +134,7 @@ describe("AnalysesStore.getLatestResult evidence references", () => {
|
|
|
134
134
|
evidenceId: "analysis:investigate.latency:tail_offenders:1:service:api",
|
|
135
135
|
entityKind: "service",
|
|
136
136
|
entityId: "api",
|
|
137
|
-
route: "#/
|
|
137
|
+
route: "#/service-map?service=api",
|
|
138
138
|
source: "analysis.payload.evidence.tail_offenders",
|
|
139
139
|
confidence: 0.7,
|
|
140
140
|
}),
|
|
@@ -219,7 +219,7 @@ describe("AnalysesStore.getLatestResult evidence references", () => {
|
|
|
219
219
|
"analysis:trace.instrumentation_gaps:instrumentation-gap:0:span:trace-gap:span-root",
|
|
220
220
|
entityKind: "span",
|
|
221
221
|
entityId: "trace-gap:span-root",
|
|
222
|
-
route: "#/traces
|
|
222
|
+
route: "#/traces?trace=trace-gap&span=span-root",
|
|
223
223
|
source: "analysis.payload.instrumentationGaps",
|
|
224
224
|
confidence: 0.9,
|
|
225
225
|
citations: [
|
|
@@ -227,7 +227,7 @@ describe("AnalysesStore.getLatestResult evidence references", () => {
|
|
|
227
227
|
label: "trace trace-gap",
|
|
228
228
|
entityKind: "trace",
|
|
229
229
|
entityId: "trace-gap",
|
|
230
|
-
route: "#/traces
|
|
230
|
+
route: "#/traces?trace=trace-gap",
|
|
231
231
|
},
|
|
232
232
|
],
|
|
233
233
|
suggestedNextPivots: expect.arrayContaining([
|
|
@@ -235,7 +235,7 @@ describe("AnalysesStore.getLatestResult evidence references", () => {
|
|
|
235
235
|
label: "Open profiler setup docs",
|
|
236
236
|
entityKind: "docs",
|
|
237
237
|
entityId: "howto/ebpf",
|
|
238
|
-
route: "#/docs
|
|
238
|
+
route: "#/evidence?kind=docs&id=howto%2Febpf",
|
|
239
239
|
}),
|
|
240
240
|
]),
|
|
241
241
|
});
|
|
@@ -15,10 +15,11 @@ import type {
|
|
|
15
15
|
import {
|
|
16
16
|
buildCanonicalNodeId,
|
|
17
17
|
buildFindingFingerprint,
|
|
18
|
+
OntologyNodeKindSchema,
|
|
18
19
|
} from "@obsunified/types";
|
|
19
20
|
import { consoleLogger, type Logger } from "../framework/logger";
|
|
20
21
|
import { computeChangeBlastRadius } from "./autosre-change-correlation";
|
|
21
|
-
import { randomHex } from "./hash";
|
|
22
|
+
import { randomHex, sha256Hex } from "./hash";
|
|
22
23
|
import { parseJsonArray, parseJsonRecord } from "./json";
|
|
23
24
|
import type { SqlDb } from "./sql-db";
|
|
24
25
|
|
|
@@ -221,6 +222,97 @@ function resolveSafeCausalProofTier(
|
|
|
221
222
|
return "unverified_locus";
|
|
222
223
|
}
|
|
223
224
|
|
|
225
|
+
export interface FaultLocusSpanRow {
|
|
226
|
+
service_name: string | null;
|
|
227
|
+
span_name: string;
|
|
228
|
+
status_message: string | null;
|
|
229
|
+
error_signature?: string | null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Looks up the span a finding's fault locus refers to (newest match wins). */
|
|
233
|
+
export async function loadFaultLocusSpan(
|
|
234
|
+
db: SqlDb,
|
|
235
|
+
projectId: string,
|
|
236
|
+
environment: string,
|
|
237
|
+
spanId: string,
|
|
238
|
+
traceId?: string,
|
|
239
|
+
): Promise<FaultLocusSpanRow | null> {
|
|
240
|
+
if (!spanId) return null;
|
|
241
|
+
return db
|
|
242
|
+
.prepare(
|
|
243
|
+
`SELECT service_name, span_name, status_message, error_signature
|
|
244
|
+
FROM telemetry_spans
|
|
245
|
+
WHERE project_id = ? AND environment = ? AND span_id = ?
|
|
246
|
+
${traceId ? "AND trace_id = ?" : ""}
|
|
247
|
+
ORDER BY start_time DESC
|
|
248
|
+
LIMIT 1`,
|
|
249
|
+
)
|
|
250
|
+
.bind(
|
|
251
|
+
...(traceId
|
|
252
|
+
? [projectId, environment, spanId, traceId]
|
|
253
|
+
: [projectId, environment, spanId]),
|
|
254
|
+
)
|
|
255
|
+
.first<FaultLocusSpanRow>();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Fingerprint inputs derivable from a span; empty fields are left undefined. */
|
|
259
|
+
export type SpanFingerprintInput = Pick<
|
|
260
|
+
FindingFingerprintInput,
|
|
261
|
+
"environment" | "originatingNodeId" | "downstreamTargetNodeId"
|
|
262
|
+
> &
|
|
263
|
+
Partial<
|
|
264
|
+
Pick<FindingFingerprintInput, "normalizedOperation" | "errorSignature">
|
|
265
|
+
>;
|
|
266
|
+
|
|
267
|
+
/** True for `kind:env:ns:name...` ids built by buildCanonicalNodeId. */
|
|
268
|
+
function looksLikeCanonicalNodeId(id: string): boolean {
|
|
269
|
+
const parts = id.split(":");
|
|
270
|
+
return (
|
|
271
|
+
parts.length >= 4 &&
|
|
272
|
+
(OntologyNodeKindSchema.options as readonly string[]).includes(parts[0])
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Splits a `<traceId>:<spanId>` span locus. Returns null for anything that
|
|
278
|
+
* is a canonical node id or does not have exactly two non-empty parts.
|
|
279
|
+
*/
|
|
280
|
+
function splitTraceSpanLocus(
|
|
281
|
+
id: string,
|
|
282
|
+
): { traceId: string; spanId: string } | null {
|
|
283
|
+
if (looksLikeCanonicalNodeId(id)) return null;
|
|
284
|
+
const parts = id.split(":");
|
|
285
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
|
|
286
|
+
return { traceId: parts[0], spanId: parts[1] };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Stable recurrence-fingerprint inputs for a fault-locus span: the service node,
|
|
291
|
+
* the span name as the operation and the span's error signature (falling back
|
|
292
|
+
* to its status message). No instance ids participate, so two traces failing
|
|
293
|
+
* the same way on the same service/route share a fingerprint. Used by both
|
|
294
|
+
* finding creation and the evidence bundle's prior-finding lookup.
|
|
295
|
+
*/
|
|
296
|
+
export function fingerprintInputFromSpan(
|
|
297
|
+
span: FaultLocusSpanRow,
|
|
298
|
+
environment: string,
|
|
299
|
+
): SpanFingerprintInput {
|
|
300
|
+
return {
|
|
301
|
+
environment,
|
|
302
|
+
originatingNodeId: buildCanonicalNodeId({
|
|
303
|
+
kind: "service",
|
|
304
|
+
environment,
|
|
305
|
+
serviceName: span.service_name || "unknown-service",
|
|
306
|
+
}).id,
|
|
307
|
+
// Empty strings are "absent": returning undefined lets the caller's
|
|
308
|
+
// `??` chain reach its title/summary fallbacks.
|
|
309
|
+
normalizedOperation: span.span_name?.trim() || undefined,
|
|
310
|
+
errorSignature:
|
|
311
|
+
span.error_signature?.trim() || span.status_message?.trim() || undefined,
|
|
312
|
+
downstreamTargetNodeId: null,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
224
316
|
export async function recordInvestigationFinding(
|
|
225
317
|
db: SqlDb,
|
|
226
318
|
projectId: string,
|
|
@@ -237,23 +329,86 @@ export async function recordInvestigationFinding(
|
|
|
237
329
|
.trim()
|
|
238
330
|
.toLowerCase();
|
|
239
331
|
|
|
240
|
-
|
|
241
|
-
|
|
332
|
+
// Idempotent retries: one key maps to one finding id per project and
|
|
333
|
+
// environment, and a replay returns the stored finding untouched.
|
|
334
|
+
const idempotencyKey = input.idempotencyKey?.trim();
|
|
335
|
+
let idempotencyId: string | undefined;
|
|
336
|
+
if (idempotencyKey) {
|
|
337
|
+
idempotencyId = `fnd_k${(await sha256Hex(`${projectId}\u0000${environment}\u0000${idempotencyKey}`)).slice(0, 20)}`;
|
|
338
|
+
const replay = await getInvestigationFinding(
|
|
339
|
+
db,
|
|
340
|
+
projectId,
|
|
341
|
+
idempotencyId,
|
|
342
|
+
environment,
|
|
343
|
+
);
|
|
344
|
+
if (replay) return replay;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Findings pointing at a span id (no colon) must not mint a per-incident
|
|
348
|
+
// fingerprint. Resolve the span to its service/operation/error signature so
|
|
349
|
+
// every incident on the same service + route + error shares one identity.
|
|
350
|
+
// Caller-supplied fingerprint fields always win; unknown spans fall back to
|
|
351
|
+
// treating the locus id as a service name.
|
|
352
|
+
const supplied: Partial<FindingFingerprintInput> =
|
|
353
|
+
input.fingerprintInput ?? {};
|
|
354
|
+
const locusId = input.faultLocusEntityId.trim();
|
|
355
|
+
let spanDerived: Partial<FindingFingerprintInput> = {};
|
|
356
|
+
const traceSpan =
|
|
357
|
+
input.faultLocusEntityKind === "span" ? splitTraceSpanLocus(locusId) : null;
|
|
358
|
+
if (traceSpan) {
|
|
359
|
+
const spanRow = await loadFaultLocusSpan(
|
|
360
|
+
db,
|
|
361
|
+
projectId,
|
|
362
|
+
environment,
|
|
363
|
+
traceSpan.spanId,
|
|
364
|
+
traceSpan.traceId,
|
|
365
|
+
);
|
|
366
|
+
if (spanRow) spanDerived = fingerprintInputFromSpan(spanRow, environment);
|
|
367
|
+
} else if (
|
|
368
|
+
!locusId.includes(":") &&
|
|
369
|
+
input.faultLocusEntityKind !== "service"
|
|
370
|
+
) {
|
|
371
|
+
const spanRow = await loadFaultLocusSpan(
|
|
372
|
+
db,
|
|
373
|
+
projectId,
|
|
374
|
+
environment,
|
|
375
|
+
locusId,
|
|
376
|
+
);
|
|
377
|
+
if (spanRow) spanDerived = fingerprintInputFromSpan(spanRow, environment);
|
|
378
|
+
}
|
|
379
|
+
const defaultOriginNode = locusId.includes(":")
|
|
380
|
+
? traceSpan
|
|
381
|
+
? buildCanonicalNodeId({
|
|
382
|
+
kind: "service",
|
|
383
|
+
environment,
|
|
384
|
+
serviceName: locusId,
|
|
385
|
+
}).id
|
|
386
|
+
: locusId
|
|
242
387
|
: buildCanonicalNodeId({
|
|
243
388
|
kind: "service",
|
|
244
389
|
environment,
|
|
245
|
-
serviceName:
|
|
390
|
+
serviceName: locusId,
|
|
246
391
|
}).id;
|
|
247
392
|
|
|
248
|
-
const fpResult = buildFindingFingerprint(
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
originatingNodeId
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
393
|
+
const fpResult = buildFindingFingerprint({
|
|
394
|
+
environment: supplied.environment ?? environment,
|
|
395
|
+
originatingNodeId:
|
|
396
|
+
supplied.originatingNodeId ??
|
|
397
|
+
spanDerived.originatingNodeId ??
|
|
398
|
+
defaultOriginNode,
|
|
399
|
+
normalizedOperation:
|
|
400
|
+
supplied.normalizedOperation ??
|
|
401
|
+
spanDerived.normalizedOperation ??
|
|
402
|
+
input.title,
|
|
403
|
+
errorSignature:
|
|
404
|
+
supplied.errorSignature ??
|
|
405
|
+
spanDerived.errorSignature ??
|
|
406
|
+
input.summary.slice(0, 120),
|
|
407
|
+
downstreamTargetNodeId:
|
|
408
|
+
supplied.downstreamTargetNodeId ??
|
|
409
|
+
spanDerived.downstreamTargetNodeId ??
|
|
410
|
+
null,
|
|
411
|
+
});
|
|
257
412
|
|
|
258
413
|
// Check for prior verified findings with the same stable fingerprint
|
|
259
414
|
const priorRow = await db
|
|
@@ -346,7 +501,7 @@ export async function recordInvestigationFinding(
|
|
|
346
501
|
finalConfidence: confidence,
|
|
347
502
|
};
|
|
348
503
|
|
|
349
|
-
const id = input.id?.trim() || `fnd_${randomHex(10)}
|
|
504
|
+
const id = idempotencyId ?? (input.id?.trim() || `fnd_${randomHex(10)}`);
|
|
350
505
|
const expiresAt = new Date(Date.now() + 90 * 24 * 3600_000).toISOString();
|
|
351
506
|
|
|
352
507
|
await db
|