@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.
- package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
- package/dist/chunk-ZSF5XP3B.js.map +1 -0
- package/dist/index.d.ts +359 -2
- package/dist/index.js +5000 -334
- package/dist/index.js.map +1 -1
- package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
- package/package.json +2 -2
- package/src/__tests__/autosre-closed-loop.test.ts +2222 -0
- package/src/index.ts +43 -0
- package/src/lib/analyses-runner.ts +46 -0
- package/src/lib/autosre-change-correlation.ts +807 -0
- package/src/lib/autosre-findings.ts +763 -0
- package/src/lib/autosre-ontology-cache.test.ts +78 -0
- package/src/lib/autosre-ontology.ts +938 -0
- package/src/lib/autosre-remediation-gate.ts +2127 -0
- package/src/lib/eval-cases-store.ts +63 -1
- package/src/lib/evidence-references.ts +6 -0
- package/src/lib/housekeeping.test.ts +150 -0
- package/src/lib/housekeeping.ts +109 -1
- package/src/lib/store/ingest.ts +46 -6
- package/src/migrations/050_autosre_causal_ontology.sql +216 -0
- package/src/migrations/051_incident_active_dedup_unique.sql +31 -0
- package/src/migrations/alert-state-notified.test.ts +3 -3
- package/src/migrations/parity.test.ts +4 -0
- package/src/migrations/project-scoped-keys.test.ts +1 -1
- package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
- package/src/migrations-postgres/051_incident_active_dedup_unique.sql +31 -0
- package/src/plugins/alerts-evaluator.ts +29 -1
- package/src/plugins/autosre-routes.ts +1134 -0
- package/src/plugins/connected-routes/manifest.ts +10 -1
- package/src/plugins/connected-routes.ts +241 -0
- package/src/plugins/default-span-enrichment.ts +13 -1
- package/src/plugins/eval-cases-routes.ts +57 -2
- package/src/plugins/evidence-retrieval-routes.test.ts +32 -0
- package/src/plugins/evidence-retrieval-routes.ts +643 -5
- package/dist/chunk-XKQTVIRE.js.map +0 -1
- /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
|
+
};
|