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