@obsunified/collector 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/auth.d.ts +1 -1
  2. package/dist/auth.js +1 -1
  3. package/dist/{chunk-KMEZKGSO.js → chunk-2FALTDCE.js} +12 -4
  4. package/dist/chunk-2FALTDCE.js.map +1 -0
  5. package/dist/{chunk-ZSF5XP3B.js → chunk-RASSTGNB.js} +30 -22
  6. package/dist/chunk-RASSTGNB.js.map +1 -0
  7. package/dist/index.d.ts +10 -2
  8. package/dist/index.js +2892 -2240
  9. package/dist/index.js.map +1 -1
  10. package/dist/{ingest-auth-C5-8Mly5.d.ts → ingest-auth-BmP5-f96.d.ts} +32 -1
  11. package/dist/{openai-GSJZGFGL.js → openai-VDKVVZAV.js} +2 -2
  12. package/package.json +4 -3
  13. package/src/__tests__/autosre-closed-loop.test.ts +700 -0
  14. package/src/__tests__/db-parity.integration.test.ts +961 -0
  15. package/src/__tests__/internal-contracts-routes.test.ts +451 -0
  16. package/src/auth/dashboard-auth.test.ts +61 -0
  17. package/src/auth/dashboard-auth.ts +17 -2
  18. package/src/framework/env.ts +105 -0
  19. package/src/index.ts +13 -4
  20. package/src/lib/ai-store/calls.ts +43 -23
  21. package/src/lib/ai-store/evaluations.test.ts +3 -3
  22. package/src/lib/ai-store.ts +12 -1
  23. package/src/lib/analyses-store.test.ts +6 -6
  24. package/src/lib/autosre-findings.ts +169 -14
  25. package/src/lib/autosre-remediation-gate.ts +156 -26
  26. package/src/lib/db-parity.ts +883 -0
  27. package/src/lib/eval-cases-store.test.ts +1 -1
  28. package/src/lib/eval-cases-store.ts +16 -4
  29. package/src/lib/evidence-references.ts +30 -22
  30. package/src/lib/projects-store.test.ts +38 -1
  31. package/src/lib/projects-store.ts +23 -4
  32. package/src/lib/sql-db-postgres.integration.test.ts +75 -2
  33. package/src/lib/stage6.test.ts +1 -1
  34. package/src/lib/usage-store.ts +9 -3
  35. package/src/migrations/052_remediation_callback.sql +10 -0
  36. package/src/migrations/053_remediation_callback_delivery.sql +7 -0
  37. package/src/migrations/parity.test.ts +105 -3
  38. package/src/migrations-postgres/052_remediation_callback.sql +10 -0
  39. package/src/migrations-postgres/053_remediation_callback_delivery.sql +7 -0
  40. package/src/plugins/ai-call-by-id.test.ts +73 -0
  41. package/src/plugins/ai-receiver.ts +15 -0
  42. package/src/plugins/autosre-routes.ts +409 -147
  43. package/src/plugins/connected-routes/deep-link-shapes.test.ts +44 -0
  44. package/src/plugins/connected-routes/manifest.test.ts +224 -0
  45. package/src/plugins/connected-routes/manifest.ts +131 -28
  46. package/src/plugins/connected-routes-links.test.ts +96 -0
  47. package/src/plugins/connected-routes.test.ts +13 -11
  48. package/src/plugins/connected-routes.ts +53 -20
  49. package/src/plugins/eval-cases-routes.ts +33 -10
  50. package/src/plugins/evidence-retrieval-routes.ts +89 -24
  51. package/dist/chunk-KMEZKGSO.js.map +0 -1
  52. package/dist/chunk-ZSF5XP3B.js.map +0 -1
  53. /package/dist/{openai-GSJZGFGL.js.map → openai-VDKVVZAV.js.map} +0 -0
@@ -1,9 +1,11 @@
1
1
  import type {
2
- CausalProofTier,
3
2
  CausalVerificationStatus,
4
3
  ChangeEventKind,
5
- CohortAttributionVerdict,
6
4
  EvidenceEntityKind,
5
+ GrantCallbackDelivery,
6
+ GrantDeliveryResult,
7
+ InternalEndpointKey,
8
+ InternalEndpoints,
7
9
  InvestigationFindingInput,
8
10
  JsonValue,
9
11
  RemediationActionKind,
@@ -12,11 +14,16 @@ import type {
12
14
  RemediationStateSpec,
13
15
  } from "@obsunified/types";
14
16
  import {
17
+ ChangeEventInputSchema,
15
18
  ChangeEventKindSchema,
16
19
  EvidencePredicateSchema,
20
+ InvestigationFindingHttpInputSchema,
17
21
  RemediationActionKindSchema,
22
+ RemediationProposalInputSchema,
23
+ validateContractResponse,
18
24
  } from "@obsunified/types";
19
25
  import { AgentAutonomyLevel } from "@obsunified/types/constants";
26
+ import type { Context } from "hono";
20
27
  import type { CollectorPlugin } from "../framework/collector";
21
28
  import type { CollectorEnv } from "../framework/env";
22
29
  import {
@@ -46,19 +53,37 @@ import {
46
53
  evaluateRemediationWatchdogs,
47
54
  failRemediationProposal,
48
55
  getIncidentInvestigation,
56
+ getRemediationCallback,
49
57
  getRemediationProposal,
50
58
  listIncidentInvestigations,
51
59
  listRemediationProposals,
52
60
  openOrDeduplicateIncident,
53
61
  proposeRemediationAction,
54
62
  RemediationGateError,
63
+ recordCallbackDelivery,
55
64
  rejectRemediationProposal,
56
65
  } from "../lib/autosre-remediation-gate";
57
66
  import { sha256Hex } from "../lib/hash";
58
67
  import { sqlDbFor } from "../lib/sql-db";
68
+ import { assertSafeWebhookUrl } from "../lib/webhook-url";
59
69
  import { getProjectId } from "./_context";
60
70
  import { readJsonObject } from "./_json";
61
71
 
72
+ export function respondWithContract<
73
+ K extends InternalEndpointKey,
74
+ C extends {
75
+ json: (data: unknown, status?: 200 | 201 | 202) => Response;
76
+ },
77
+ >(
78
+ c: C,
79
+ endpoint: K,
80
+ payload: InternalEndpoints[K]["response"],
81
+ status: 200 | 201 | 202 = 200,
82
+ ): Response {
83
+ validateContractResponse(endpoint, payload, status);
84
+ return c.json(payload, status);
85
+ }
86
+
62
87
  const CHANGE_KINDS = new Set<string>(ChangeEventKindSchema.options);
63
88
 
64
89
  const REMEDIATION_ACTION_KINDS = new Set<string>(
@@ -126,6 +151,12 @@ export const autosreRoutesPlugin: CollectorPlugin = {
126
151
  * `webhookUrl` (+ optional `secret`) in that same request and the token is
127
152
  * pushed to the runner over the SSRF-guarded, HMAC-signed incident webhook.
128
153
  * Without a `webhookUrl` the token is simply returned in the response.
154
+ *
155
+ * A `webhookUrl` (+ `secret`) given to `propose` for a proposal that is
156
+ * parked for human approval is persisted on the proposal and used when
157
+ * the approve request carries none. A callback on the approve request
158
+ * wins over the stored one. The stored callback is cleared on approve
159
+ * or reject, and only its host is ever returned by GET.
129
160
  */
130
161
  const deliverGrant = async (
131
162
  env: CollectorEnv | undefined,
@@ -139,23 +170,87 @@ export const autosreRoutesPlugin: CollectorPlugin = {
139
170
  status: string;
140
171
  grantToken?: string;
141
172
  },
173
+ /** Callback persisted at propose time; used when the request has none. */
174
+ stored?: { url: string; secret?: string } | null,
142
175
  ) => {
143
- const webhookUrl =
176
+ const bodyUrl =
144
177
  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,
178
+ const target = bodyUrl
179
+ ? {
180
+ url: bodyUrl,
181
+ secret: typeof body.secret === "string" ? body.secret : undefined,
182
+ }
183
+ : (stored ?? null);
184
+ const notAttempted: GrantDeliveryResult = {
185
+ attempted: false,
186
+ delivered: false,
187
+ attempts: 0,
188
+ host: null,
189
+ };
190
+ if (!target || !proposal.grantToken) {
191
+ return { webhookDelivery: undefined, grantDelivery: notAttempted };
192
+ }
193
+ if (proposal.status !== "grant_issued") {
194
+ return { webhookDelivery: undefined, grantDelivery: notAttempted };
195
+ }
196
+ let host: string | null = null;
197
+ try {
198
+ host = new URL(target.url).host || null;
199
+ } catch {
200
+ host = null;
201
+ }
202
+ const webhookDelivery = await dispatchIncidentWebhook(
203
+ db,
204
+ projectId,
205
+ proposal.incidentId,
206
+ {
207
+ url: target.url,
208
+ secret: target.secret,
209
+ environment: proposal.environment,
210
+ allowPrivateNetworks: env?.ALERT_WEBHOOK_ALLOW_PRIVATE === "true",
211
+ logger: runtime.logger,
212
+ grant: {
213
+ proposalId: proposal.id,
214
+ status: proposal.status,
215
+ grantToken: proposal.grantToken,
216
+ },
157
217
  },
158
- });
218
+ );
219
+ // Error text is surfaced to the human: strip anything URL-shaped and
220
+ // the secret so neither the query string nor the HMAC key can leak.
221
+ let error = webhookDelivery.error
222
+ ? webhookDelivery.error.replace(
223
+ /\b[a-z][a-z0-9+.-]*:\/\/\S+/gi,
224
+ "<url>",
225
+ )
226
+ : undefined;
227
+ if (error && target.secret) {
228
+ error = error.split(target.secret).join("<redacted>");
229
+ }
230
+ const record = await recordCallbackDelivery(
231
+ db,
232
+ projectId,
233
+ proposal.environment,
234
+ proposal.id,
235
+ {
236
+ delivered: webhookDelivery.delivered,
237
+ attempts: webhookDelivery.attempts,
238
+ error,
239
+ },
240
+ );
241
+ (
242
+ proposal as { callbackDelivery?: GrantCallbackDelivery | null }
243
+ ).callbackDelivery = record;
244
+ const grantDelivery: GrantDeliveryResult = {
245
+ attempted: true,
246
+ delivered: webhookDelivery.delivered,
247
+ attempts: webhookDelivery.attempts,
248
+ host,
249
+ ...(webhookDelivery.delivered
250
+ ? {}
251
+ : { error: error ?? "unknown error" }),
252
+ };
253
+ return { webhookDelivery, grantDelivery };
159
254
  };
160
255
 
161
256
  // 1. Change Ingestion (Ingest Auth /v1/changes)
@@ -225,52 +320,63 @@ export const autosreRoutesPlugin: CollectorPlugin = {
225
320
  ? body.actor
226
321
  : null;
227
322
 
323
+ const rawInput = {
324
+ id: typeof body.id === "string" ? body.id : undefined,
325
+ environment:
326
+ typeof body.environment === "string" ? body.environment : undefined,
327
+ kind: normalizedKind,
328
+ source:
329
+ body.source === "inferred_telemetry" ||
330
+ body.source === "autosre_remediation" ||
331
+ body.source === "ci_webhook"
332
+ ? body.source
333
+ : ("explicit_api" as const),
334
+ targetNodeId:
335
+ typeof body.targetNodeId === "string" ? body.targetNodeId : undefined,
336
+ serviceName,
337
+ namespace,
338
+ version: typeof body.version === "string" ? body.version : null,
339
+ previousVersion:
340
+ typeof body.previousVersion === "string"
341
+ ? body.previousVersion
342
+ : null,
343
+ commitSha: typeof body.commitSha === "string" ? body.commitSha : null,
344
+ previousCommitSha:
345
+ typeof body.previousCommitSha === "string"
346
+ ? body.previousCommitSha
347
+ : null,
348
+ configDigest:
349
+ typeof body.configDigest === "string" ? body.configDigest : null,
350
+ summary,
351
+ actorId,
352
+ repositoryUrl,
353
+ pullRequestUrl,
354
+ causedByActionId:
355
+ typeof body.causedByActionId === "string"
356
+ ? body.causedByActionId
357
+ : null,
358
+ agentRunId:
359
+ typeof body.agentRunId === "string" ? body.agentRunId : null,
360
+ touchedPaths,
361
+ attributes,
362
+ occurredAt:
363
+ typeof body.occurredAt === "string" ? body.occurredAt : undefined,
364
+ };
365
+ const parsedInput = ChangeEventInputSchema.safeParse(rawInput);
366
+ if (!parsedInput.success) {
367
+ return c.json(
368
+ {
369
+ error: parsedInput.error.issues
370
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
371
+ .join("; "),
372
+ },
373
+ 400,
374
+ );
375
+ }
376
+
228
377
  let changeEvent: Awaited<ReturnType<typeof recordChangeEvent>>;
229
378
  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
- });
379
+ changeEvent = await recordChangeEvent(db, projectId, parsedInput.data);
274
380
  } catch (err) {
275
381
  if (err instanceof InvalidTimestampError) {
276
382
  return c.json({ error: err.message }, 400);
@@ -278,7 +384,12 @@ export const autosreRoutesPlugin: CollectorPlugin = {
278
384
  throw err;
279
385
  }
280
386
 
281
- return c.json({ changeEvent, change: changeEvent }, 201);
387
+ return respondWithContract(
388
+ c,
389
+ "POST /v1/changes",
390
+ { ...changeEvent, changeEvent, change: changeEvent },
391
+ 201,
392
+ );
282
393
  });
283
394
 
284
395
  // 2. Ontology Graph & Fault Localization
@@ -296,7 +407,7 @@ export const autosreRoutesPlugin: CollectorPlugin = {
296
407
  ? await loadTraceFaultLocusFromDb(db, projectId, traceId)
297
408
  : null;
298
409
 
299
- return c.json({
410
+ return respondWithContract(c, "GET /internal/ontology", {
300
411
  ...graph,
301
412
  faultLocus,
302
413
  });
@@ -323,7 +434,7 @@ export const autosreRoutesPlugin: CollectorPlugin = {
323
434
  since: c.req.query("since") || undefined,
324
435
  limit: parseLimit(c.req.query("limit"), 50),
325
436
  });
326
- return c.json({ changes });
437
+ return respondWithContract(c, "GET /internal/changes", { changes });
327
438
  } catch (err) {
328
439
  if (err instanceof InvalidTimestampError) {
329
440
  return c.json({ error: err.message }, 400);
@@ -359,7 +470,10 @@ export const autosreRoutesPlugin: CollectorPlugin = {
359
470
  if (!blastRadius) {
360
471
  return c.json({ error: "Change event not found" }, 404);
361
472
  }
362
- return c.json({ blastRadius, ...blastRadius });
473
+ return respondWithContract(c, "GET /internal/changes/:id/blast-radius", {
474
+ blastRadius,
475
+ ...blastRadius,
476
+ });
363
477
  });
364
478
 
365
479
  // 4. Incident Investigations & Lease Coordination
@@ -372,10 +486,12 @@ export const autosreRoutesPlugin: CollectorPlugin = {
372
486
  status: c.req.query("status") || undefined,
373
487
  limit: parseLimit(c.req.query("limit"), 50),
374
488
  });
375
- return c.json({ incidents });
489
+ return respondWithContract(c, "GET /internal/incidents", { incidents });
376
490
  });
377
491
 
378
- app.post("/internal/incidents", async (c) => {
492
+ const openIncidentHandler = async (
493
+ c: Context<{ Bindings: CollectorEnv }>,
494
+ ) => {
379
495
  const projectId = getProjectId(c);
380
496
  const body = await readJsonObject<Record<string, unknown>>(c);
381
497
  if (!body) {
@@ -429,8 +545,15 @@ export const autosreRoutesPlugin: CollectorPlugin = {
429
545
  ? (body.latestBundle as Record<string, unknown>)
430
546
  : null,
431
547
  });
432
- return c.json(result, result.deduplicated ? 200 : 201);
433
- });
548
+ return respondWithContract(
549
+ c,
550
+ "POST /internal/incidents",
551
+ result,
552
+ result.deduplicated ? 200 : 201,
553
+ );
554
+ };
555
+ app.post("/internal/incidents", openIncidentHandler);
556
+ app.post("/internal/incidents/open", openIncidentHandler);
434
557
 
435
558
  app.post("/internal/incidents/:id/lease", async (c) => {
436
559
  const projectId = getProjectId(c);
@@ -444,7 +567,9 @@ export const autosreRoutesPlugin: CollectorPlugin = {
444
567
  ? body.runnerId.trim()
445
568
  : typeof body.leaseOwner === "string"
446
569
  ? body.leaseOwner.trim()
447
- : "";
570
+ : typeof body.leaseOwnerId === "string"
571
+ ? body.leaseOwnerId.trim()
572
+ : "";
448
573
  if (!runnerId) {
449
574
  return c.json({ error: "runnerId is required" }, 400);
450
575
  }
@@ -456,17 +581,28 @@ export const autosreRoutesPlugin: CollectorPlugin = {
456
581
  : typeof body.ttlSeconds === "number" &&
457
582
  Number.isFinite(body.ttlSeconds)
458
583
  ? body.ttlSeconds
459
- : typeof body.leaseDurationMs === "number" &&
460
- Number.isFinite(body.leaseDurationMs)
461
- ? Math.ceil(body.leaseDurationMs / 1000)
462
- : undefined;
584
+ : typeof body.leaseSeconds === "number" &&
585
+ Number.isFinite(body.leaseSeconds)
586
+ ? body.leaseSeconds
587
+ : typeof body.leaseDurationMs === "number" &&
588
+ Number.isFinite(body.leaseDurationMs)
589
+ ? Math.ceil(body.leaseDurationMs / 1000)
590
+ : undefined;
463
591
  const result = await acquireIncidentLease(db, projectId, incidentId, {
464
592
  environment:
465
593
  typeof body.environment === "string" ? body.environment : undefined,
466
594
  runnerId,
467
595
  leaseDurationSeconds,
468
596
  });
469
- return c.json(result, result.acquired ? 200 : 409);
597
+ if (!result.acquired) {
598
+ return c.json(result, 409);
599
+ }
600
+ return respondWithContract(
601
+ c,
602
+ "POST /internal/incidents/:id/lease",
603
+ result,
604
+ 200,
605
+ );
470
606
  });
471
607
 
472
608
  app.post("/internal/incidents/:id/dispatch", async (c) => {
@@ -493,7 +629,15 @@ export const autosreRoutesPlugin: CollectorPlugin = {
493
629
  allowPrivateNetworks: c.env?.ALERT_WEBHOOK_ALLOW_PRIVATE === "true",
494
630
  logger: runtime.logger,
495
631
  });
496
- return c.json(result, result.delivered ? 200 : 502);
632
+ if (!result.delivered) {
633
+ return c.json({ ...result, dispatched: result.delivered }, 502);
634
+ }
635
+ return respondWithContract(
636
+ c,
637
+ "POST /internal/incidents/:id/dispatch",
638
+ { ...result, dispatched: result.delivered },
639
+ 200,
640
+ );
497
641
  });
498
642
 
499
643
  // 5. Investigation Findings & Counterfactual Verification
@@ -516,7 +660,7 @@ export const autosreRoutesPlugin: CollectorPlugin = {
516
660
  incidentId: c.req.query("incidentId") || undefined,
517
661
  limit: parseLimit(c.req.query("limit"), 20),
518
662
  });
519
- return c.json({ findings });
663
+ return respondWithContract(c, "GET /internal/findings", { findings });
520
664
  });
521
665
 
522
666
  app.post("/internal/findings", async (c) => {
@@ -582,65 +726,67 @@ export const autosreRoutesPlugin: CollectorPlugin = {
582
726
  }
583
727
  }
584
728
 
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,
729
+ const parsed = InvestigationFindingHttpInputSchema.safeParse({
730
+ ...body,
596
731
  title,
597
732
  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,
733
+ symptomEntityKind,
605
734
  symptomEntityId,
606
- faultLocusEntityKind: faultLocusEntityKind as EvidenceEntityKind,
735
+ faultLocusEntityKind,
607
736
  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,
737
+ idempotencyKey:
738
+ typeof body.idempotencyKey === "string"
739
+ ? body.idempotencyKey.trim() || undefined
740
+ : body.idempotencyKey,
741
+ });
742
+ if (!parsed.success) {
743
+ return c.json(
744
+ {
745
+ error: parsed.error.issues
746
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
747
+ .join("; "),
748
+ },
749
+ 400,
750
+ );
751
+ }
752
+ const data = parsed.data;
753
+
754
+ const db = sqlDbFor(c.env);
755
+ const input: InvestigationFindingInput = {
756
+ id: data.id,
757
+ idempotencyKey: data.idempotencyKey,
758
+ environment: data.environment,
759
+ incidentId: data.incidentId ?? null,
760
+ fingerprintInput: data.fingerprintInput,
761
+ title: data.title,
762
+ summary: data.summary,
763
+ causalProofTier: data.causalProofTier,
764
+ confidence: data.confidence,
765
+ symptomEntityKind: data.symptomEntityKind,
766
+ symptomEntityId: data.symptomEntityId,
767
+ faultLocusEntityKind: data.faultLocusEntityKind,
768
+ faultLocusEntityId: data.faultLocusEntityId,
769
+ rootCauseEntityKind: data.rootCauseEntityKind ?? null,
770
+ rootCauseEntityId: data.rootCauseEntityId ?? null,
771
+ contributingChangeId: data.contributingChangeId ?? null,
772
+ cohortVerdict: data.cohortVerdict ?? null,
773
+ contendingTraceId: data.contendingTraceId ?? null,
774
+ causalChain: (data.causalChain ??
775
+ []) as InvestigationFindingInput["causalChain"],
776
+ citedEvidenceIds: data.citedEvidenceIds ?? [],
777
+ codeReferences: data.codeReferences,
778
+ agentRunId: data.agentRunId ?? null,
638
779
  };
639
780
 
640
781
  const finding = await recordInvestigationFinding(db, projectId, input, {
641
782
  logger: runtime.logger,
642
783
  });
643
- return c.json({ finding, id: finding.id }, 201);
784
+ return respondWithContract(
785
+ c,
786
+ "POST /internal/findings",
787
+ { ...finding, finding, id: finding.id },
788
+ 201,
789
+ );
644
790
  });
645
791
 
646
792
  app.post("/internal/findings/:id/counterfactual", async (c) => {
@@ -688,7 +834,15 @@ export const autosreRoutesPlugin: CollectorPlugin = {
688
834
  : undefined,
689
835
  },
690
836
  );
691
- return c.json({ verified: true, finding });
837
+ return respondWithContract(
838
+ c,
839
+ "POST /internal/findings/:id/counterfactual",
840
+ {
841
+ verified: true,
842
+ finding,
843
+ proof: finding.counterfactualProof,
844
+ },
845
+ );
692
846
  } catch (err) {
693
847
  const message = err instanceof Error ? err.message : String(err);
694
848
  if (err instanceof CounterfactualVerificationError) {
@@ -702,6 +856,40 @@ export const autosreRoutesPlugin: CollectorPlugin = {
702
856
  }
703
857
  });
704
858
 
859
+ // Lightweight existence check so callers can validate counterfactual
860
+ // prerequisites before persisting a finding.
861
+ app.get("/internal/evals/runs/:id", async (c) => {
862
+ const projectId = getProjectId(c);
863
+ const runId = c.req.param("id");
864
+ const db = sqlDbFor(c.env);
865
+ const row = await db
866
+ .prepare(
867
+ `SELECT id, eval_case_id, service_version, commit_sha,
868
+ pass_count, fail_count
869
+ FROM eval_runs WHERE project_id = ? AND id = ? LIMIT 1`,
870
+ )
871
+ .bind(projectId, runId)
872
+ .first<{
873
+ id: string;
874
+ eval_case_id: string | null;
875
+ service_version: string | null;
876
+ commit_sha: string | null;
877
+ pass_count: number | null;
878
+ fail_count: number | null;
879
+ }>();
880
+ if (!row) return c.json({ error: "Eval run not found" }, 404);
881
+ return respondWithContract(c, "GET /internal/evals/runs/:id", {
882
+ run: {
883
+ id: row.id,
884
+ evalCaseId: row.eval_case_id,
885
+ serviceVersion: row.service_version,
886
+ commitSha: row.commit_sha,
887
+ passCount: Number(row.pass_count ?? 0),
888
+ failCount: Number(row.fail_count ?? 0),
889
+ },
890
+ });
891
+ });
892
+
705
893
  // 6. Remediation Proposals, Human Approval, Claim Grant, Watchdog
706
894
  app.get("/internal/remediations", async (c) => {
707
895
  const projectId = getProjectId(c);
@@ -716,7 +904,9 @@ export const autosreRoutesPlugin: CollectorPlugin = {
716
904
  undefined,
717
905
  limit: parseLimit(c.req.query("limit"), 50),
718
906
  });
719
- return c.json({ proposals });
907
+ return respondWithContract(c, "GET /internal/remediations", {
908
+ proposals,
909
+ });
720
910
  });
721
911
 
722
912
  app.post("/internal/remediations/propose", async (c) => {
@@ -774,7 +964,23 @@ export const autosreRoutesPlugin: CollectorPlugin = {
774
964
  : "autonomous_write";
775
965
 
776
966
  const db = sqlDbFor(c.env);
777
- const input: RemediationProposalInput = {
967
+ const callbackUrl =
968
+ typeof body.webhookUrl === "string" ? body.webhookUrl.trim() : "";
969
+ if (callbackUrl) {
970
+ try {
971
+ assertSafeWebhookUrl(callbackUrl, {
972
+ allowPrivateNetworks: c.env?.ALERT_WEBHOOK_ALLOW_PRIVATE === "true",
973
+ });
974
+ } catch (err) {
975
+ return c.json(
976
+ {
977
+ error: `webhookUrl rejected: ${err instanceof Error ? err.message : String(err)}`,
978
+ },
979
+ 400,
980
+ );
981
+ }
982
+ }
983
+ const rawInput: RemediationProposalInput = {
778
984
  environment:
779
985
  typeof body.environment === "string" ? body.environment : undefined,
780
986
  incidentId,
@@ -786,23 +992,39 @@ export const autosreRoutesPlugin: CollectorPlugin = {
786
992
  desiredTargetState,
787
993
  requestedAutonomy,
788
994
  };
995
+ const parsedInput = RemediationProposalInputSchema.safeParse(rawInput);
996
+ const input: RemediationProposalInput = parsedInput.success
997
+ ? parsedInput.data
998
+ : rawInput;
789
999
 
790
1000
  try {
791
1001
  const proposal = await proposeRemediationAction(db, projectId, input, {
792
1002
  logger: runtime.logger,
1003
+ callback: callbackUrl
1004
+ ? {
1005
+ url: callbackUrl,
1006
+ secret:
1007
+ typeof body.secret === "string" && body.secret
1008
+ ? body.secret
1009
+ : undefined,
1010
+ }
1011
+ : undefined,
793
1012
  });
794
- const webhookDelivery = await deliverGrant(
1013
+ const { webhookDelivery, grantDelivery } = await deliverGrant(
795
1014
  c.env,
796
1015
  db,
797
1016
  projectId,
798
1017
  body,
799
1018
  proposal,
800
1019
  );
801
- return c.json(
1020
+ return respondWithContract(
1021
+ c,
1022
+ "POST /internal/remediations/propose",
802
1023
  {
803
1024
  proposal,
804
1025
  ...proposal,
805
1026
  grantToken: proposal.grantToken ?? null,
1027
+ grantDelivery,
806
1028
  ...(webhookDelivery ? { webhookDelivery } : {}),
807
1029
  },
808
1030
  201,
@@ -836,6 +1058,13 @@ export const autosreRoutesPlugin: CollectorPlugin = {
836
1058
 
837
1059
  const db = sqlDbFor(c.env);
838
1060
  try {
1061
+ // Read before approving: the approve UPDATE clears the stored callback.
1062
+ const storedCallback = await getRemediationCallback(
1063
+ db,
1064
+ projectId,
1065
+ proposalId,
1066
+ environment,
1067
+ );
839
1068
  const proposal = await approveRemediationProposal(
840
1069
  db,
841
1070
  projectId,
@@ -845,19 +1074,25 @@ export const autosreRoutesPlugin: CollectorPlugin = {
845
1074
  approvedByUserId,
846
1075
  },
847
1076
  );
848
- const webhookDelivery = await deliverGrant(
1077
+ const { webhookDelivery, grantDelivery } = await deliverGrant(
849
1078
  c.env,
850
1079
  db,
851
1080
  projectId,
852
1081
  body,
853
1082
  proposal,
1083
+ storedCallback,
1084
+ );
1085
+ return respondWithContract(
1086
+ c,
1087
+ "POST /internal/remediations/:id/approve",
1088
+ {
1089
+ proposal,
1090
+ ...proposal,
1091
+ grantToken: proposal.grantToken ?? null,
1092
+ grantDelivery,
1093
+ ...(webhookDelivery ? { webhookDelivery } : {}),
1094
+ },
854
1095
  );
855
- return c.json({
856
- proposal,
857
- ...proposal,
858
- grantToken: proposal.grantToken ?? null,
859
- ...(webhookDelivery ? { webhookDelivery } : {}),
860
- });
861
1096
  } catch (err) {
862
1097
  const message = err instanceof Error ? err.message : String(err);
863
1098
  const status =
@@ -897,7 +1132,11 @@ export const autosreRoutesPlugin: CollectorPlugin = {
897
1132
  reason,
898
1133
  },
899
1134
  );
900
- return c.json({ proposal, ...proposal });
1135
+ return respondWithContract(
1136
+ c,
1137
+ "POST /internal/remediations/:id/reject",
1138
+ { proposal, ...proposal },
1139
+ );
901
1140
  } catch (err) {
902
1141
  const message = err instanceof Error ? err.message : String(err);
903
1142
  const status =
@@ -937,7 +1176,11 @@ export const autosreRoutesPlugin: CollectorPlugin = {
937
1176
  logger: runtime.logger,
938
1177
  },
939
1178
  );
940
- return c.json({ claimed: true, proposal, ...proposal });
1179
+ return respondWithContract(c, "POST /internal/remediations/:id/claim", {
1180
+ claimed: true,
1181
+ proposal,
1182
+ ...proposal,
1183
+ });
941
1184
  } catch (err) {
942
1185
  const message = err instanceof Error ? err.message : String(err);
943
1186
  const status =
@@ -983,7 +1226,11 @@ export const autosreRoutesPlugin: CollectorPlugin = {
983
1226
  : null,
984
1227
  },
985
1228
  );
986
- return c.json({ proposal, ...proposal });
1229
+ return respondWithContract(
1230
+ c,
1231
+ "POST /internal/remediations/:id/complete",
1232
+ { proposal, ...proposal },
1233
+ );
987
1234
  } catch (err) {
988
1235
  const message = err instanceof Error ? err.message : String(err);
989
1236
  const status =
@@ -1022,7 +1269,10 @@ export const autosreRoutesPlugin: CollectorPlugin = {
1022
1269
  : "Execution failed",
1023
1270
  },
1024
1271
  );
1025
- return c.json({ proposal, ...proposal });
1272
+ return respondWithContract(c, "POST /internal/remediations/:id/fail", {
1273
+ proposal,
1274
+ ...proposal,
1275
+ });
1026
1276
  } catch (err) {
1027
1277
  const message = err instanceof Error ? err.message : String(err);
1028
1278
  const status =
@@ -1049,7 +1299,10 @@ export const autosreRoutesPlugin: CollectorPlugin = {
1049
1299
  : undefined,
1050
1300
  logger: runtime.logger,
1051
1301
  });
1052
- return c.json({ summary, ...summary });
1302
+ return respondWithContract(c, "POST /internal/remediations/watchdog", {
1303
+ summary,
1304
+ ...summary,
1305
+ });
1053
1306
  });
1054
1307
 
1055
1308
  app.get("/internal/remediations/:id", async (c) => {
@@ -1065,7 +1318,9 @@ export const autosreRoutesPlugin: CollectorPlugin = {
1065
1318
  if (!proposal) {
1066
1319
  return c.json({ error: "Proposal not found" }, 404);
1067
1320
  }
1068
- return c.json({ proposal });
1321
+ return respondWithContract(c, "GET /internal/remediations/:id", {
1322
+ proposal,
1323
+ });
1069
1324
  });
1070
1325
 
1071
1326
  app.get("/internal/incidents/:id", async (c) => {
@@ -1096,7 +1351,11 @@ export const autosreRoutesPlugin: CollectorPlugin = {
1096
1351
  limit: 20,
1097
1352
  }),
1098
1353
  ]);
1099
- return c.json({ incident, findings, proposals });
1354
+ return respondWithContract(c, "GET /internal/incidents/:id", {
1355
+ incident,
1356
+ findings,
1357
+ proposals,
1358
+ });
1100
1359
  });
1101
1360
 
1102
1361
  app.get("/internal/findings/:id", async (c) => {
@@ -1112,7 +1371,7 @@ export const autosreRoutesPlugin: CollectorPlugin = {
1112
1371
  if (!finding) {
1113
1372
  return c.json({ error: "Finding not found" }, 404);
1114
1373
  }
1115
- return c.json({ finding });
1374
+ return respondWithContract(c, "GET /internal/findings/:id", { finding });
1116
1375
  });
1117
1376
 
1118
1377
  app.get("/internal/changes/:id", async (c) => {
@@ -1128,7 +1387,10 @@ export const autosreRoutesPlugin: CollectorPlugin = {
1128
1387
  if (!changeEvent) {
1129
1388
  return c.json({ error: "Change event not found" }, 404);
1130
1389
  }
1131
- return c.json({ changeEvent, change: changeEvent });
1390
+ return respondWithContract(c, "GET /internal/changes/:id", {
1391
+ changeEvent,
1392
+ change: changeEvent,
1393
+ });
1132
1394
  });
1133
1395
  },
1134
1396
  };