@obsunified/collector 2.1.1 → 2.2.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.
Files changed (37) hide show
  1. package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
  2. package/dist/chunk-ZSF5XP3B.js.map +1 -0
  3. package/dist/index.d.ts +359 -2
  4. package/dist/index.js +5000 -334
  5. package/dist/index.js.map +1 -1
  6. package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
  7. package/package.json +2 -2
  8. package/src/__tests__/autosre-closed-loop.test.ts +2222 -0
  9. package/src/index.ts +43 -0
  10. package/src/lib/analyses-runner.ts +46 -0
  11. package/src/lib/autosre-change-correlation.ts +807 -0
  12. package/src/lib/autosre-findings.ts +763 -0
  13. package/src/lib/autosre-ontology-cache.test.ts +78 -0
  14. package/src/lib/autosre-ontology.ts +938 -0
  15. package/src/lib/autosre-remediation-gate.ts +2127 -0
  16. package/src/lib/eval-cases-store.ts +63 -1
  17. package/src/lib/evidence-references.ts +6 -0
  18. package/src/lib/housekeeping.test.ts +150 -0
  19. package/src/lib/housekeeping.ts +109 -1
  20. package/src/lib/store/ingest.ts +46 -6
  21. package/src/migrations/050_autosre_causal_ontology.sql +216 -0
  22. package/src/migrations/051_incident_active_dedup_unique.sql +31 -0
  23. package/src/migrations/alert-state-notified.test.ts +3 -3
  24. package/src/migrations/parity.test.ts +4 -0
  25. package/src/migrations/project-scoped-keys.test.ts +1 -1
  26. package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
  27. package/src/migrations-postgres/051_incident_active_dedup_unique.sql +31 -0
  28. package/src/plugins/alerts-evaluator.ts +29 -1
  29. package/src/plugins/autosre-routes.ts +1134 -0
  30. package/src/plugins/connected-routes/manifest.ts +10 -1
  31. package/src/plugins/connected-routes.ts +241 -0
  32. package/src/plugins/default-span-enrichment.ts +13 -1
  33. package/src/plugins/eval-cases-routes.ts +57 -2
  34. package/src/plugins/evidence-retrieval-routes.test.ts +32 -0
  35. package/src/plugins/evidence-retrieval-routes.ts +643 -5
  36. package/dist/chunk-XKQTVIRE.js.map +0 -1
  37. /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
@@ -0,0 +1,1134 @@
1
+ import type {
2
+ CausalProofTier,
3
+ CausalVerificationStatus,
4
+ ChangeEventKind,
5
+ CohortAttributionVerdict,
6
+ EvidenceEntityKind,
7
+ InvestigationFindingInput,
8
+ JsonValue,
9
+ RemediationActionKind,
10
+ RemediationProposalInput,
11
+ RemediationProposalStatus,
12
+ RemediationStateSpec,
13
+ } from "@obsunified/types";
14
+ import {
15
+ ChangeEventKindSchema,
16
+ EvidencePredicateSchema,
17
+ RemediationActionKindSchema,
18
+ } from "@obsunified/types";
19
+ import { AgentAutonomyLevel } from "@obsunified/types/constants";
20
+ import type { CollectorPlugin } from "../framework/collector";
21
+ import type { CollectorEnv } from "../framework/env";
22
+ import {
23
+ computeChangeBlastRadius,
24
+ getChangeEvent,
25
+ InvalidTimestampError,
26
+ listChangeEvents,
27
+ recordChangeEvent,
28
+ } from "../lib/autosre-change-correlation";
29
+ import {
30
+ CounterfactualVerificationError,
31
+ getInvestigationFinding,
32
+ recordInvestigationFinding,
33
+ searchInvestigationFindings,
34
+ verifyPairedCounterfactualProof,
35
+ } from "../lib/autosre-findings";
36
+ import {
37
+ getOntologyGraph,
38
+ loadTraceFaultLocusFromDb,
39
+ } from "../lib/autosre-ontology";
40
+ import {
41
+ acquireIncidentLease,
42
+ approveRemediationProposal,
43
+ claimRemediationGrant,
44
+ completeRemediationProposal,
45
+ dispatchIncidentWebhook,
46
+ evaluateRemediationWatchdogs,
47
+ failRemediationProposal,
48
+ getIncidentInvestigation,
49
+ getRemediationProposal,
50
+ listIncidentInvestigations,
51
+ listRemediationProposals,
52
+ openOrDeduplicateIncident,
53
+ proposeRemediationAction,
54
+ RemediationGateError,
55
+ rejectRemediationProposal,
56
+ } from "../lib/autosre-remediation-gate";
57
+ import { sha256Hex } from "../lib/hash";
58
+ import { sqlDbFor } from "../lib/sql-db";
59
+ import { getProjectId } from "./_context";
60
+ import { readJsonObject } from "./_json";
61
+
62
+ const CHANGE_KINDS = new Set<string>(ChangeEventKindSchema.options);
63
+
64
+ const REMEDIATION_ACTION_KINDS = new Set<string>(
65
+ RemediationActionKindSchema.options,
66
+ );
67
+
68
+ const parseLimit = (raw: string | undefined, fallback = 50): number => {
69
+ const parsed = Number.parseInt(raw ?? String(fallback), 10);
70
+ if (!Number.isFinite(parsed)) return fallback;
71
+ return Math.max(1, Math.min(200, parsed));
72
+ };
73
+
74
+ const isAgentAutonomyLevel = (value: unknown): value is AgentAutonomyLevel =>
75
+ typeof value === "string" &&
76
+ Object.values(AgentAutonomyLevel).includes(value as AgentAutonomyLevel);
77
+
78
+ /**
79
+ * Enforces Review Item #4: POST /internal/remediations/:id/approve and /reject
80
+ * require an authenticated human dashboard session (`obs_session` cookie) and
81
+ * reject programmatic agent/MCP bearer tokens (`403 Forbidden`).
82
+ */
83
+ async function requireHumanDashboardSession(c: {
84
+ req: { header: (name: string) => string | undefined };
85
+ }): Promise<
86
+ { ok: true; sessionActor: string } | { ok: false; message: string }
87
+ > {
88
+ const authHeader = c.req.header("Authorization");
89
+ if (authHeader) {
90
+ return {
91
+ ok: false,
92
+ message:
93
+ "Human approval requires an authenticated human dashboard session (obs_session cookie); agent/API bearer tokens cannot self-approve or reject remediation proposals.",
94
+ };
95
+ }
96
+ const cookieHeader = c.req.header("Cookie") ?? "";
97
+ const match = cookieHeader.match(/(?:^|;\s*)obs_session=([^;]*)/);
98
+ if (!match?.[1]) {
99
+ return {
100
+ ok: false,
101
+ message:
102
+ "Human approval requires an authenticated human dashboard session (obs_session cookie).",
103
+ };
104
+ }
105
+ // The recorded approver is derived from the session itself. Client-supplied
106
+ // identities (X-Obs-User-Actor, body.approvedByUserId) are never trusted:
107
+ // they would let any session holder attribute an approval to someone else.
108
+ // Only a hash prefix is stored so the cookie value never reaches the DB.
109
+ let rawSession = match[1];
110
+ try {
111
+ rawSession = decodeURIComponent(rawSession);
112
+ } catch {
113
+ // keep the raw value; it is only hashed
114
+ }
115
+ const sessionActor = `dashboard_session:${(await sha256Hex(rawSession)).slice(0, 16)}`;
116
+ return { ok: true, sessionActor };
117
+ }
118
+
119
+ export const autosreRoutesPlugin: CollectorPlugin = {
120
+ name: "autosre-routes",
121
+ register(app, runtime) {
122
+ /**
123
+ * Grant handoff: the plaintext grant token exists only in the response of
124
+ * the request that mints it (propose with an autonomous grant, or human
125
+ * approve). Incidents do not store a webhook, so the caller may pass
126
+ * `webhookUrl` (+ optional `secret`) in that same request and the token is
127
+ * pushed to the runner over the SSRF-guarded, HMAC-signed incident webhook.
128
+ * Without a `webhookUrl` the token is simply returned in the response.
129
+ */
130
+ const deliverGrant = async (
131
+ env: CollectorEnv | undefined,
132
+ db: ReturnType<typeof sqlDbFor>,
133
+ projectId: string,
134
+ body: Record<string, unknown>,
135
+ proposal: {
136
+ id: string;
137
+ incidentId: string;
138
+ environment: string;
139
+ status: string;
140
+ grantToken?: string;
141
+ },
142
+ ) => {
143
+ const webhookUrl =
144
+ typeof body.webhookUrl === "string" ? body.webhookUrl.trim() : "";
145
+ if (!webhookUrl || !proposal.grantToken) return undefined;
146
+ if (proposal.status !== "grant_issued") return undefined;
147
+ return dispatchIncidentWebhook(db, projectId, proposal.incidentId, {
148
+ url: webhookUrl,
149
+ secret: typeof body.secret === "string" ? body.secret : undefined,
150
+ environment: proposal.environment,
151
+ allowPrivateNetworks: env?.ALERT_WEBHOOK_ALLOW_PRIVATE === "true",
152
+ logger: runtime.logger,
153
+ grant: {
154
+ proposalId: proposal.id,
155
+ status: proposal.status,
156
+ grantToken: proposal.grantToken,
157
+ },
158
+ });
159
+ };
160
+
161
+ // 1. Change Ingestion (Ingest Auth /v1/changes)
162
+ app.post("/v1/changes", async (c) => {
163
+ const projectId = getProjectId(c);
164
+ const body = await readJsonObject<Record<string, unknown>>(c);
165
+ if (!body) {
166
+ return c.json({ error: "Invalid JSON body" }, 400);
167
+ }
168
+ const rawKind =
169
+ typeof body.kind === "string" ? body.kind.trim() : "deploy";
170
+ const normalizedKind = (
171
+ rawKind === "agent_prompt_release" ? "prompt_rollout" : rawKind
172
+ ) as ChangeEventKind;
173
+ if (!CHANGE_KINDS.has(normalizedKind)) {
174
+ return c.json(
175
+ {
176
+ error: `Invalid change kind: ${String(body.kind)}. Must be one of: ${Array.from(CHANGE_KINDS).join(", ")}`,
177
+ },
178
+ 400,
179
+ );
180
+ }
181
+ const summary =
182
+ typeof body.summary === "string" ? body.summary.trim() : "";
183
+ if (!summary) {
184
+ return c.json({ error: "summary is required" }, 400);
185
+ }
186
+
187
+ const db = runtime.getSqlDb(c.env);
188
+ const touchedPaths = Array.isArray(body.touchedPaths)
189
+ ? body.touchedPaths.filter((p): p is string => typeof p === "string")
190
+ : undefined;
191
+ const rawAttrs = body.attributes ?? body.metadata;
192
+ const attributes =
193
+ rawAttrs && typeof rawAttrs === "object" && !Array.isArray(rawAttrs)
194
+ ? (rawAttrs as Record<string, JsonValue>)
195
+ : undefined;
196
+
197
+ const serviceName =
198
+ typeof body.serviceName === "string"
199
+ ? body.serviceName
200
+ : typeof body.service === "string"
201
+ ? body.service
202
+ : undefined;
203
+ const namespace =
204
+ typeof body.namespace === "string"
205
+ ? body.namespace
206
+ : typeof body.serviceNamespace === "string"
207
+ ? body.serviceNamespace
208
+ : undefined;
209
+ const repositoryUrl =
210
+ typeof body.repositoryUrl === "string"
211
+ ? body.repositoryUrl
212
+ : typeof body.repoUrl === "string"
213
+ ? body.repoUrl
214
+ : null;
215
+ const pullRequestUrl =
216
+ typeof body.pullRequestUrl === "string"
217
+ ? body.pullRequestUrl
218
+ : typeof body.prUrl === "string"
219
+ ? body.prUrl
220
+ : null;
221
+ const actorId =
222
+ typeof body.actorId === "string"
223
+ ? body.actorId
224
+ : typeof body.actor === "string"
225
+ ? body.actor
226
+ : null;
227
+
228
+ let changeEvent: Awaited<ReturnType<typeof recordChangeEvent>>;
229
+ try {
230
+ changeEvent = await recordChangeEvent(db, projectId, {
231
+ id: typeof body.id === "string" ? body.id : undefined,
232
+ environment:
233
+ typeof body.environment === "string" ? body.environment : undefined,
234
+ kind: normalizedKind,
235
+ source:
236
+ body.source === "inferred_telemetry" ||
237
+ body.source === "autosre_remediation" ||
238
+ body.source === "ci_webhook"
239
+ ? body.source
240
+ : "explicit_api",
241
+ targetNodeId:
242
+ typeof body.targetNodeId === "string"
243
+ ? body.targetNodeId
244
+ : undefined,
245
+ serviceName,
246
+ namespace,
247
+ version: typeof body.version === "string" ? body.version : null,
248
+ previousVersion:
249
+ typeof body.previousVersion === "string"
250
+ ? body.previousVersion
251
+ : null,
252
+ commitSha: typeof body.commitSha === "string" ? body.commitSha : null,
253
+ previousCommitSha:
254
+ typeof body.previousCommitSha === "string"
255
+ ? body.previousCommitSha
256
+ : null,
257
+ configDigest:
258
+ typeof body.configDigest === "string" ? body.configDigest : null,
259
+ summary,
260
+ actorId,
261
+ repositoryUrl,
262
+ pullRequestUrl,
263
+ causedByActionId:
264
+ typeof body.causedByActionId === "string"
265
+ ? body.causedByActionId
266
+ : null,
267
+ agentRunId:
268
+ typeof body.agentRunId === "string" ? body.agentRunId : null,
269
+ touchedPaths,
270
+ attributes,
271
+ occurredAt:
272
+ typeof body.occurredAt === "string" ? body.occurredAt : undefined,
273
+ });
274
+ } catch (err) {
275
+ if (err instanceof InvalidTimestampError) {
276
+ return c.json({ error: err.message }, 400);
277
+ }
278
+ throw err;
279
+ }
280
+
281
+ return c.json({ changeEvent, change: changeEvent }, 201);
282
+ });
283
+
284
+ // 2. Ontology Graph & Fault Localization
285
+ app.get("/internal/ontology", async (c) => {
286
+ const projectId = getProjectId(c);
287
+ const db = sqlDbFor(c.env);
288
+ const environment = c.req.query("environment") || undefined;
289
+ const traceId = c.req.query("traceId") || undefined;
290
+
291
+ const graph = await getOntologyGraph(db, {
292
+ projectId,
293
+ environment,
294
+ });
295
+ const faultLocus = traceId
296
+ ? await loadTraceFaultLocusFromDb(db, projectId, traceId)
297
+ : null;
298
+
299
+ return c.json({
300
+ ...graph,
301
+ faultLocus,
302
+ });
303
+ });
304
+
305
+ // 3. Change Events Query & Blast Radius
306
+ app.get("/internal/changes", async (c) => {
307
+ const projectId = getProjectId(c);
308
+ const db = sqlDbFor(c.env);
309
+ const rawKind = c.req.query("kind");
310
+ const normalizedKind =
311
+ rawKind === "agent_prompt_release" ? "prompt_rollout" : rawKind;
312
+ if (normalizedKind && !CHANGE_KINDS.has(normalizedKind)) {
313
+ return c.json({ error: `Invalid kind: ${rawKind}` }, 400);
314
+ }
315
+ try {
316
+ const changes = await listChangeEvents(db, {
317
+ projectId,
318
+ environment: c.req.query("environment") || undefined,
319
+ serviceName:
320
+ c.req.query("serviceName") || c.req.query("service") || undefined,
321
+ targetNodeId: c.req.query("targetNodeId") || undefined,
322
+ kind: normalizedKind || undefined,
323
+ since: c.req.query("since") || undefined,
324
+ limit: parseLimit(c.req.query("limit"), 50),
325
+ });
326
+ return c.json({ changes });
327
+ } catch (err) {
328
+ if (err instanceof InvalidTimestampError) {
329
+ return c.json({ error: err.message }, 400);
330
+ }
331
+ throw err;
332
+ }
333
+ });
334
+
335
+ app.get("/internal/changes/:id/blast-radius", async (c) => {
336
+ const projectId = getProjectId(c);
337
+ const changeId = c.req.param("id");
338
+ if (!changeId) {
339
+ return c.json({ error: "changeId is required" }, 400);
340
+ }
341
+ const db = sqlDbFor(c.env);
342
+ const minSampleRaw =
343
+ c.req.query("minSample") ?? c.req.query("minComparableSample");
344
+ const minSample = minSampleRaw
345
+ ? Number.parseInt(minSampleRaw, 10)
346
+ : undefined;
347
+ const blastRadius = await computeChangeBlastRadius(
348
+ db,
349
+ projectId,
350
+ changeId,
351
+ {
352
+ environment: c.req.query("environment") || undefined,
353
+ minComparableSample:
354
+ minSample !== undefined && Number.isFinite(minSample)
355
+ ? minSample
356
+ : undefined,
357
+ },
358
+ );
359
+ if (!blastRadius) {
360
+ return c.json({ error: "Change event not found" }, 404);
361
+ }
362
+ return c.json({ blastRadius, ...blastRadius });
363
+ });
364
+
365
+ // 4. Incident Investigations & Lease Coordination
366
+ app.get("/internal/incidents", async (c) => {
367
+ const projectId = getProjectId(c);
368
+ const db = sqlDbFor(c.env);
369
+ const incidents = await listIncidentInvestigations(db, {
370
+ projectId,
371
+ environment: c.req.query("environment") || undefined,
372
+ status: c.req.query("status") || undefined,
373
+ limit: parseLimit(c.req.query("limit"), 50),
374
+ });
375
+ return c.json({ incidents });
376
+ });
377
+
378
+ app.post("/internal/incidents", async (c) => {
379
+ const projectId = getProjectId(c);
380
+ const body = await readJsonObject<Record<string, unknown>>(c);
381
+ if (!body) {
382
+ return c.json({ error: "Invalid JSON body" }, 400);
383
+ }
384
+ const targetNodeId =
385
+ typeof body.targetNodeId === "string"
386
+ ? body.targetNodeId.trim()
387
+ : typeof body.symptomNodeId === "string"
388
+ ? body.symptomNodeId.trim()
389
+ : "";
390
+ if (!targetNodeId) {
391
+ return c.json({ error: "targetNodeId is required" }, 400);
392
+ }
393
+ const symptomAnchorKind = (
394
+ typeof body.symptomAnchorKind === "string"
395
+ ? body.symptomAnchorKind
396
+ : typeof body.triggerKind === "string"
397
+ ? body.triggerKind
398
+ : "alert"
399
+ ) as EvidenceEntityKind;
400
+ const symptomAnchorId =
401
+ typeof body.symptomAnchorId === "string"
402
+ ? body.symptomAnchorId
403
+ : typeof body.triggerEntityId === "string"
404
+ ? body.triggerEntityId
405
+ : targetNodeId;
406
+
407
+ const db = sqlDbFor(c.env);
408
+ const result = await openOrDeduplicateIncident(db, projectId, {
409
+ id: typeof body.id === "string" ? body.id : undefined,
410
+ environment:
411
+ typeof body.environment === "string" ? body.environment : undefined,
412
+ targetNodeId,
413
+ symptomCategory:
414
+ typeof body.symptomCategory === "string"
415
+ ? body.symptomCategory
416
+ : undefined,
417
+ symptomAnchorKind,
418
+ symptomAnchorId,
419
+ symptomFingerprint:
420
+ typeof body.symptomFingerprint === "string"
421
+ ? body.symptomFingerprint
422
+ : typeof body.fingerprint === "string"
423
+ ? body.fingerprint
424
+ : null,
425
+ latestBundle:
426
+ body.latestBundle &&
427
+ typeof body.latestBundle === "object" &&
428
+ !Array.isArray(body.latestBundle)
429
+ ? (body.latestBundle as Record<string, unknown>)
430
+ : null,
431
+ });
432
+ return c.json(result, result.deduplicated ? 200 : 201);
433
+ });
434
+
435
+ app.post("/internal/incidents/:id/lease", async (c) => {
436
+ const projectId = getProjectId(c);
437
+ const incidentId = c.req.param("id");
438
+ const body = await readJsonObject<Record<string, unknown>>(c);
439
+ if (!body) {
440
+ return c.json({ error: "Invalid JSON body" }, 400);
441
+ }
442
+ const runnerId =
443
+ typeof body.runnerId === "string"
444
+ ? body.runnerId.trim()
445
+ : typeof body.leaseOwner === "string"
446
+ ? body.leaseOwner.trim()
447
+ : "";
448
+ if (!runnerId) {
449
+ return c.json({ error: "runnerId is required" }, 400);
450
+ }
451
+ const db = sqlDbFor(c.env);
452
+ const leaseDurationSeconds =
453
+ typeof body.leaseDurationSeconds === "number" &&
454
+ Number.isFinite(body.leaseDurationSeconds)
455
+ ? body.leaseDurationSeconds
456
+ : typeof body.ttlSeconds === "number" &&
457
+ Number.isFinite(body.ttlSeconds)
458
+ ? body.ttlSeconds
459
+ : typeof body.leaseDurationMs === "number" &&
460
+ Number.isFinite(body.leaseDurationMs)
461
+ ? Math.ceil(body.leaseDurationMs / 1000)
462
+ : undefined;
463
+ const result = await acquireIncidentLease(db, projectId, incidentId, {
464
+ environment:
465
+ typeof body.environment === "string" ? body.environment : undefined,
466
+ runnerId,
467
+ leaseDurationSeconds,
468
+ });
469
+ return c.json(result, result.acquired ? 200 : 409);
470
+ });
471
+
472
+ app.post("/internal/incidents/:id/dispatch", async (c) => {
473
+ const projectId = getProjectId(c);
474
+ const incidentId = c.req.param("id");
475
+ const body = await readJsonObject<Record<string, unknown>>(c);
476
+ const webhookUrl =
477
+ typeof body?.webhookUrl === "string"
478
+ ? body.webhookUrl
479
+ : typeof body?.url === "string"
480
+ ? body.url
481
+ : "";
482
+ if (!webhookUrl) {
483
+ return c.json({ error: "webhookUrl is required" }, 400);
484
+ }
485
+ const db = sqlDbFor(c.env);
486
+ const result = await dispatchIncidentWebhook(db, projectId, incidentId, {
487
+ url: webhookUrl,
488
+ secret: typeof body?.secret === "string" ? body.secret : undefined,
489
+ environment:
490
+ typeof body?.environment === "string" ? body.environment : undefined,
491
+ // Operator-level setting only: a request body must never be able to
492
+ // turn off the SSRF guard.
493
+ allowPrivateNetworks: c.env?.ALERT_WEBHOOK_ALLOW_PRIVATE === "true",
494
+ logger: runtime.logger,
495
+ });
496
+ return c.json(result, result.delivered ? 200 : 502);
497
+ });
498
+
499
+ // 5. Investigation Findings & Counterfactual Verification
500
+ app.get("/internal/findings", async (c) => {
501
+ const projectId = getProjectId(c);
502
+ const db = sqlDbFor(c.env);
503
+ const findings = await searchInvestigationFindings(db, {
504
+ projectId,
505
+ environment: c.req.query("environment") || undefined,
506
+ fingerprint: c.req.query("fingerprint") || undefined,
507
+ symptomEntityId: c.req.query("symptomEntityId") || undefined,
508
+ rootCauseEntityId:
509
+ c.req.query("rootCauseEntityId") ||
510
+ c.req.query("faultLocusEntityId") ||
511
+ undefined,
512
+ causalVerificationStatus:
513
+ (c.req.query("causalVerificationStatus") as
514
+ | CausalVerificationStatus
515
+ | undefined) || undefined,
516
+ incidentId: c.req.query("incidentId") || undefined,
517
+ limit: parseLimit(c.req.query("limit"), 20),
518
+ });
519
+ return c.json({ findings });
520
+ });
521
+
522
+ app.post("/internal/findings", async (c) => {
523
+ const projectId = getProjectId(c);
524
+ const body = await readJsonObject<Record<string, unknown>>(c);
525
+ if (!body) {
526
+ return c.json({ error: "Invalid JSON body" }, 400);
527
+ }
528
+ const title = typeof body.title === "string" ? body.title.trim() : "";
529
+ const summary =
530
+ typeof body.summary === "string" ? body.summary.trim() : "";
531
+ const symptomEntityKind =
532
+ typeof body.symptomEntityKind === "string"
533
+ ? body.symptomEntityKind.trim()
534
+ : "";
535
+ const symptomEntityId =
536
+ typeof body.symptomEntityId === "string"
537
+ ? body.symptomEntityId.trim()
538
+ : "";
539
+ const faultLocusEntityKind =
540
+ typeof body.faultLocusEntityKind === "string"
541
+ ? body.faultLocusEntityKind.trim()
542
+ : "";
543
+ const faultLocusEntityId =
544
+ typeof body.faultLocusEntityId === "string"
545
+ ? body.faultLocusEntityId.trim()
546
+ : "";
547
+
548
+ if (
549
+ !title ||
550
+ !summary ||
551
+ !symptomEntityKind ||
552
+ !symptomEntityId ||
553
+ !faultLocusEntityKind ||
554
+ !faultLocusEntityId
555
+ ) {
556
+ return c.json(
557
+ {
558
+ error:
559
+ "title, summary, symptomEntityKind, symptomEntityId, faultLocusEntityKind, and faultLocusEntityId are required",
560
+ },
561
+ 400,
562
+ );
563
+ }
564
+
565
+ if (body.causalChain !== undefined && !Array.isArray(body.causalChain)) {
566
+ return c.json({ error: "causalChain must be an array" }, 400);
567
+ }
568
+ for (const [i, triple] of (
569
+ (body.causalChain as unknown[] | undefined) ?? []
570
+ ).entries()) {
571
+ const predicate =
572
+ triple && typeof triple === "object"
573
+ ? (triple as Record<string, unknown>).predicate
574
+ : undefined;
575
+ if (!EvidencePredicateSchema.safeParse(predicate).success) {
576
+ return c.json(
577
+ {
578
+ error: `causalChain[${i}].predicate is not a known evidence predicate: ${String(predicate)}`,
579
+ },
580
+ 400,
581
+ );
582
+ }
583
+ }
584
+
585
+ const db = sqlDbFor(c.env);
586
+ const input: InvestigationFindingInput = {
587
+ id: typeof body.id === "string" ? body.id : undefined,
588
+ environment:
589
+ typeof body.environment === "string" ? body.environment : undefined,
590
+ incidentId:
591
+ typeof body.incidentId === "string" ? body.incidentId : null,
592
+ fingerprintInput:
593
+ body.fingerprintInput && typeof body.fingerprintInput === "object"
594
+ ? (body.fingerprintInput as InvestigationFindingInput["fingerprintInput"])
595
+ : undefined,
596
+ title,
597
+ summary,
598
+ causalProofTier:
599
+ typeof body.causalProofTier === "string"
600
+ ? (body.causalProofTier as CausalProofTier)
601
+ : undefined,
602
+ confidence:
603
+ typeof body.confidence === "number" ? body.confidence : undefined,
604
+ symptomEntityKind: symptomEntityKind as EvidenceEntityKind,
605
+ symptomEntityId,
606
+ faultLocusEntityKind: faultLocusEntityKind as EvidenceEntityKind,
607
+ faultLocusEntityId,
608
+ rootCauseEntityKind:
609
+ typeof body.rootCauseEntityKind === "string"
610
+ ? (body.rootCauseEntityKind as EvidenceEntityKind)
611
+ : null,
612
+ rootCauseEntityId:
613
+ typeof body.rootCauseEntityId === "string"
614
+ ? body.rootCauseEntityId
615
+ : null,
616
+ contributingChangeId:
617
+ typeof body.contributingChangeId === "string"
618
+ ? body.contributingChangeId
619
+ : null,
620
+ cohortVerdict:
621
+ typeof body.cohortVerdict === "string"
622
+ ? (body.cohortVerdict as CohortAttributionVerdict)
623
+ : null,
624
+ contendingTraceId:
625
+ typeof body.contendingTraceId === "string"
626
+ ? body.contendingTraceId
627
+ : null,
628
+ causalChain: Array.isArray(body.causalChain)
629
+ ? (body.causalChain as InvestigationFindingInput["causalChain"])
630
+ : [],
631
+ citedEvidenceIds: Array.isArray(body.citedEvidenceIds)
632
+ ? body.citedEvidenceIds.filter(
633
+ (id): id is string => typeof id === "string",
634
+ )
635
+ : [],
636
+ agentRunId:
637
+ typeof body.agentRunId === "string" ? body.agentRunId : null,
638
+ };
639
+
640
+ const finding = await recordInvestigationFinding(db, projectId, input, {
641
+ logger: runtime.logger,
642
+ });
643
+ return c.json({ finding, id: finding.id }, 201);
644
+ });
645
+
646
+ app.post("/internal/findings/:id/counterfactual", async (c) => {
647
+ const projectId = getProjectId(c);
648
+ const findingId = c.req.param("id");
649
+ const body = await readJsonObject<Record<string, unknown>>(c);
650
+ if (!body) {
651
+ return c.json({ error: "Invalid JSON body" }, 400);
652
+ }
653
+ const evalCaseId =
654
+ typeof body.evalCaseId === "string" ? body.evalCaseId.trim() : "";
655
+ const failingRunId =
656
+ typeof body.failingRunId === "string" ? body.failingRunId.trim() : "";
657
+ const passingRunIds = Array.isArray(body.passingRunIds)
658
+ ? body.passingRunIds.filter(
659
+ (id): id is string => typeof id === "string" && id.trim() !== "",
660
+ )
661
+ : [];
662
+
663
+ if (!evalCaseId || !failingRunId) {
664
+ return c.json(
665
+ { error: "evalCaseId and failingRunId are required" },
666
+ 400,
667
+ );
668
+ }
669
+
670
+ const db = sqlDbFor(c.env);
671
+ try {
672
+ const finding = await verifyPairedCounterfactualProof(
673
+ db,
674
+ projectId,
675
+ findingId,
676
+ {
677
+ environment:
678
+ typeof body.environment === "string"
679
+ ? body.environment
680
+ : undefined,
681
+ evalCaseId,
682
+ failingRunId,
683
+ passingRunIds,
684
+ requiredTrials:
685
+ typeof body.requiredTrials === "number" &&
686
+ Number.isFinite(body.requiredTrials)
687
+ ? body.requiredTrials
688
+ : undefined,
689
+ },
690
+ );
691
+ return c.json({ verified: true, finding });
692
+ } catch (err) {
693
+ const message = err instanceof Error ? err.message : String(err);
694
+ if (err instanceof CounterfactualVerificationError) {
695
+ const isNotFound = message.includes("not found in project");
696
+ return c.json(
697
+ { verified: false, error: message, reason: message },
698
+ isNotFound ? 404 : 422,
699
+ );
700
+ }
701
+ return c.json({ verified: false, error: message }, 400);
702
+ }
703
+ });
704
+
705
+ // 6. Remediation Proposals, Human Approval, Claim Grant, Watchdog
706
+ app.get("/internal/remediations", async (c) => {
707
+ const projectId = getProjectId(c);
708
+ const db = sqlDbFor(c.env);
709
+ const proposals = await listRemediationProposals(db, {
710
+ projectId,
711
+ environment: c.req.query("environment") || undefined,
712
+ incidentId: c.req.query("incidentId") || undefined,
713
+ findingId: c.req.query("findingId") || undefined,
714
+ status:
715
+ (c.req.query("status") as RemediationProposalStatus | undefined) ||
716
+ undefined,
717
+ limit: parseLimit(c.req.query("limit"), 50),
718
+ });
719
+ return c.json({ proposals });
720
+ });
721
+
722
+ app.post("/internal/remediations/propose", async (c) => {
723
+ const projectId = getProjectId(c);
724
+ const body = await readJsonObject<Record<string, unknown>>(c);
725
+ if (!body) {
726
+ return c.json({ error: "Invalid JSON body" }, 400);
727
+ }
728
+ const incidentId =
729
+ typeof body.incidentId === "string" ? body.incidentId.trim() : "";
730
+ const findingId =
731
+ typeof body.findingId === "string" ? body.findingId.trim() : "";
732
+ const idempotencyKey =
733
+ typeof body.idempotencyKey === "string"
734
+ ? body.idempotencyKey.trim()
735
+ : "";
736
+ const actionKind = (
737
+ typeof body.actionKind === "string" ? body.actionKind.trim() : ""
738
+ ) as RemediationActionKind;
739
+ const targetNodeId =
740
+ typeof body.targetNodeId === "string" ? body.targetNodeId.trim() : "";
741
+
742
+ if (
743
+ !incidentId ||
744
+ !findingId ||
745
+ !idempotencyKey ||
746
+ !REMEDIATION_ACTION_KINDS.has(actionKind) ||
747
+ !targetNodeId
748
+ ) {
749
+ return c.json(
750
+ {
751
+ error:
752
+ "incidentId, findingId, idempotencyKey, valid actionKind, and targetNodeId are required",
753
+ },
754
+ 400,
755
+ );
756
+ }
757
+
758
+ const expectedCurrentState =
759
+ body.expectedCurrentState &&
760
+ typeof body.expectedCurrentState === "object" &&
761
+ !Array.isArray(body.expectedCurrentState)
762
+ ? (body.expectedCurrentState as RemediationStateSpec)
763
+ : {};
764
+ const desiredTargetState =
765
+ body.desiredTargetState &&
766
+ typeof body.desiredTargetState === "object" &&
767
+ !Array.isArray(body.desiredTargetState)
768
+ ? (body.desiredTargetState as RemediationStateSpec)
769
+ : {};
770
+ const requestedAutonomy: AgentAutonomyLevel = isAgentAutonomyLevel(
771
+ body.requestedAutonomy,
772
+ )
773
+ ? body.requestedAutonomy
774
+ : "autonomous_write";
775
+
776
+ const db = sqlDbFor(c.env);
777
+ const input: RemediationProposalInput = {
778
+ environment:
779
+ typeof body.environment === "string" ? body.environment : undefined,
780
+ incidentId,
781
+ findingId,
782
+ idempotencyKey,
783
+ actionKind,
784
+ targetNodeId,
785
+ expectedCurrentState,
786
+ desiredTargetState,
787
+ requestedAutonomy,
788
+ };
789
+
790
+ try {
791
+ const proposal = await proposeRemediationAction(db, projectId, input, {
792
+ logger: runtime.logger,
793
+ });
794
+ const webhookDelivery = await deliverGrant(
795
+ c.env,
796
+ db,
797
+ projectId,
798
+ body,
799
+ proposal,
800
+ );
801
+ return c.json(
802
+ {
803
+ proposal,
804
+ ...proposal,
805
+ grantToken: proposal.grantToken ?? null,
806
+ ...(webhookDelivery ? { webhookDelivery } : {}),
807
+ },
808
+ 201,
809
+ );
810
+ } catch (err) {
811
+ const message = err instanceof Error ? err.message : String(err);
812
+ const status =
813
+ err instanceof RemediationGateError
814
+ ? (err.statusCode as 400 | 404 | 409)
815
+ : 400;
816
+ return c.json({ error: message }, status);
817
+ }
818
+ });
819
+
820
+ app.post("/internal/remediations/:id/approve", async (c) => {
821
+ const sessionCheck = await requireHumanDashboardSession(c);
822
+ if (!sessionCheck.ok) {
823
+ return c.json(
824
+ { error: "Forbidden", message: sessionCheck.message },
825
+ 403,
826
+ );
827
+ }
828
+ const projectId = getProjectId(c);
829
+ const proposalId = c.req.param("id");
830
+ const body =
831
+ (await readJsonObject<Record<string, unknown>>(c).catch(() => null)) ??
832
+ {};
833
+ const environment =
834
+ typeof body.environment === "string" ? body.environment : undefined;
835
+ const approvedByUserId = sessionCheck.sessionActor;
836
+
837
+ const db = sqlDbFor(c.env);
838
+ try {
839
+ const proposal = await approveRemediationProposal(
840
+ db,
841
+ projectId,
842
+ proposalId,
843
+ {
844
+ environment,
845
+ approvedByUserId,
846
+ },
847
+ );
848
+ const webhookDelivery = await deliverGrant(
849
+ c.env,
850
+ db,
851
+ projectId,
852
+ body,
853
+ proposal,
854
+ );
855
+ return c.json({
856
+ proposal,
857
+ ...proposal,
858
+ grantToken: proposal.grantToken ?? null,
859
+ ...(webhookDelivery ? { webhookDelivery } : {}),
860
+ });
861
+ } catch (err) {
862
+ const message = err instanceof Error ? err.message : String(err);
863
+ const status =
864
+ err instanceof RemediationGateError
865
+ ? (err.statusCode as 400 | 404 | 409)
866
+ : 400;
867
+ return c.json({ error: message }, status);
868
+ }
869
+ });
870
+
871
+ app.post("/internal/remediations/:id/reject", async (c) => {
872
+ const sessionCheck = await requireHumanDashboardSession(c);
873
+ if (!sessionCheck.ok) {
874
+ return c.json(
875
+ { error: "Forbidden", message: sessionCheck.message },
876
+ 403,
877
+ );
878
+ }
879
+ const projectId = getProjectId(c);
880
+ const proposalId = c.req.param("id");
881
+ const body =
882
+ (await readJsonObject<Record<string, unknown>>(c).catch(() => null)) ??
883
+ {};
884
+ const environment =
885
+ typeof body.environment === "string" ? body.environment : undefined;
886
+ const reason = typeof body.reason === "string" ? body.reason : undefined;
887
+
888
+ const db = sqlDbFor(c.env);
889
+ try {
890
+ const proposal = await rejectRemediationProposal(
891
+ db,
892
+ projectId,
893
+ proposalId,
894
+ {
895
+ environment,
896
+ rejectedByUserId: sessionCheck.sessionActor,
897
+ reason,
898
+ },
899
+ );
900
+ return c.json({ proposal, ...proposal });
901
+ } catch (err) {
902
+ const message = err instanceof Error ? err.message : String(err);
903
+ const status =
904
+ err instanceof RemediationGateError
905
+ ? (err.statusCode as 400 | 404 | 409)
906
+ : 400;
907
+ return c.json({ error: message }, status);
908
+ }
909
+ });
910
+
911
+ app.post("/internal/remediations/:id/claim", async (c) => {
912
+ const projectId = getProjectId(c);
913
+ const proposalId = c.req.param("id");
914
+ const body = await readJsonObject<Record<string, unknown>>(c);
915
+ if (!body) {
916
+ return c.json({ error: "Invalid JSON body" }, 400);
917
+ }
918
+ const grantToken =
919
+ typeof body.grantToken === "string" ? body.grantToken.trim() : "";
920
+ if (!grantToken) {
921
+ return c.json({ error: "grantToken is required" }, 400);
922
+ }
923
+ const db = sqlDbFor(c.env);
924
+ try {
925
+ const proposal = await claimRemediationGrant(
926
+ db,
927
+ projectId,
928
+ proposalId,
929
+ {
930
+ environment:
931
+ typeof body.environment === "string"
932
+ ? body.environment
933
+ : undefined,
934
+ grantToken,
935
+ runnerId:
936
+ typeof body.runnerId === "string" ? body.runnerId : undefined,
937
+ logger: runtime.logger,
938
+ },
939
+ );
940
+ return c.json({ claimed: true, proposal, ...proposal });
941
+ } catch (err) {
942
+ const message = err instanceof Error ? err.message : String(err);
943
+ const status =
944
+ err instanceof RemediationGateError
945
+ ? (err.statusCode as 400 | 403 | 404 | 409 | 410)
946
+ : 409;
947
+ return c.json(
948
+ { claimed: false, error: message, reason: message },
949
+ status,
950
+ );
951
+ }
952
+ });
953
+
954
+ app.post("/internal/remediations/:id/complete", async (c) => {
955
+ const projectId = getProjectId(c);
956
+ const proposalId = c.req.param("id");
957
+ const body =
958
+ (await readJsonObject<Record<string, unknown>>(c).catch(() => null)) ??
959
+ {};
960
+ const runnerId =
961
+ typeof body.runnerId === "string" && body.runnerId.trim()
962
+ ? body.runnerId.trim()
963
+ : "autosre-runner";
964
+ const db = sqlDbFor(c.env);
965
+ try {
966
+ const proposal = await completeRemediationProposal(
967
+ db,
968
+ projectId,
969
+ proposalId,
970
+ {
971
+ environment:
972
+ typeof body.environment === "string"
973
+ ? body.environment
974
+ : undefined,
975
+ runnerId,
976
+ resultingChangeId:
977
+ typeof body.resultingChangeId === "string"
978
+ ? body.resultingChangeId
979
+ : null,
980
+ remediationAgentRunId:
981
+ typeof body.remediationAgentRunId === "string"
982
+ ? body.remediationAgentRunId
983
+ : null,
984
+ },
985
+ );
986
+ return c.json({ proposal, ...proposal });
987
+ } catch (err) {
988
+ const message = err instanceof Error ? err.message : String(err);
989
+ const status =
990
+ err instanceof RemediationGateError
991
+ ? (err.statusCode as 400 | 404 | 409)
992
+ : 400;
993
+ return c.json({ error: message }, status);
994
+ }
995
+ });
996
+
997
+ app.post("/internal/remediations/:id/fail", async (c) => {
998
+ const projectId = getProjectId(c);
999
+ const proposalId = c.req.param("id");
1000
+ const body =
1001
+ (await readJsonObject<Record<string, unknown>>(c).catch(() => null)) ??
1002
+ {};
1003
+ const runnerId =
1004
+ typeof body.runnerId === "string" && body.runnerId.trim()
1005
+ ? body.runnerId.trim()
1006
+ : "autosre-runner";
1007
+ const db = sqlDbFor(c.env);
1008
+ try {
1009
+ const proposal = await failRemediationProposal(
1010
+ db,
1011
+ projectId,
1012
+ proposalId,
1013
+ {
1014
+ environment:
1015
+ typeof body.environment === "string"
1016
+ ? body.environment
1017
+ : undefined,
1018
+ runnerId,
1019
+ failureReason:
1020
+ typeof body.failureReason === "string"
1021
+ ? body.failureReason
1022
+ : "Execution failed",
1023
+ },
1024
+ );
1025
+ return c.json({ proposal, ...proposal });
1026
+ } catch (err) {
1027
+ const message = err instanceof Error ? err.message : String(err);
1028
+ const status =
1029
+ err instanceof RemediationGateError
1030
+ ? (err.statusCode as 400 | 404 | 409)
1031
+ : 400;
1032
+ return c.json({ error: message }, status);
1033
+ }
1034
+ });
1035
+
1036
+ app.post("/internal/remediations/watchdog", async (c) => {
1037
+ const db = sqlDbFor(c.env);
1038
+ const body =
1039
+ (await readJsonObject<Record<string, unknown>>(c).catch(() => null)) ??
1040
+ {};
1041
+ // Scoped to the caller's project; the clock is never client-controlled
1042
+ // and the traffic floor can only be raised, never lowered, by a caller.
1043
+ const summary = await evaluateRemediationWatchdogs(db, {
1044
+ projectId: getProjectId(c),
1045
+ minComparableRequests:
1046
+ typeof body.minComparableRequests === "number" &&
1047
+ Number.isFinite(body.minComparableRequests)
1048
+ ? Math.max(50, body.minComparableRequests)
1049
+ : undefined,
1050
+ logger: runtime.logger,
1051
+ });
1052
+ return c.json({ summary, ...summary });
1053
+ });
1054
+
1055
+ app.get("/internal/remediations/:id", async (c) => {
1056
+ const projectId = getProjectId(c);
1057
+ const proposalId = c.req.param("id");
1058
+ const db = sqlDbFor(c.env);
1059
+ const proposal = await getRemediationProposal(
1060
+ db,
1061
+ projectId,
1062
+ proposalId,
1063
+ c.req.query("environment") || undefined,
1064
+ );
1065
+ if (!proposal) {
1066
+ return c.json({ error: "Proposal not found" }, 404);
1067
+ }
1068
+ return c.json({ proposal });
1069
+ });
1070
+
1071
+ app.get("/internal/incidents/:id", async (c) => {
1072
+ const projectId = getProjectId(c);
1073
+ const incidentId = c.req.param("id");
1074
+ const environment = c.req.query("environment") || undefined;
1075
+ const db = sqlDbFor(c.env);
1076
+ const incident = await getIncidentInvestigation(
1077
+ db,
1078
+ projectId,
1079
+ incidentId,
1080
+ environment,
1081
+ );
1082
+ if (!incident) {
1083
+ return c.json({ error: "Incident not found" }, 404);
1084
+ }
1085
+ const [findings, proposals] = await Promise.all([
1086
+ searchInvestigationFindings(db, {
1087
+ projectId,
1088
+ environment: incident.environment,
1089
+ incidentId: incident.id,
1090
+ limit: 20,
1091
+ }),
1092
+ listRemediationProposals(db, {
1093
+ projectId,
1094
+ environment: incident.environment,
1095
+ incidentId: incident.id,
1096
+ limit: 20,
1097
+ }),
1098
+ ]);
1099
+ return c.json({ incident, findings, proposals });
1100
+ });
1101
+
1102
+ app.get("/internal/findings/:id", async (c) => {
1103
+ const projectId = getProjectId(c);
1104
+ const findingId = c.req.param("id");
1105
+ const db = sqlDbFor(c.env);
1106
+ const finding = await getInvestigationFinding(
1107
+ db,
1108
+ projectId,
1109
+ findingId,
1110
+ c.req.query("environment") || undefined,
1111
+ );
1112
+ if (!finding) {
1113
+ return c.json({ error: "Finding not found" }, 404);
1114
+ }
1115
+ return c.json({ finding });
1116
+ });
1117
+
1118
+ app.get("/internal/changes/:id", async (c) => {
1119
+ const projectId = getProjectId(c);
1120
+ const changeId = c.req.param("id");
1121
+ const db = sqlDbFor(c.env);
1122
+ const changeEvent = await getChangeEvent(
1123
+ db,
1124
+ projectId,
1125
+ changeId,
1126
+ c.req.query("environment") || undefined,
1127
+ );
1128
+ if (!changeEvent) {
1129
+ return c.json({ error: "Change event not found" }, 404);
1130
+ }
1131
+ return c.json({ changeEvent, change: changeEvent });
1132
+ });
1133
+ },
1134
+ };