@obsunified/collector 2.1.1 → 2.2.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/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
- package/dist/chunk-ZSF5XP3B.js.map +1 -0
- package/dist/index.d.ts +309 -2
- package/dist/index.js +4159 -256
- package/dist/index.js.map +1 -1
- package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
- package/package.json +2 -2
- package/src/__tests__/autosre-closed-loop.test.ts +1275 -0
- package/src/index.ts +43 -0
- package/src/lib/analyses-runner.ts +9 -0
- package/src/lib/autosre-change-correlation.ts +780 -0
- package/src/lib/autosre-findings.ts +725 -0
- package/src/lib/autosre-ontology.ts +914 -0
- package/src/lib/autosre-remediation-gate.ts +1663 -0
- package/src/lib/eval-cases-store.ts +63 -1
- package/src/lib/evidence-references.ts +6 -0
- package/src/lib/housekeeping.test.ts +80 -0
- package/src/lib/housekeeping.ts +101 -1
- package/src/lib/store/ingest.ts +94 -6
- package/src/migrations/050_autosre_causal_ontology.sql +216 -0
- package/src/migrations/alert-state-notified.test.ts +3 -3
- package/src/migrations/parity.test.ts +4 -0
- package/src/migrations/project-scoped-keys.test.ts +1 -1
- package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
- package/src/plugins/alerts-evaluator.ts +17 -1
- package/src/plugins/autosre-routes.ts +1019 -0
- package/src/plugins/connected-routes/manifest.ts +13 -1
- package/src/plugins/connected-routes.ts +271 -0
- package/src/plugins/default-span-enrichment.ts +13 -1
- package/src/plugins/eval-cases-routes.ts +57 -2
- package/src/plugins/evidence-retrieval-routes.ts +300 -4
- package/dist/chunk-XKQTVIRE.js.map +0 -1
- /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
|
@@ -0,0 +1,1275 @@
|
|
|
1
|
+
import { buildCanonicalNodeId, buildFindingFingerprint } from "@obsunified/types";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { CollectorRuntime } from "../framework/collector";
|
|
5
|
+
import type { CollectorEnv } from "../framework/env";
|
|
6
|
+
import {
|
|
7
|
+
computeChangeBlastRadius,
|
|
8
|
+
findCrossTraceContention,
|
|
9
|
+
recordChangeEvent,
|
|
10
|
+
} from "../lib/autosre-change-correlation";
|
|
11
|
+
import {
|
|
12
|
+
computeTarjanScc,
|
|
13
|
+
extractSpanLineageAndTopology,
|
|
14
|
+
localizeTraceFaultLocus,
|
|
15
|
+
upsertOntologyFromSpans,
|
|
16
|
+
} from "../lib/autosre-ontology";
|
|
17
|
+
import { evaluateRemediationWatchdogs } from "../lib/autosre-remediation-gate";
|
|
18
|
+
import {
|
|
19
|
+
createMigratedSqliteDb,
|
|
20
|
+
type NodeSqliteDb,
|
|
21
|
+
} from "../lib/test-utils/node-sqlite-db";
|
|
22
|
+
import { autosreRoutesPlugin } from "../plugins/autosre-routes";
|
|
23
|
+
import { connectedRoutesPlugin } from "../plugins/connected-routes";
|
|
24
|
+
import { evalCasesRoutesPlugin } from "../plugins/eval-cases-routes";
|
|
25
|
+
import { evidenceRetrievalRoutesPlugin } from "../plugins/evidence-retrieval-routes";
|
|
26
|
+
|
|
27
|
+
const createTestApp = (db: NodeSqliteDb) => {
|
|
28
|
+
const app = new Hono<{ Bindings: CollectorEnv }>();
|
|
29
|
+
const runtime = new CollectorRuntime();
|
|
30
|
+
autosreRoutesPlugin.register(app, runtime);
|
|
31
|
+
evalCasesRoutesPlugin.register(app, runtime);
|
|
32
|
+
connectedRoutesPlugin.register(app, runtime);
|
|
33
|
+
evidenceRetrievalRoutesPlugin.register(app, runtime);
|
|
34
|
+
const env: CollectorEnv = {
|
|
35
|
+
DB: db as unknown as D1Database,
|
|
36
|
+
};
|
|
37
|
+
return { app, env };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const insertSpan = (
|
|
41
|
+
db: NodeSqliteDb,
|
|
42
|
+
opts: {
|
|
43
|
+
traceId: string;
|
|
44
|
+
spanId: string;
|
|
45
|
+
parentSpanId?: string | null;
|
|
46
|
+
serviceName: string;
|
|
47
|
+
spanName: string;
|
|
48
|
+
spanKind?: number;
|
|
49
|
+
statusCode?: number;
|
|
50
|
+
statusMessage?: string | null;
|
|
51
|
+
startTime: string;
|
|
52
|
+
endTime: string;
|
|
53
|
+
durationMs: number;
|
|
54
|
+
environment?: string;
|
|
55
|
+
serviceVersion?: string | null;
|
|
56
|
+
commitSha?: string | null;
|
|
57
|
+
targetNodeId?: string | null;
|
|
58
|
+
errorSignature?: string | null;
|
|
59
|
+
attributes?: Record<string, unknown>;
|
|
60
|
+
resourceAttributes?: Record<string, unknown>;
|
|
61
|
+
events?: unknown[];
|
|
62
|
+
},
|
|
63
|
+
) => {
|
|
64
|
+
db.raw
|
|
65
|
+
.prepare(
|
|
66
|
+
`INSERT INTO telemetry_spans (
|
|
67
|
+
project_id, trace_id, span_id, parent_span_id, service_name,
|
|
68
|
+
span_name, span_kind, start_time, end_time, duration_ms,
|
|
69
|
+
status_code, status_message, attributes_json, resource_attributes_json,
|
|
70
|
+
events_json, links_json, received_at, expires_at,
|
|
71
|
+
environment, service_version, commit_sha, target_node_id, error_signature
|
|
72
|
+
) VALUES (
|
|
73
|
+
'default', ?, ?, ?, ?,
|
|
74
|
+
?, ?, ?, ?, ?,
|
|
75
|
+
?, ?, ?, ?,
|
|
76
|
+
?, '[]', ?, ?,
|
|
77
|
+
?, ?, ?, ?, ?
|
|
78
|
+
)`,
|
|
79
|
+
)
|
|
80
|
+
.run(
|
|
81
|
+
opts.traceId,
|
|
82
|
+
opts.spanId,
|
|
83
|
+
opts.parentSpanId ?? null,
|
|
84
|
+
opts.serviceName,
|
|
85
|
+
opts.spanName,
|
|
86
|
+
opts.spanKind ?? 1,
|
|
87
|
+
opts.startTime,
|
|
88
|
+
opts.endTime,
|
|
89
|
+
opts.durationMs,
|
|
90
|
+
opts.statusCode ?? 1,
|
|
91
|
+
opts.statusMessage ?? null,
|
|
92
|
+
JSON.stringify(opts.attributes ?? {}),
|
|
93
|
+
JSON.stringify(opts.resourceAttributes ?? {}),
|
|
94
|
+
JSON.stringify(opts.events ?? []),
|
|
95
|
+
opts.startTime,
|
|
96
|
+
new Date(Date.parse(opts.startTime) + 72 * 3600_000).toISOString(),
|
|
97
|
+
opts.environment ?? "prod",
|
|
98
|
+
opts.serviceVersion ?? null,
|
|
99
|
+
opts.commitSha ?? null,
|
|
100
|
+
opts.targetNodeId ?? null,
|
|
101
|
+
opts.errorSignature ?? null,
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
describe("RFC 0013 — AutoSRE Causal Ontology, Change Correlation & Closed-Loop Incident Memory", () => {
|
|
106
|
+
it("Criterion 1 (P1 #5): isolates environment and host:port in canonical node IDs and caps unscoped DB contention at inferred certainty", async () => {
|
|
107
|
+
const db = createMigratedSqliteDb();
|
|
108
|
+
|
|
109
|
+
const prodCheckout = buildCanonicalNodeId({
|
|
110
|
+
kind: "service",
|
|
111
|
+
environment: "prod",
|
|
112
|
+
namespace: "default",
|
|
113
|
+
serviceName: "checkout-api",
|
|
114
|
+
});
|
|
115
|
+
const stagingCheckout = buildCanonicalNodeId({
|
|
116
|
+
kind: "service",
|
|
117
|
+
environment: "staging",
|
|
118
|
+
namespace: "default",
|
|
119
|
+
serviceName: "checkout-api",
|
|
120
|
+
});
|
|
121
|
+
expect(prodCheckout.id).toBe("service:prod:default:checkout-api");
|
|
122
|
+
expect(stagingCheckout.id).toBe("service:staging:default:checkout-api");
|
|
123
|
+
expect(prodCheckout.id).not.toBe(stagingCheckout.id);
|
|
124
|
+
|
|
125
|
+
// Two DBs named 'orders' on different hosts must not collide
|
|
126
|
+
const primarySpan = {
|
|
127
|
+
projectId: "default",
|
|
128
|
+
traceId: "tr_db_1",
|
|
129
|
+
spanId: "sp_db_1",
|
|
130
|
+
parentSpanId: null,
|
|
131
|
+
traceState: null,
|
|
132
|
+
serviceName: "checkout-api",
|
|
133
|
+
scopeName: "db-client",
|
|
134
|
+
scopeVersion: "1.0.0",
|
|
135
|
+
spanName: "SELECT orders",
|
|
136
|
+
spanKind: 3,
|
|
137
|
+
startTime: "2026-10-15T01:00:00.000Z",
|
|
138
|
+
endTime: "2026-10-15T01:00:00.050Z",
|
|
139
|
+
durationMs: 50,
|
|
140
|
+
statusCode: 1,
|
|
141
|
+
statusMessage: null,
|
|
142
|
+
attributesJson: JSON.stringify({
|
|
143
|
+
"db.system": "postgresql",
|
|
144
|
+
"db.name": "orders",
|
|
145
|
+
"server.address": "pg-primary.internal",
|
|
146
|
+
"server.port": 5432,
|
|
147
|
+
}),
|
|
148
|
+
resourceAttributesJson: JSON.stringify({
|
|
149
|
+
"deployment.environment.name": "prod",
|
|
150
|
+
"service.version": "v2.4.1",
|
|
151
|
+
}),
|
|
152
|
+
eventsJson: "[]",
|
|
153
|
+
linksJson: "[]",
|
|
154
|
+
droppedAttributesCount: 0,
|
|
155
|
+
droppedEventsCount: 0,
|
|
156
|
+
droppedLinksCount: 0,
|
|
157
|
+
receivedAt: "2026-10-15T01:00:00.050Z",
|
|
158
|
+
expiresAt: "2026-10-18T01:00:00.050Z",
|
|
159
|
+
};
|
|
160
|
+
const replicaSpan = {
|
|
161
|
+
...primarySpan,
|
|
162
|
+
traceId: "tr_db_2",
|
|
163
|
+
spanId: "sp_db_2",
|
|
164
|
+
serviceName: "analytics-worker",
|
|
165
|
+
attributesJson: JSON.stringify({
|
|
166
|
+
"db.system": "postgresql",
|
|
167
|
+
"db.name": "orders",
|
|
168
|
+
"server.address": "pg-replica.internal",
|
|
169
|
+
"server.port": 5432,
|
|
170
|
+
}),
|
|
171
|
+
};
|
|
172
|
+
const unscopedSpan = {
|
|
173
|
+
...primarySpan,
|
|
174
|
+
traceId: "tr_db_unscoped",
|
|
175
|
+
spanId: "sp_db_unscoped",
|
|
176
|
+
serviceName: "legacy-worker",
|
|
177
|
+
attributesJson: JSON.stringify({
|
|
178
|
+
"db.system": "postgresql",
|
|
179
|
+
"db.name": "orders",
|
|
180
|
+
}),
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const primaryCtx = extractSpanLineageAndTopology(primarySpan);
|
|
184
|
+
const replicaCtx = extractSpanLineageAndTopology(replicaSpan);
|
|
185
|
+
const unscopedCtx = extractSpanLineageAndTopology(unscopedSpan);
|
|
186
|
+
|
|
187
|
+
expect(primaryCtx.targetNode?.id).toBe(
|
|
188
|
+
"database:prod:postgresql:pg-primary.internal:5432/orders",
|
|
189
|
+
);
|
|
190
|
+
expect(primaryCtx.targetNode?.isScopedIdentity).toBe(true);
|
|
191
|
+
expect(replicaCtx.targetNode?.id).toBe(
|
|
192
|
+
"database:prod:postgresql:pg-replica.internal:5432/orders",
|
|
193
|
+
);
|
|
194
|
+
expect(replicaCtx.targetNode?.isScopedIdentity).toBe(true);
|
|
195
|
+
expect(primaryCtx.targetNode?.id).not.toBe(replicaCtx.targetNode?.id);
|
|
196
|
+
|
|
197
|
+
expect(unscopedCtx.targetNode?.id).toBe(
|
|
198
|
+
"database:prod:unscoped/postgresql:orders",
|
|
199
|
+
);
|
|
200
|
+
expect(unscopedCtx.targetNode?.isScopedIdentity).toBe(false);
|
|
201
|
+
|
|
202
|
+
await upsertOntologyFromSpans(
|
|
203
|
+
db,
|
|
204
|
+
[primarySpan, replicaSpan, unscopedSpan],
|
|
205
|
+
{ bypassCache: true },
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
// Verify unscoped DB contention caps relationshipCertainty at "inferred"
|
|
209
|
+
const baseMs = Date.parse("2026-10-15T01:10:00.000Z");
|
|
210
|
+
const unscopedNodeId = unscopedCtx.targetNode!.id;
|
|
211
|
+
for (let i = 0; i < 6; i++) {
|
|
212
|
+
insertSpan(db, {
|
|
213
|
+
traceId: "tr_aggressor_unscoped",
|
|
214
|
+
spanId: `sp_agg_${i}`,
|
|
215
|
+
serviceName: "checkout-api",
|
|
216
|
+
spanName: "UPDATE orders",
|
|
217
|
+
spanKind: 3,
|
|
218
|
+
statusCode: 1,
|
|
219
|
+
startTime: new Date(baseMs + i * 50).toISOString(),
|
|
220
|
+
endTime: new Date(baseMs + i * 50 + 400).toISOString(),
|
|
221
|
+
durationMs: 400,
|
|
222
|
+
environment: "prod",
|
|
223
|
+
serviceVersion: "v2.4.1",
|
|
224
|
+
targetNodeId: unscopedNodeId,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
insertSpan(db, {
|
|
228
|
+
traceId: "tr_victim_unscoped",
|
|
229
|
+
spanId: "sp_victim_1",
|
|
230
|
+
serviceName: "checkout-api",
|
|
231
|
+
spanName: "SELECT orders",
|
|
232
|
+
spanKind: 3,
|
|
233
|
+
statusCode: 2,
|
|
234
|
+
statusMessage: "Lock wait timeout",
|
|
235
|
+
startTime: new Date(baseMs + 100).toISOString(),
|
|
236
|
+
endTime: new Date(baseMs + 900).toISOString(),
|
|
237
|
+
durationMs: 800,
|
|
238
|
+
environment: "prod",
|
|
239
|
+
serviceVersion: "v2.4.0",
|
|
240
|
+
targetNodeId: unscopedNodeId,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const contention = await findCrossTraceContention(db, {
|
|
244
|
+
projectId: "default",
|
|
245
|
+
environment: "prod",
|
|
246
|
+
targetNodeId: unscopedNodeId,
|
|
247
|
+
excludeTraceId: "tr_victim_unscoped",
|
|
248
|
+
startTime: new Date(baseMs - 1000).toISOString(),
|
|
249
|
+
endTime: new Date(baseMs + 5000).toISOString(),
|
|
250
|
+
});
|
|
251
|
+
expect(contention.length).toBeGreaterThan(0);
|
|
252
|
+
expect(contention[0]?.aggressorTraceId).toBe("tr_aggressor_unscoped");
|
|
253
|
+
expect(contention[0]?.relationshipCertainty).toBe("inferred");
|
|
254
|
+
|
|
255
|
+
db.close();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("Criterion 2 (P2 #6 & P1 #1): condenses cyclic service topology via Tarjan SCC and separates exact structural locus from unverified_locus causal proof", async () => {
|
|
259
|
+
const db = createMigratedSqliteDb();
|
|
260
|
+
const { app, env } = createTestApp(db);
|
|
261
|
+
|
|
262
|
+
// Cyclic graph: service A -> service B -> service A
|
|
263
|
+
const sccResult = computeTarjanScc(
|
|
264
|
+
[
|
|
265
|
+
{
|
|
266
|
+
id: "service:prod:default:svc-a",
|
|
267
|
+
projectId: "default",
|
|
268
|
+
environment: "prod",
|
|
269
|
+
kind: "service",
|
|
270
|
+
name: "svc-a",
|
|
271
|
+
namespaceOrEndpoint: "default",
|
|
272
|
+
isScopedIdentity: true,
|
|
273
|
+
currentVersion: "v1",
|
|
274
|
+
currentCommitSha: null,
|
|
275
|
+
currentConfigDigest: null,
|
|
276
|
+
attributes: {},
|
|
277
|
+
firstSeenAt: "2026-10-15T01:00:00.000Z",
|
|
278
|
+
lastSeenAt: "2026-10-15T01:00:00.000Z",
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
id: "service:prod:default:svc-b",
|
|
282
|
+
projectId: "default",
|
|
283
|
+
environment: "prod",
|
|
284
|
+
kind: "service",
|
|
285
|
+
name: "svc-b",
|
|
286
|
+
namespaceOrEndpoint: "default",
|
|
287
|
+
isScopedIdentity: true,
|
|
288
|
+
currentVersion: "v1",
|
|
289
|
+
currentCommitSha: null,
|
|
290
|
+
currentConfigDigest: null,
|
|
291
|
+
attributes: {},
|
|
292
|
+
firstSeenAt: "2026-10-15T01:00:00.000Z",
|
|
293
|
+
lastSeenAt: "2026-10-15T01:00:00.000Z",
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
[
|
|
297
|
+
{
|
|
298
|
+
id: "e1",
|
|
299
|
+
projectId: "default",
|
|
300
|
+
environment: "prod",
|
|
301
|
+
sourceNodeId: "service:prod:default:svc-a",
|
|
302
|
+
predicate: "calls",
|
|
303
|
+
targetNodeId: "service:prod:default:svc-b",
|
|
304
|
+
relationshipCertainty: "exact",
|
|
305
|
+
callCount: 10,
|
|
306
|
+
errorCount: 2,
|
|
307
|
+
lastDurationMs: 100,
|
|
308
|
+
firstSeenAt: "2026-10-15T01:00:00.000Z",
|
|
309
|
+
lastSeenAt: "2026-10-15T01:00:00.000Z",
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
id: "e2",
|
|
313
|
+
projectId: "default",
|
|
314
|
+
environment: "prod",
|
|
315
|
+
sourceNodeId: "service:prod:default:svc-b",
|
|
316
|
+
predicate: "calls",
|
|
317
|
+
targetNodeId: "service:prod:default:svc-a",
|
|
318
|
+
relationshipCertainty: "exact",
|
|
319
|
+
callCount: 10,
|
|
320
|
+
errorCount: 2,
|
|
321
|
+
lastDurationMs: 90,
|
|
322
|
+
firstSeenAt: "2026-10-15T01:00:00.000Z",
|
|
323
|
+
lastSeenAt: "2026-10-15T01:00:00.000Z",
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
expect(sccResult.components).toHaveLength(1);
|
|
329
|
+
expect(sccResult.components[0]?.isCyclic).toBe(true);
|
|
330
|
+
expect(sccResult.annotatedNodes.every((n) => n.inCycle === true)).toBe(
|
|
331
|
+
true,
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
// Cyclic span tree within a trace must also terminate deterministically
|
|
335
|
+
const locus = localizeTraceFaultLocus("tr_cyclic", [
|
|
336
|
+
{
|
|
337
|
+
span_id: "sp_root",
|
|
338
|
+
parent_span_id: "sp_leaf", // cycle in span parent pointers
|
|
339
|
+
service_name: "svc-a",
|
|
340
|
+
span_name: "POST /checkout",
|
|
341
|
+
span_kind: 1,
|
|
342
|
+
status_code: 2,
|
|
343
|
+
status_message: "500 Internal Error",
|
|
344
|
+
start_time: "2026-10-15T01:00:00.000Z",
|
|
345
|
+
end_time: "2026-10-15T01:00:01.000Z",
|
|
346
|
+
duration_ms: 1000,
|
|
347
|
+
environment: "prod",
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
span_id: "sp_leaf",
|
|
351
|
+
parent_span_id: "sp_root",
|
|
352
|
+
service_name: "svc-b",
|
|
353
|
+
span_name: "SELECT inventory",
|
|
354
|
+
span_kind: 3,
|
|
355
|
+
status_code: 2,
|
|
356
|
+
status_message: "Deadlock detected",
|
|
357
|
+
start_time: "2026-10-15T01:00:00.100Z",
|
|
358
|
+
end_time: "2026-10-15T01:00:00.900Z",
|
|
359
|
+
duration_ms: 800,
|
|
360
|
+
environment: "prod",
|
|
361
|
+
},
|
|
362
|
+
]);
|
|
363
|
+
|
|
364
|
+
expect(locus.faultLocusSpan?.span_id).toBe("sp_leaf");
|
|
365
|
+
expect(locus.relationshipCertainty).toBe("exact");
|
|
366
|
+
expect(locus.causalProofTier).toBe("unverified_locus");
|
|
367
|
+
|
|
368
|
+
// Record an unverified_locus finding and verify rollback_deploy is blocked_by_policy (P1 #1)
|
|
369
|
+
const incRes = await app.request(
|
|
370
|
+
"/internal/incidents",
|
|
371
|
+
{
|
|
372
|
+
method: "POST",
|
|
373
|
+
headers: { "Content-Type": "application/json" },
|
|
374
|
+
body: JSON.stringify({
|
|
375
|
+
environment: "prod",
|
|
376
|
+
targetNodeId: "service:prod:default:svc-a",
|
|
377
|
+
symptomAnchorKind: "trace",
|
|
378
|
+
symptomAnchorId: "tr_cyclic",
|
|
379
|
+
}),
|
|
380
|
+
},
|
|
381
|
+
env,
|
|
382
|
+
);
|
|
383
|
+
expect(incRes.status).toBe(201);
|
|
384
|
+
const { incident } = (await incRes.json()) as {
|
|
385
|
+
incident: { id: string };
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const findingRes = await app.request(
|
|
389
|
+
"/internal/findings",
|
|
390
|
+
{
|
|
391
|
+
method: "POST",
|
|
392
|
+
headers: { "Content-Type": "application/json" },
|
|
393
|
+
body: JSON.stringify({
|
|
394
|
+
environment: "prod",
|
|
395
|
+
incidentId: incident.id,
|
|
396
|
+
title: "Fault locus at sp_leaf",
|
|
397
|
+
summary: "Error surfaced at sp_leaf without change correlation",
|
|
398
|
+
causalProofTier: "unverified_locus",
|
|
399
|
+
symptomEntityKind: "trace",
|
|
400
|
+
symptomEntityId: "tr_cyclic",
|
|
401
|
+
faultLocusEntityKind: "span",
|
|
402
|
+
faultLocusEntityId: "sp_leaf",
|
|
403
|
+
causalChain: locus.causalTriples,
|
|
404
|
+
citedEvidenceIds: ["tr_cyclic", "sp_leaf"],
|
|
405
|
+
}),
|
|
406
|
+
},
|
|
407
|
+
env,
|
|
408
|
+
);
|
|
409
|
+
expect(findingRes.status).toBe(201);
|
|
410
|
+
const { finding } = (await findingRes.json()) as {
|
|
411
|
+
finding: { id: string; causalProofTier: string };
|
|
412
|
+
};
|
|
413
|
+
expect(finding.causalProofTier).toBe("unverified_locus");
|
|
414
|
+
|
|
415
|
+
const proposeRes = await app.request(
|
|
416
|
+
"/internal/remediations/propose",
|
|
417
|
+
{
|
|
418
|
+
method: "POST",
|
|
419
|
+
headers: { "Content-Type": "application/json" },
|
|
420
|
+
body: JSON.stringify({
|
|
421
|
+
environment: "prod",
|
|
422
|
+
incidentId: incident.id,
|
|
423
|
+
findingId: finding.id,
|
|
424
|
+
idempotencyKey: "idem_unverified_rollback",
|
|
425
|
+
actionKind: "rollback_deploy",
|
|
426
|
+
targetNodeId: "service:prod:default:svc-a",
|
|
427
|
+
expectedCurrentState: { version: "v1" },
|
|
428
|
+
desiredTargetState: { version: "v0" },
|
|
429
|
+
requestedAutonomy: "autonomous_write",
|
|
430
|
+
}),
|
|
431
|
+
},
|
|
432
|
+
env,
|
|
433
|
+
);
|
|
434
|
+
expect(proposeRes.status).toBe(201);
|
|
435
|
+
const { proposal, grantToken } = (await proposeRes.json()) as {
|
|
436
|
+
proposal: { effectiveAutonomy: string; status: string };
|
|
437
|
+
grantToken: string | null;
|
|
438
|
+
};
|
|
439
|
+
expect(proposal.effectiveAutonomy).toBe("blocked_by_policy");
|
|
440
|
+
expect(proposal.status).toBe("rejected");
|
|
441
|
+
expect(grantToken).toBeNull();
|
|
442
|
+
|
|
443
|
+
db.close();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("Criterion 3 (P1 #2): computes traffic-normalized cohort error rate lift and handles equal error rates, low sample sizes, and shared DB degradation", async () => {
|
|
447
|
+
const db = createMigratedSqliteDb();
|
|
448
|
+
|
|
449
|
+
const t0 = Date.parse("2026-10-15T02:00:00.000Z");
|
|
450
|
+
const insertCohortSpans = (
|
|
451
|
+
serviceName: string,
|
|
452
|
+
version: string,
|
|
453
|
+
total: number,
|
|
454
|
+
errors: number,
|
|
455
|
+
dbCallsPerTrace = 0,
|
|
456
|
+
offsetMs = 1000,
|
|
457
|
+
) => {
|
|
458
|
+
for (let i = 0; i < total; i++) {
|
|
459
|
+
const traceId = `tr_${serviceName}_${version}_${i}`;
|
|
460
|
+
const isError = i < errors;
|
|
461
|
+
const startIso = new Date(t0 + offsetMs + i * 50).toISOString();
|
|
462
|
+
const endIso = new Date(t0 + offsetMs + i * 50 + 40).toISOString();
|
|
463
|
+
insertSpan(db, {
|
|
464
|
+
traceId,
|
|
465
|
+
spanId: `sp_root_${serviceName}_${version}_${i}`,
|
|
466
|
+
serviceName,
|
|
467
|
+
spanName: "POST /checkout",
|
|
468
|
+
spanKind: 1, // SERVER span
|
|
469
|
+
statusCode: isError ? 2 : 1,
|
|
470
|
+
statusMessage: isError ? "Error" : null,
|
|
471
|
+
startTime: startIso,
|
|
472
|
+
endTime: endIso,
|
|
473
|
+
durationMs: 40,
|
|
474
|
+
environment: "prod",
|
|
475
|
+
serviceVersion: version,
|
|
476
|
+
});
|
|
477
|
+
for (let d = 0; d < dbCallsPerTrace; d++) {
|
|
478
|
+
insertSpan(db, {
|
|
479
|
+
traceId,
|
|
480
|
+
spanId: `sp_db_${serviceName}_${version}_${i}_${d}`,
|
|
481
|
+
parentSpanId: `sp_root_${serviceName}_${version}_${i}`,
|
|
482
|
+
serviceName,
|
|
483
|
+
spanName: "SELECT inventory",
|
|
484
|
+
spanKind: 2, // CLIENT span
|
|
485
|
+
statusCode: 1,
|
|
486
|
+
startTime: startIso,
|
|
487
|
+
endTime: endIso,
|
|
488
|
+
durationMs: 20,
|
|
489
|
+
environment: "prod",
|
|
490
|
+
serviceVersion: version,
|
|
491
|
+
targetNodeId: "database:prod:postgresql:pg.internal:5432/orders",
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
// Case A: 95% traffic on v2.4.1 (950 spans, 95 errors = 10%) vs 5% traffic on v2.4.0 (50 spans, 5 errors = 10%)
|
|
498
|
+
// Must NOT blame v2.4.1 just because it has 95% of raw errors!
|
|
499
|
+
await recordChangeEvent(db, "default", {
|
|
500
|
+
id: "chg_equal_rate",
|
|
501
|
+
environment: "prod",
|
|
502
|
+
kind: "deploy",
|
|
503
|
+
serviceName: "svc-equal-rate",
|
|
504
|
+
version: "v2.4.1",
|
|
505
|
+
previousVersion: "v2.4.0",
|
|
506
|
+
summary: "Deploy svc-equal-rate v2.4.1",
|
|
507
|
+
occurredAt: new Date(t0).toISOString(),
|
|
508
|
+
});
|
|
509
|
+
insertCohortSpans("svc-equal-rate", "v2.4.1", 950, 95);
|
|
510
|
+
insertCohortSpans("svc-equal-rate", "v2.4.0", 50, 5);
|
|
511
|
+
|
|
512
|
+
const equalRateResult = await computeChangeBlastRadius(
|
|
513
|
+
db,
|
|
514
|
+
"default",
|
|
515
|
+
"chg_equal_rate",
|
|
516
|
+
{ environment: "prod" },
|
|
517
|
+
);
|
|
518
|
+
expect(equalRateResult?.cohortVerdict).toBe("no_regression_detected");
|
|
519
|
+
expect(equalRateResult?.relativeRisk).toBeCloseTo(1.0, 2);
|
|
520
|
+
expect(equalRateResult?.causalProofTier).toBe("unverified_locus");
|
|
521
|
+
|
|
522
|
+
// Case B: Insufficient sample size (N < 50)
|
|
523
|
+
await recordChangeEvent(db, "default", {
|
|
524
|
+
id: "chg_small_sample",
|
|
525
|
+
environment: "prod",
|
|
526
|
+
kind: "deploy",
|
|
527
|
+
serviceName: "svc-small-sample",
|
|
528
|
+
version: "v2.4.1",
|
|
529
|
+
previousVersion: "v2.4.0",
|
|
530
|
+
summary: "Deploy svc-small-sample v2.4.1",
|
|
531
|
+
occurredAt: new Date(t0).toISOString(),
|
|
532
|
+
});
|
|
533
|
+
insertCohortSpans("svc-small-sample", "v2.4.1", 20, 10);
|
|
534
|
+
insertCohortSpans("svc-small-sample", "v2.4.0", 20, 0);
|
|
535
|
+
|
|
536
|
+
const smallSampleResult = await computeChangeBlastRadius(
|
|
537
|
+
db,
|
|
538
|
+
"default",
|
|
539
|
+
"chg_small_sample",
|
|
540
|
+
{ environment: "prod" },
|
|
541
|
+
);
|
|
542
|
+
expect(smallSampleResult?.cohortVerdict).toBe("insufficient_sample");
|
|
543
|
+
|
|
544
|
+
// Case C: Shared downstream degradation where BOTH v2.4.1 and v2.4.0 fail at 20%,
|
|
545
|
+
// but v2.4.1 has 5x downstream DB call fanout per trace (noisy-neighbor aggressor!)
|
|
546
|
+
await recordChangeEvent(db, "default", {
|
|
547
|
+
id: "chg_noisy",
|
|
548
|
+
environment: "prod",
|
|
549
|
+
kind: "deploy",
|
|
550
|
+
serviceName: "svc-noisy",
|
|
551
|
+
version: "v2.4.1",
|
|
552
|
+
previousVersion: "v2.4.0",
|
|
553
|
+
summary: "Deploy svc-noisy v2.4.1",
|
|
554
|
+
occurredAt: new Date(t0).toISOString(),
|
|
555
|
+
});
|
|
556
|
+
insertCohortSpans("svc-noisy", "v2.4.1", 60, 12, 5);
|
|
557
|
+
insertCohortSpans("svc-noisy", "v2.4.0", 60, 12, 1);
|
|
558
|
+
|
|
559
|
+
const noisyResult = await computeChangeBlastRadius(
|
|
560
|
+
db,
|
|
561
|
+
"default",
|
|
562
|
+
"chg_noisy",
|
|
563
|
+
{ environment: "prod" },
|
|
564
|
+
);
|
|
565
|
+
expect(noisyResult?.cohortVerdict).toBe(
|
|
566
|
+
"shared_degradation_new_version_aggressor",
|
|
567
|
+
);
|
|
568
|
+
expect(noisyResult?.causalProofTier).toBe("corroborated_candidate");
|
|
569
|
+
expect(noisyResult?.sharedResourceAggressors.length).toBeGreaterThan(0);
|
|
570
|
+
|
|
571
|
+
// Case D: Isolated regression on v2.4.1 (20% error rate vs 1.6% on v2.4.0, N >= 50)
|
|
572
|
+
await recordChangeEvent(db, "default", {
|
|
573
|
+
id: "chg_isolated",
|
|
574
|
+
environment: "prod",
|
|
575
|
+
kind: "deploy",
|
|
576
|
+
serviceName: "svc-isolated",
|
|
577
|
+
version: "v2.4.1",
|
|
578
|
+
previousVersion: "v2.4.0",
|
|
579
|
+
summary: "Deploy svc-isolated v2.4.1",
|
|
580
|
+
occurredAt: new Date(t0).toISOString(),
|
|
581
|
+
});
|
|
582
|
+
insertCohortSpans("svc-isolated", "v2.4.1", 60, 12);
|
|
583
|
+
insertCohortSpans("svc-isolated", "v2.4.0", 60, 1);
|
|
584
|
+
|
|
585
|
+
const isolatedResult = await computeChangeBlastRadius(
|
|
586
|
+
db,
|
|
587
|
+
"default",
|
|
588
|
+
"chg_isolated",
|
|
589
|
+
{ environment: "prod" },
|
|
590
|
+
);
|
|
591
|
+
expect(isolatedResult?.cohortVerdict).toBe("isolated_to_new_version");
|
|
592
|
+
expect(isolatedResult?.causalProofTier).toBe("cohort_rate_isolated");
|
|
593
|
+
|
|
594
|
+
db.close();
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("Criterion 6 (P2 #8): normalizes dynamic IDs in buildFindingFingerprint so recurring incidents produce the exact same fingerprint", () => {
|
|
598
|
+
const fp1 = buildFindingFingerprint({
|
|
599
|
+
environment: "prod",
|
|
600
|
+
originatingNodeId: "service:prod:default:checkout-api",
|
|
601
|
+
normalizedOperation:
|
|
602
|
+
"POST /v1/orders/123e4567-e89b-12d3-a456-426614174000",
|
|
603
|
+
errorSignature:
|
|
604
|
+
"LockWaitTimeoutError: Lock wait timeout 5012ms for order 123e4567-e89b-12d3-a456-426614174000 at 2026-10-15T01:00:00Z",
|
|
605
|
+
downstreamTargetNodeId:
|
|
606
|
+
"database:prod:postgresql:pg-primary.internal:5432/orders",
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
const fp2 = buildFindingFingerprint({
|
|
610
|
+
environment: "prod",
|
|
611
|
+
originatingNodeId: "service:prod:default:checkout-api",
|
|
612
|
+
normalizedOperation:
|
|
613
|
+
"POST /v1/orders/987fcdeb-51a2-43d7-9012-345678901234",
|
|
614
|
+
errorSignature:
|
|
615
|
+
"LockWaitTimeoutError: Lock wait timeout 5489ms for order 987fcdeb-51a2-43d7-9012-345678901234 at 2026-10-15T03:15:22Z",
|
|
616
|
+
downstreamTargetNodeId:
|
|
617
|
+
"database:prod:postgresql:pg-primary.internal:5432/orders",
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
expect(fp1.fingerprint).toBe(fp2.fingerprint);
|
|
621
|
+
expect(fp1.normalizedInput).toEqual(fp2.normalizedInput);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it("Criteria 4, 5 & End-to-End 6-Hop Closed-Loop Flow (P1 #1-#5, P2 #6-#8): executes full symptom -> fault locus -> cohort & contention -> paired counterfactual -> governed remediation -> post-remediation watchdog & recurrence memory", async () => {
|
|
625
|
+
const db = createMigratedSqliteDb();
|
|
626
|
+
const { app, env } = createTestApp(db);
|
|
627
|
+
|
|
628
|
+
const deployTime = Date.parse("2026-10-15T03:00:00.000Z");
|
|
629
|
+
|
|
630
|
+
// 1. Seed concurrent v2.4.0 traffic (60 spans, 1 error = 1.67% error rate)
|
|
631
|
+
// and canary v2.4.1 traffic (60 spans, 15 errors = 25% error rate) with code stackframe in src/checkout/inventory.ts
|
|
632
|
+
const dbNodeId =
|
|
633
|
+
"database:prod:postgresql:pg-primary.internal:5432/orders";
|
|
634
|
+
const serviceNodeId = "service:prod:default:checkout-api";
|
|
635
|
+
|
|
636
|
+
for (let i = 0; i < 60; i++) {
|
|
637
|
+
const startIso = new Date(deployTime + (i + 1) * 1000).toISOString();
|
|
638
|
+
const endIso = new Date(deployTime + (i + 1) * 1000 + 40).toISOString();
|
|
639
|
+
insertSpan(db, {
|
|
640
|
+
traceId: `tr_base_${i}`,
|
|
641
|
+
spanId: `sp_base_${i}`,
|
|
642
|
+
serviceName: "checkout-api",
|
|
643
|
+
spanName: "POST /checkout",
|
|
644
|
+
spanKind: 1,
|
|
645
|
+
statusCode: i === 0 ? 2 : 1,
|
|
646
|
+
statusMessage: i === 0 ? "Transient error" : null,
|
|
647
|
+
startTime: startIso,
|
|
648
|
+
endTime: endIso,
|
|
649
|
+
durationMs: 40,
|
|
650
|
+
environment: "prod",
|
|
651
|
+
serviceVersion: "v2.4.0",
|
|
652
|
+
commitSha: "1111111",
|
|
653
|
+
targetNodeId: dbNodeId,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
for (let i = 0; i < 60; i++) {
|
|
658
|
+
const isError = i < 15;
|
|
659
|
+
const startIso = new Date(deployTime + (i + 1) * 1000).toISOString();
|
|
660
|
+
const endIso = new Date(deployTime + (i + 1) * 1000 + 850).toISOString();
|
|
661
|
+
insertSpan(db, {
|
|
662
|
+
traceId: `tr_new_${i}`,
|
|
663
|
+
spanId: `sp_new_${i}`,
|
|
664
|
+
serviceName: "checkout-api",
|
|
665
|
+
spanName: "POST /checkout",
|
|
666
|
+
spanKind: 1,
|
|
667
|
+
statusCode: isError ? 2 : 1,
|
|
668
|
+
statusMessage: isError
|
|
669
|
+
? `LockWaitTimeoutError: timeout 5000ms on order 123e4567-e89b-12d3-a456-426614174000`
|
|
670
|
+
: null,
|
|
671
|
+
startTime: startIso,
|
|
672
|
+
endTime: endIso,
|
|
673
|
+
durationMs: isError ? 850 : 45,
|
|
674
|
+
environment: "prod",
|
|
675
|
+
serviceVersion: "v2.4.1",
|
|
676
|
+
commitSha: "8f3a1b2",
|
|
677
|
+
targetNodeId: dbNodeId,
|
|
678
|
+
errorSignature: isError
|
|
679
|
+
? "LockWaitTimeoutError: timeout <num> on order <uuid>"
|
|
680
|
+
: null,
|
|
681
|
+
attributes: isError
|
|
682
|
+
? {
|
|
683
|
+
"code.filepath": "src/checkout/inventory.ts",
|
|
684
|
+
"code.function": "reserveInventoryBatch",
|
|
685
|
+
}
|
|
686
|
+
: {},
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Record explicit deploy ChangeEvent via POST /v1/changes
|
|
691
|
+
const changeRes = await app.request(
|
|
692
|
+
"/v1/changes",
|
|
693
|
+
{
|
|
694
|
+
method: "POST",
|
|
695
|
+
headers: { "Content-Type": "application/json" },
|
|
696
|
+
body: JSON.stringify({
|
|
697
|
+
id: "chg_deploy_241",
|
|
698
|
+
environment: "prod",
|
|
699
|
+
kind: "deploy",
|
|
700
|
+
source: "ci_webhook",
|
|
701
|
+
serviceName: "checkout-api",
|
|
702
|
+
version: "v2.4.1",
|
|
703
|
+
previousVersion: "v2.4.0",
|
|
704
|
+
commitSha: "8f3a1b2",
|
|
705
|
+
previousCommitSha: "1111111",
|
|
706
|
+
summary: "Deploy checkout-api v2.4.1 (batch inventory lock)",
|
|
707
|
+
touchedPaths: [
|
|
708
|
+
"src/checkout/inventory.ts",
|
|
709
|
+
"src/checkout/routes.ts",
|
|
710
|
+
],
|
|
711
|
+
occurredAt: new Date(deployTime).toISOString(),
|
|
712
|
+
}),
|
|
713
|
+
},
|
|
714
|
+
env,
|
|
715
|
+
);
|
|
716
|
+
expect(changeRes.status).toBe(201);
|
|
717
|
+
|
|
718
|
+
// Verify GET /internal/changes/:id/blast-radius computes cohort lift + code-path overlap
|
|
719
|
+
const blastRes = await app.request(
|
|
720
|
+
"/internal/changes/chg_deploy_241/blast-radius?environment=prod",
|
|
721
|
+
{ method: "GET" },
|
|
722
|
+
env,
|
|
723
|
+
);
|
|
724
|
+
expect(blastRes.status).toBe(200);
|
|
725
|
+
const { blastRadius } = (await blastRes.json()) as {
|
|
726
|
+
blastRadius: NonNullable<
|
|
727
|
+
Awaited<ReturnType<typeof computeChangeBlastRadius>>
|
|
728
|
+
>;
|
|
729
|
+
};
|
|
730
|
+
expect(blastRadius.cohortVerdict).toBe("isolated_to_new_version");
|
|
731
|
+
expect(blastRadius.causalProofTier).toBe("cohort_rate_isolated");
|
|
732
|
+
expect(blastRadius.overlappingCodePaths).toContain(
|
|
733
|
+
"src/checkout/inventory.ts",
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
// 2. Incident deduplication & runner lease coordination (P2 #7)
|
|
737
|
+
const inc1Res = await app.request(
|
|
738
|
+
"/internal/incidents",
|
|
739
|
+
{
|
|
740
|
+
method: "POST",
|
|
741
|
+
headers: { "Content-Type": "application/json" },
|
|
742
|
+
body: JSON.stringify({
|
|
743
|
+
environment: "prod",
|
|
744
|
+
targetNodeId: serviceNodeId,
|
|
745
|
+
symptomAnchorKind: "trace",
|
|
746
|
+
symptomAnchorId: "tr_new_0",
|
|
747
|
+
symptomFingerprint: "fp_checkout_lock_timeout",
|
|
748
|
+
}),
|
|
749
|
+
},
|
|
750
|
+
env,
|
|
751
|
+
);
|
|
752
|
+
expect(inc1Res.status).toBe(201);
|
|
753
|
+
const inc1Body = (await inc1Res.json()) as {
|
|
754
|
+
incident: { id: string; alertCount: number };
|
|
755
|
+
deduplicated: boolean;
|
|
756
|
+
};
|
|
757
|
+
expect(inc1Body.deduplicated).toBe(false);
|
|
758
|
+
expect(inc1Body.incident.alertCount).toBe(1);
|
|
759
|
+
|
|
760
|
+
// Second identical alert coalesces into the same active incident
|
|
761
|
+
const inc2Res = await app.request(
|
|
762
|
+
"/internal/incidents",
|
|
763
|
+
{
|
|
764
|
+
method: "POST",
|
|
765
|
+
headers: { "Content-Type": "application/json" },
|
|
766
|
+
body: JSON.stringify({
|
|
767
|
+
environment: "prod",
|
|
768
|
+
targetNodeId: serviceNodeId,
|
|
769
|
+
symptomAnchorKind: "trace",
|
|
770
|
+
symptomAnchorId: "tr_new_1",
|
|
771
|
+
symptomFingerprint: "fp_checkout_lock_timeout",
|
|
772
|
+
}),
|
|
773
|
+
},
|
|
774
|
+
env,
|
|
775
|
+
);
|
|
776
|
+
expect(inc2Res.status).toBe(200);
|
|
777
|
+
const inc2Body = (await inc2Res.json()) as {
|
|
778
|
+
incident: { id: string; alertCount: number };
|
|
779
|
+
deduplicated: boolean;
|
|
780
|
+
};
|
|
781
|
+
expect(inc2Body.deduplicated).toBe(true);
|
|
782
|
+
expect(inc2Body.incident.id).toBe(inc1Body.incident.id);
|
|
783
|
+
expect(inc2Body.incident.alertCount).toBe(2);
|
|
784
|
+
|
|
785
|
+
const incidentId = inc1Body.incident.id;
|
|
786
|
+
|
|
787
|
+
// Runner 1 acquires lease; Runner 2 gets 409 Conflict
|
|
788
|
+
const lease1 = await app.request(
|
|
789
|
+
`/internal/incidents/${incidentId}/lease`,
|
|
790
|
+
{
|
|
791
|
+
method: "POST",
|
|
792
|
+
headers: { "Content-Type": "application/json" },
|
|
793
|
+
body: JSON.stringify({
|
|
794
|
+
environment: "prod",
|
|
795
|
+
runnerId: "runner-alpha",
|
|
796
|
+
ttlSeconds: 300,
|
|
797
|
+
}),
|
|
798
|
+
},
|
|
799
|
+
env,
|
|
800
|
+
);
|
|
801
|
+
expect(lease1.status).toBe(200);
|
|
802
|
+
|
|
803
|
+
const lease2 = await app.request(
|
|
804
|
+
`/internal/incidents/${incidentId}/lease`,
|
|
805
|
+
{
|
|
806
|
+
method: "POST",
|
|
807
|
+
headers: { "Content-Type": "application/json" },
|
|
808
|
+
body: JSON.stringify({
|
|
809
|
+
environment: "prod",
|
|
810
|
+
runnerId: "runner-beta",
|
|
811
|
+
ttlSeconds: 300,
|
|
812
|
+
}),
|
|
813
|
+
},
|
|
814
|
+
env,
|
|
815
|
+
);
|
|
816
|
+
expect(lease2.status).toBe(409);
|
|
817
|
+
|
|
818
|
+
// 3. Record InvestigationFinding at corroborated_candidate first to test human approval gate (P1 #4)
|
|
819
|
+
const findingRes = await app.request(
|
|
820
|
+
"/internal/findings",
|
|
821
|
+
{
|
|
822
|
+
method: "POST",
|
|
823
|
+
headers: { "Content-Type": "application/json" },
|
|
824
|
+
body: JSON.stringify({
|
|
825
|
+
environment: "prod",
|
|
826
|
+
incidentId,
|
|
827
|
+
title:
|
|
828
|
+
"Checkout lock timeout caused by v2.4.1 batch lock in inventory.ts",
|
|
829
|
+
summary:
|
|
830
|
+
"Deploy v2.4.1 introduced batch lock contention in reserveInventoryBatch",
|
|
831
|
+
causalProofTier: "corroborated_candidate",
|
|
832
|
+
symptomEntityKind: "trace",
|
|
833
|
+
symptomEntityId: "tr_new_0",
|
|
834
|
+
faultLocusEntityKind: "span",
|
|
835
|
+
faultLocusEntityId: "sp_new_0",
|
|
836
|
+
rootCauseEntityKind: "change",
|
|
837
|
+
rootCauseEntityId: "chg_deploy_241",
|
|
838
|
+
contributingChangeId: "chg_deploy_241",
|
|
839
|
+
cohortVerdict: "shared_degradation_new_version_aggressor",
|
|
840
|
+
causalChain: [
|
|
841
|
+
{
|
|
842
|
+
subjectKind: "trace",
|
|
843
|
+
subjectId: "tr_new_0",
|
|
844
|
+
predicate: "originated_fault_in_span",
|
|
845
|
+
objectKind: "span",
|
|
846
|
+
objectId: "sp_new_0",
|
|
847
|
+
relationshipCertainty: "exact",
|
|
848
|
+
causalProofTier: "unverified_locus",
|
|
849
|
+
confidence: 0.9,
|
|
850
|
+
reason: "Fault locus",
|
|
851
|
+
},
|
|
852
|
+
{
|
|
853
|
+
subjectKind: "span",
|
|
854
|
+
subjectId: "sp_new_0",
|
|
855
|
+
predicate: "stackframe_overlaps_changed_file",
|
|
856
|
+
objectKind: "change",
|
|
857
|
+
objectId: "chg_deploy_241",
|
|
858
|
+
relationshipCertainty: "exact",
|
|
859
|
+
causalProofTier: "corroborated_candidate",
|
|
860
|
+
confidence: 0.85,
|
|
861
|
+
reason: "Stackframe overlaps src/checkout/inventory.ts",
|
|
862
|
+
},
|
|
863
|
+
],
|
|
864
|
+
citedEvidenceIds: ["tr_new_0", "sp_new_0", "chg_deploy_241"],
|
|
865
|
+
}),
|
|
866
|
+
},
|
|
867
|
+
env,
|
|
868
|
+
);
|
|
869
|
+
expect(findingRes.status).toBe(201);
|
|
870
|
+
const { finding } = (await findingRes.json()) as {
|
|
871
|
+
finding: { id: string; fingerprint: string; causalProofTier: string };
|
|
872
|
+
};
|
|
873
|
+
expect(finding.causalProofTier).toBe("corroborated_candidate");
|
|
874
|
+
|
|
875
|
+
// Proposing rollback_deploy on corroborated_candidate must require human_approved_write
|
|
876
|
+
const propHumanRes = await app.request(
|
|
877
|
+
"/internal/remediations/propose",
|
|
878
|
+
{
|
|
879
|
+
method: "POST",
|
|
880
|
+
headers: { "Content-Type": "application/json" },
|
|
881
|
+
body: JSON.stringify({
|
|
882
|
+
environment: "prod",
|
|
883
|
+
incidentId,
|
|
884
|
+
findingId: finding.id,
|
|
885
|
+
idempotencyKey: "idem_human_rollback_1",
|
|
886
|
+
actionKind: "rollback_deploy",
|
|
887
|
+
targetNodeId: serviceNodeId,
|
|
888
|
+
expectedCurrentState: { version: "v2.4.1" },
|
|
889
|
+
desiredTargetState: { version: "v2.4.0" },
|
|
890
|
+
requestedAutonomy: "autonomous_write",
|
|
891
|
+
}),
|
|
892
|
+
},
|
|
893
|
+
env,
|
|
894
|
+
);
|
|
895
|
+
expect(propHumanRes.status).toBe(201);
|
|
896
|
+
const propHumanBody = (await propHumanRes.json()) as {
|
|
897
|
+
proposal: { id: string; effectiveAutonomy: string; status: string };
|
|
898
|
+
grantToken: string | null;
|
|
899
|
+
};
|
|
900
|
+
expect(propHumanBody.proposal.effectiveAutonomy).toBe(
|
|
901
|
+
"human_approved_write",
|
|
902
|
+
);
|
|
903
|
+
expect(propHumanBody.proposal.status).toBe("pending_human_approval");
|
|
904
|
+
expect(propHumanBody.grantToken).toBeNull();
|
|
905
|
+
|
|
906
|
+
// Agent attempting to self-approve with Authorization: Bearer must get 403 Forbidden (P1 #4)
|
|
907
|
+
const agentApproveRes = await app.request(
|
|
908
|
+
`/internal/remediations/${propHumanBody.proposal.id}/approve`,
|
|
909
|
+
{
|
|
910
|
+
method: "POST",
|
|
911
|
+
headers: {
|
|
912
|
+
"Content-Type": "application/json",
|
|
913
|
+
Authorization: "Bearer obs_tok_agent_secret",
|
|
914
|
+
},
|
|
915
|
+
body: JSON.stringify({ environment: "prod" }),
|
|
916
|
+
},
|
|
917
|
+
env,
|
|
918
|
+
);
|
|
919
|
+
expect(agentApproveRes.status).toBe(403);
|
|
920
|
+
|
|
921
|
+
// Human session with obs_session cookie approves -> mints grantToken
|
|
922
|
+
const humanApproveRes = await app.request(
|
|
923
|
+
`/internal/remediations/${propHumanBody.proposal.id}/approve`,
|
|
924
|
+
{
|
|
925
|
+
method: "POST",
|
|
926
|
+
headers: {
|
|
927
|
+
"Content-Type": "application/json",
|
|
928
|
+
Cookie: "obs_session=sess_human_operator_123",
|
|
929
|
+
"X-Obs-User-Actor": "sawan@obsunified.dev",
|
|
930
|
+
},
|
|
931
|
+
body: JSON.stringify({ environment: "prod" }),
|
|
932
|
+
},
|
|
933
|
+
env,
|
|
934
|
+
);
|
|
935
|
+
expect(humanApproveRes.status).toBe(200);
|
|
936
|
+
const humanApproveBody = (await humanApproveRes.json()) as {
|
|
937
|
+
proposal: { status: string; approvedByUserId: string };
|
|
938
|
+
grantToken: string;
|
|
939
|
+
};
|
|
940
|
+
expect(humanApproveBody.proposal.status).toBe("grant_issued");
|
|
941
|
+
expect(humanApproveBody.proposal.approvedByUserId).toBe(
|
|
942
|
+
"sawan@obsunified.dev",
|
|
943
|
+
);
|
|
944
|
+
expect(humanApproveBody.grantToken).toMatch(/^rgt_/);
|
|
945
|
+
|
|
946
|
+
// 4. Paired Counterfactual Gate (P1 #3)
|
|
947
|
+
// Create EvalCase from failing trace tr_new_0
|
|
948
|
+
const evalCaseRes = await app.request(
|
|
949
|
+
"/internal/eval-cases",
|
|
950
|
+
{
|
|
951
|
+
method: "POST",
|
|
952
|
+
headers: { "Content-Type": "application/json" },
|
|
953
|
+
body: JSON.stringify({
|
|
954
|
+
sourceEntityType: "trace",
|
|
955
|
+
sourceEntityId: "tr_new_0",
|
|
956
|
+
name: "Reproduce checkout lock timeout",
|
|
957
|
+
expectedOutcome: "Checkout completes under 200ms without lock timeout",
|
|
958
|
+
}),
|
|
959
|
+
},
|
|
960
|
+
env,
|
|
961
|
+
);
|
|
962
|
+
expect(evalCaseRes.status).toBe(201);
|
|
963
|
+
const { evalCase } = (await evalCaseRes.json()) as {
|
|
964
|
+
evalCase: { id: string };
|
|
965
|
+
};
|
|
966
|
+
|
|
967
|
+
// Create failing baseline EvalRun (v2.4.1) and passing candidate EvalRun (v2.4.0)
|
|
968
|
+
const failRunRes = await app.request(
|
|
969
|
+
"/internal/eval-runs",
|
|
970
|
+
{
|
|
971
|
+
method: "POST",
|
|
972
|
+
headers: { "Content-Type": "application/json" },
|
|
973
|
+
body: JSON.stringify({
|
|
974
|
+
id: "run_baseline_fail_241",
|
|
975
|
+
evalCaseId: evalCase.id,
|
|
976
|
+
status: "failed",
|
|
977
|
+
environment: "prod",
|
|
978
|
+
serviceVersion: "v2.4.1",
|
|
979
|
+
commitSha: "8f3a1b2",
|
|
980
|
+
inputPayloadHash: "sha256_payload_checkout_99",
|
|
981
|
+
failureFingerprint: finding.fingerprint,
|
|
982
|
+
totalCount: 1,
|
|
983
|
+
passCount: 0,
|
|
984
|
+
failCount: 1,
|
|
985
|
+
}),
|
|
986
|
+
},
|
|
987
|
+
env,
|
|
988
|
+
);
|
|
989
|
+
expect(failRunRes.status).toBe(201);
|
|
990
|
+
|
|
991
|
+
const passRunRes = await app.request(
|
|
992
|
+
"/internal/eval-runs",
|
|
993
|
+
{
|
|
994
|
+
method: "POST",
|
|
995
|
+
headers: { "Content-Type": "application/json" },
|
|
996
|
+
body: JSON.stringify({
|
|
997
|
+
id: "run_candidate_pass_240",
|
|
998
|
+
evalCaseId: evalCase.id,
|
|
999
|
+
status: "completed",
|
|
1000
|
+
environment: "prod",
|
|
1001
|
+
serviceVersion: "v2.4.0",
|
|
1002
|
+
commitSha: "1111111",
|
|
1003
|
+
inputPayloadHash: "sha256_payload_checkout_99",
|
|
1004
|
+
totalCount: 1,
|
|
1005
|
+
passCount: 1,
|
|
1006
|
+
failCount: 0,
|
|
1007
|
+
}),
|
|
1008
|
+
},
|
|
1009
|
+
env,
|
|
1010
|
+
);
|
|
1011
|
+
expect(passRunRes.status).toBe(201);
|
|
1012
|
+
|
|
1013
|
+
// Attempting to verify counterfactual when baseline also passed (or same run passed for both) must fail with 422
|
|
1014
|
+
const invalidCfRes = await app.request(
|
|
1015
|
+
`/internal/findings/${finding.id}/counterfactual`,
|
|
1016
|
+
{
|
|
1017
|
+
method: "POST",
|
|
1018
|
+
headers: { "Content-Type": "application/json" },
|
|
1019
|
+
body: JSON.stringify({
|
|
1020
|
+
environment: "prod",
|
|
1021
|
+
evalCaseId: evalCase.id,
|
|
1022
|
+
failingRunId: "run_candidate_pass_240", // passing run used as baseline -> must fail!
|
|
1023
|
+
passingRunIds: ["run_candidate_pass_240"],
|
|
1024
|
+
}),
|
|
1025
|
+
},
|
|
1026
|
+
env,
|
|
1027
|
+
);
|
|
1028
|
+
expect(invalidCfRes.status).toBe(422);
|
|
1029
|
+
|
|
1030
|
+
// Valid paired counterfactual verification upgrades finding to counterfactual_verified
|
|
1031
|
+
const validCfRes = await app.request(
|
|
1032
|
+
`/internal/findings/${finding.id}/counterfactual`,
|
|
1033
|
+
{
|
|
1034
|
+
method: "POST",
|
|
1035
|
+
headers: { "Content-Type": "application/json" },
|
|
1036
|
+
body: JSON.stringify({
|
|
1037
|
+
environment: "prod",
|
|
1038
|
+
evalCaseId: evalCase.id,
|
|
1039
|
+
failingRunId: "run_baseline_fail_241",
|
|
1040
|
+
passingRunIds: ["run_candidate_pass_240"],
|
|
1041
|
+
}),
|
|
1042
|
+
},
|
|
1043
|
+
env,
|
|
1044
|
+
);
|
|
1045
|
+
expect(validCfRes.status).toBe(200);
|
|
1046
|
+
const validCfBody = (await validCfRes.json()) as {
|
|
1047
|
+
finding: {
|
|
1048
|
+
causalProofTier: string;
|
|
1049
|
+
causalVerificationStatus: string;
|
|
1050
|
+
confidence: number;
|
|
1051
|
+
};
|
|
1052
|
+
};
|
|
1053
|
+
expect(validCfBody.finding.causalProofTier).toBe("counterfactual_verified");
|
|
1054
|
+
expect(validCfBody.finding.causalVerificationStatus).toBe(
|
|
1055
|
+
"counterfactual_verified",
|
|
1056
|
+
);
|
|
1057
|
+
expect(validCfBody.finding.confidence).toBeGreaterThanOrEqual(0.95);
|
|
1058
|
+
|
|
1059
|
+
// 5. Autonomous Remediation Proposal, State-Mismatch Check, Single-Use Claim & Complete
|
|
1060
|
+
const autoPropRes = await app.request(
|
|
1061
|
+
"/internal/remediations/propose",
|
|
1062
|
+
{
|
|
1063
|
+
method: "POST",
|
|
1064
|
+
headers: { "Content-Type": "application/json" },
|
|
1065
|
+
body: JSON.stringify({
|
|
1066
|
+
environment: "prod",
|
|
1067
|
+
incidentId,
|
|
1068
|
+
findingId: finding.id,
|
|
1069
|
+
idempotencyKey: "idem_auto_rollback_241",
|
|
1070
|
+
actionKind: "rollback_deploy",
|
|
1071
|
+
targetNodeId: serviceNodeId,
|
|
1072
|
+
expectedCurrentState: { version: "v2.4.1" },
|
|
1073
|
+
desiredTargetState: { version: "v2.4.0" },
|
|
1074
|
+
requestedAutonomy: "autonomous_write",
|
|
1075
|
+
}),
|
|
1076
|
+
},
|
|
1077
|
+
env,
|
|
1078
|
+
);
|
|
1079
|
+
expect(autoPropRes.status).toBe(201);
|
|
1080
|
+
const autoPropBody = (await autoPropRes.json()) as {
|
|
1081
|
+
proposal: { id: string; effectiveAutonomy: string; status: string };
|
|
1082
|
+
grantToken: string;
|
|
1083
|
+
};
|
|
1084
|
+
expect(autoPropBody.proposal.effectiveAutonomy).toBe("autonomous_write");
|
|
1085
|
+
expect(autoPropBody.proposal.status).toBe("grant_issued");
|
|
1086
|
+
expect(autoPropBody.grantToken).toMatch(/^rgt_/);
|
|
1087
|
+
|
|
1088
|
+
// Claiming with single-use grantToken succeeds once and fails on replay
|
|
1089
|
+
const claim1Res = await app.request(
|
|
1090
|
+
`/internal/remediations/${autoPropBody.proposal.id}/claim`,
|
|
1091
|
+
{
|
|
1092
|
+
method: "POST",
|
|
1093
|
+
headers: { "Content-Type": "application/json" },
|
|
1094
|
+
body: JSON.stringify({
|
|
1095
|
+
environment: "prod",
|
|
1096
|
+
grantToken: autoPropBody.grantToken,
|
|
1097
|
+
runnerId: "runner-alpha",
|
|
1098
|
+
}),
|
|
1099
|
+
},
|
|
1100
|
+
env,
|
|
1101
|
+
);
|
|
1102
|
+
expect(claim1Res.status).toBe(200);
|
|
1103
|
+
|
|
1104
|
+
const claimReplayRes = await app.request(
|
|
1105
|
+
`/internal/remediations/${autoPropBody.proposal.id}/claim`,
|
|
1106
|
+
{
|
|
1107
|
+
method: "POST",
|
|
1108
|
+
headers: { "Content-Type": "application/json" },
|
|
1109
|
+
body: JSON.stringify({
|
|
1110
|
+
environment: "prod",
|
|
1111
|
+
grantToken: autoPropBody.grantToken,
|
|
1112
|
+
runnerId: "runner-alpha",
|
|
1113
|
+
}),
|
|
1114
|
+
},
|
|
1115
|
+
env,
|
|
1116
|
+
);
|
|
1117
|
+
expect(claimReplayRes.status).toBe(409);
|
|
1118
|
+
|
|
1119
|
+
// Record the rollback change event and complete remediation
|
|
1120
|
+
const rollbackChange = await recordChangeEvent(db, "default", {
|
|
1121
|
+
id: "chg_rollback_240",
|
|
1122
|
+
environment: "prod",
|
|
1123
|
+
kind: "rollback",
|
|
1124
|
+
source: "autosre_remediation",
|
|
1125
|
+
serviceName: "checkout-api",
|
|
1126
|
+
version: "v2.4.0",
|
|
1127
|
+
previousVersion: "v2.4.1",
|
|
1128
|
+
summary: "Autonomous rollback checkout-api v2.4.1 -> v2.4.0",
|
|
1129
|
+
occurredAt: new Date(deployTime + 120_000).toISOString(),
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
const completeRes = await app.request(
|
|
1133
|
+
`/internal/remediations/${autoPropBody.proposal.id}/complete`,
|
|
1134
|
+
{
|
|
1135
|
+
method: "POST",
|
|
1136
|
+
headers: { "Content-Type": "application/json" },
|
|
1137
|
+
body: JSON.stringify({
|
|
1138
|
+
environment: "prod",
|
|
1139
|
+
resultingChangeId: rollbackChange.id,
|
|
1140
|
+
remediationAgentRunId: "arun_autosre_1",
|
|
1141
|
+
}),
|
|
1142
|
+
},
|
|
1143
|
+
env,
|
|
1144
|
+
);
|
|
1145
|
+
expect(completeRes.status).toBe(200);
|
|
1146
|
+
|
|
1147
|
+
// 6. Post-Remediation Watchdog: Low Traffic vs Normal Recovery (P1 #3)
|
|
1148
|
+
// First force watchdog_step = 3 (+15m check) due when < 50 post-remediation spans exist -> inconclusive_low_traffic
|
|
1149
|
+
db.raw
|
|
1150
|
+
.prepare(
|
|
1151
|
+
`UPDATE investigation_findings SET watchdog_step = 3, next_watchdog_check_at = ? WHERE id = ?`,
|
|
1152
|
+
)
|
|
1153
|
+
.run(new Date(deployTime + 200_000).toISOString(), finding.id);
|
|
1154
|
+
|
|
1155
|
+
const watchdogLow = await evaluateRemediationWatchdogs(db, {
|
|
1156
|
+
nowIso: new Date(deployTime + 300_000).toISOString(),
|
|
1157
|
+
});
|
|
1158
|
+
expect(watchdogLow.evaluated).toBe(1);
|
|
1159
|
+
expect(watchdogLow.inconclusiveLowTraffic).toBe(1);
|
|
1160
|
+
|
|
1161
|
+
// Now seed 60 healthy post-rollback spans on v2.4.0 and advance watchdog
|
|
1162
|
+
const postRollbackBase = deployTime + 600_000;
|
|
1163
|
+
for (let i = 0; i < 60; i++) {
|
|
1164
|
+
const sIso = new Date(postRollbackBase + i * 500).toISOString();
|
|
1165
|
+
const eIso = new Date(postRollbackBase + i * 500 + 35).toISOString();
|
|
1166
|
+
insertSpan(db, {
|
|
1167
|
+
traceId: `tr_recovered_${i}`,
|
|
1168
|
+
spanId: `sp_recovered_${i}`,
|
|
1169
|
+
serviceName: "checkout-api",
|
|
1170
|
+
spanName: "POST /checkout",
|
|
1171
|
+
spanKind: 1,
|
|
1172
|
+
statusCode: 1,
|
|
1173
|
+
startTime: sIso,
|
|
1174
|
+
endTime: eIso,
|
|
1175
|
+
durationMs: 35,
|
|
1176
|
+
environment: "prod",
|
|
1177
|
+
serviceVersion: "v2.4.0",
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// Force next_watchdog_check_at due and evaluate
|
|
1182
|
+
db.raw
|
|
1183
|
+
.prepare(
|
|
1184
|
+
`UPDATE investigation_findings SET recovery_status = 'monitoring', watchdog_step = 3, next_watchdog_check_at = ? WHERE id = ?`,
|
|
1185
|
+
)
|
|
1186
|
+
.run(new Date(postRollbackBase + 40_000).toISOString(), finding.id);
|
|
1187
|
+
|
|
1188
|
+
const watchdogRecovered = await evaluateRemediationWatchdogs(db, {
|
|
1189
|
+
nowIso: new Date(postRollbackBase + 50_000).toISOString(),
|
|
1190
|
+
});
|
|
1191
|
+
expect(watchdogRecovered.recovered).toBe(1);
|
|
1192
|
+
|
|
1193
|
+
// Verify incident is now resolved and finding recoveryStatus === 'recovered'
|
|
1194
|
+
const incCheckRes = await app.request(
|
|
1195
|
+
`/internal/incidents/${incidentId}?environment=prod`,
|
|
1196
|
+
{ method: "GET" },
|
|
1197
|
+
env,
|
|
1198
|
+
);
|
|
1199
|
+
expect(incCheckRes.status).toBe(200);
|
|
1200
|
+
const incCheckBody = (await incCheckRes.json()) as {
|
|
1201
|
+
incident: { status: string };
|
|
1202
|
+
findings: Array<{
|
|
1203
|
+
id: string;
|
|
1204
|
+
recoveryStatus: string;
|
|
1205
|
+
causalVerificationStatus: string;
|
|
1206
|
+
}>;
|
|
1207
|
+
};
|
|
1208
|
+
expect(incCheckBody.incident.status).toBe("resolved");
|
|
1209
|
+
expect(incCheckBody.findings[0]?.recoveryStatus).toBe("recovered");
|
|
1210
|
+
|
|
1211
|
+
// 7. Connected Signals & Evidence Bundle (triage_incident)
|
|
1212
|
+
const connectedRes = await app.request(
|
|
1213
|
+
`/internal/connected/incident/${incidentId}`,
|
|
1214
|
+
{ method: "GET" },
|
|
1215
|
+
env,
|
|
1216
|
+
);
|
|
1217
|
+
expect(connectedRes.status).toBe(200);
|
|
1218
|
+
const connectedBody = (await connectedRes.json()) as {
|
|
1219
|
+
across: Array<{
|
|
1220
|
+
label: string;
|
|
1221
|
+
links: Array<{
|
|
1222
|
+
entityKind?: string;
|
|
1223
|
+
entityId?: string;
|
|
1224
|
+
relationshipCertainty?: string;
|
|
1225
|
+
causalProofTier?: string;
|
|
1226
|
+
}>;
|
|
1227
|
+
}>;
|
|
1228
|
+
};
|
|
1229
|
+
const allAcrossLinks = connectedBody.across.flatMap((s) => s.links);
|
|
1230
|
+
const findingLink = allAcrossLinks.find((l) => l.entityKind === "finding");
|
|
1231
|
+
expect(findingLink).toBeDefined();
|
|
1232
|
+
expect(findingLink?.entityId).toBe(finding.id);
|
|
1233
|
+
expect(findingLink?.relationshipCertainty).toBe("exact");
|
|
1234
|
+
expect(findingLink?.causalProofTier).toBe("counterfactual_verified");
|
|
1235
|
+
|
|
1236
|
+
const bundleRes = await app.request(
|
|
1237
|
+
`/internal/evidence/bundle`,
|
|
1238
|
+
{
|
|
1239
|
+
method: "POST",
|
|
1240
|
+
headers: { "Content-Type": "application/json" },
|
|
1241
|
+
body: JSON.stringify({
|
|
1242
|
+
intent: "triage_incident",
|
|
1243
|
+
anchor: {
|
|
1244
|
+
entityKind: "incident",
|
|
1245
|
+
entityId: incidentId,
|
|
1246
|
+
},
|
|
1247
|
+
}),
|
|
1248
|
+
},
|
|
1249
|
+
env,
|
|
1250
|
+
);
|
|
1251
|
+
expect(bundleRes.status).toBe(200);
|
|
1252
|
+
const bundleBody = (await bundleRes.json()) as {
|
|
1253
|
+
intent: string;
|
|
1254
|
+
autosre?: {
|
|
1255
|
+
faultLocus?: { nodeId: string; spanId?: string };
|
|
1256
|
+
candidateChanges?: Array<{ changeId: string }>;
|
|
1257
|
+
priorFindings?: Array<{ findingId: string }>;
|
|
1258
|
+
};
|
|
1259
|
+
};
|
|
1260
|
+
expect(bundleBody.intent).toBe("triage_incident");
|
|
1261
|
+
expect(bundleBody.autosre).toBeDefined();
|
|
1262
|
+
expect(
|
|
1263
|
+
bundleBody.autosre?.candidateChanges?.some(
|
|
1264
|
+
(c) => c.changeId === "chg_deploy_241",
|
|
1265
|
+
),
|
|
1266
|
+
).toBe(true);
|
|
1267
|
+
expect(
|
|
1268
|
+
bundleBody.autosre?.priorFindings?.some(
|
|
1269
|
+
(f) => f.findingId === finding.id,
|
|
1270
|
+
),
|
|
1271
|
+
).toBe(true);
|
|
1272
|
+
|
|
1273
|
+
db.close();
|
|
1274
|
+
});
|
|
1275
|
+
});
|