@obsunified/collector 2.2.0 → 2.2.3
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/auth.js +1 -1
- package/dist/{chunk-KMEZKGSO.js → chunk-2FALTDCE.js} +12 -4
- package/dist/chunk-2FALTDCE.js.map +1 -0
- package/dist/index.d.ts +61 -3
- package/dist/index.js +10933 -9769
- package/dist/index.js.map +1 -1
- package/package.json +2 -3
- package/src/__tests__/autosre-closed-loop.test.ts +1554 -17
- package/src/auth/dashboard-auth.test.ts +61 -0
- package/src/auth/dashboard-auth.ts +17 -2
- package/src/lib/ai-store/calls.ts +43 -23
- package/src/lib/ai-store.ts +12 -1
- package/src/lib/analyses-runner.ts +44 -7
- package/src/lib/autosre-change-correlation.ts +39 -12
- package/src/lib/autosre-findings.ts +237 -44
- package/src/lib/autosre-ontology-cache.test.ts +78 -0
- package/src/lib/autosre-ontology.ts +52 -28
- package/src/lib/autosre-remediation-gate.ts +942 -348
- package/src/lib/housekeeping.test.ts +70 -0
- package/src/lib/housekeeping.ts +91 -83
- package/src/lib/projects-store.test.ts +38 -1
- package/src/lib/projects-store.ts +23 -4
- package/src/lib/sql-db-postgres.integration.test.ts +75 -2
- package/src/lib/store/ingest.ts +8 -56
- package/src/lib/usage-store.ts +9 -3
- package/src/migrations/051_incident_active_dedup_unique.sql +31 -0
- package/src/migrations/052_remediation_callback.sql +10 -0
- package/src/migrations/053_remediation_callback_delivery.sql +7 -0
- package/src/migrations/alert-state-notified.test.ts +3 -3
- package/src/migrations-postgres/051_incident_active_dedup_unique.sql +31 -0
- package/src/migrations-postgres/052_remediation_callback.sql +10 -0
- package/src/migrations-postgres/053_remediation_callback_delivery.sql +7 -0
- package/src/plugins/ai-call-by-id.test.ts +73 -0
- package/src/plugins/ai-receiver.ts +10 -0
- package/src/plugins/alerts-evaluator.ts +15 -3
- package/src/plugins/autosre-routes.ts +374 -108
- package/src/plugins/connected-routes/deep-link-shapes.test.ts +44 -0
- package/src/plugins/connected-routes/manifest.test.ts +142 -0
- package/src/plugins/connected-routes/manifest.ts +123 -6
- package/src/plugins/connected-routes-links.test.ts +96 -0
- package/src/plugins/connected-routes.test.ts +3 -3
- package/src/plugins/connected-routes.ts +56 -83
- package/src/plugins/evidence-retrieval-routes.test.ts +32 -0
- package/src/plugins/evidence-retrieval-routes.ts +397 -25
- package/dist/chunk-KMEZKGSO.js.map +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
CausalTriple,
|
|
3
3
|
EvidenceEntityKind,
|
|
4
|
+
GrantCallbackDelivery,
|
|
4
5
|
IncidentInvestigation,
|
|
5
6
|
IncidentStatus,
|
|
6
7
|
RemediationActionKind,
|
|
@@ -9,12 +10,18 @@ import type {
|
|
|
9
10
|
RemediationProposalStatus,
|
|
10
11
|
RemediationStateSpec,
|
|
11
12
|
} from "@obsunified/types";
|
|
13
|
+
import { buildCanonicalNodeId } from "@obsunified/types";
|
|
12
14
|
import { AgentAutonomyLevel } from "@obsunified/types/constants";
|
|
15
|
+
import { consoleLogger, type Logger } from "../framework/logger";
|
|
13
16
|
import { getInvestigationFinding } from "./autosre-findings";
|
|
14
17
|
import { randomHex, sha256Hex } from "./hash";
|
|
15
18
|
import { parseJsonRecord } from "./json";
|
|
16
19
|
import type { SqlDb } from "./sql-db";
|
|
17
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
assertSafeWebhookUrl,
|
|
22
|
+
type HostLookup,
|
|
23
|
+
resolveWebhookHost,
|
|
24
|
+
} from "./webhook-url";
|
|
18
25
|
|
|
19
26
|
interface IncidentRow {
|
|
20
27
|
project_id: string;
|
|
@@ -70,6 +77,89 @@ export const ACTIVE_INCIDENT_STATUSES: ReadonlySet<IncidentStatus> = new Set([
|
|
|
70
77
|
"verifying",
|
|
71
78
|
]);
|
|
72
79
|
|
|
80
|
+
const ACTIVE_STATUS_SQL =
|
|
81
|
+
"('open', 'dispatched', 'leased', 'remediating', 'verifying')";
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Constant-time comparison for equal-purpose hex digests. Length is not secret
|
|
85
|
+
* (always a SHA-256 hex digest), so a length mismatch returns early.
|
|
86
|
+
*/
|
|
87
|
+
function constantTimeEqual(a: string, b: string): boolean {
|
|
88
|
+
if (a.length !== b.length) return false;
|
|
89
|
+
let diff = 0;
|
|
90
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
91
|
+
return diff === 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function loadProposalRow(
|
|
95
|
+
db: SqlDb,
|
|
96
|
+
projectId: string,
|
|
97
|
+
proposalId: string,
|
|
98
|
+
environment?: string,
|
|
99
|
+
): Promise<ProposalRow | null> {
|
|
100
|
+
return environment
|
|
101
|
+
? await db
|
|
102
|
+
.prepare(
|
|
103
|
+
`SELECT * FROM remediation_proposals
|
|
104
|
+
WHERE project_id = ? AND environment = ? AND id = ?`,
|
|
105
|
+
)
|
|
106
|
+
.bind(projectId, environment.toLowerCase(), proposalId)
|
|
107
|
+
.first<ProposalRow>()
|
|
108
|
+
: await db
|
|
109
|
+
.prepare(
|
|
110
|
+
`SELECT * FROM remediation_proposals
|
|
111
|
+
WHERE project_id = ? AND id = ?
|
|
112
|
+
ORDER BY created_at DESC
|
|
113
|
+
LIMIT 1`,
|
|
114
|
+
)
|
|
115
|
+
.bind(projectId, proposalId)
|
|
116
|
+
.first<ProposalRow>();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolves the environment and canonical node id an alert/analysis-triggered
|
|
121
|
+
* incident should target. The environment is taken from the most recent span of
|
|
122
|
+
* the service (rules and analyses carry no environment of their own); with no
|
|
123
|
+
* service or no spans it falls back to `default` and the supplied node id.
|
|
124
|
+
*/
|
|
125
|
+
export async function resolveServiceIncidentTarget(
|
|
126
|
+
db: SqlDb,
|
|
127
|
+
projectId: string,
|
|
128
|
+
serviceName: string | null | undefined,
|
|
129
|
+
fallbackNodeId: string,
|
|
130
|
+
logger: Logger = consoleLogger,
|
|
131
|
+
): Promise<{ environment: string; targetNodeId: string }> {
|
|
132
|
+
const service = serviceName?.trim();
|
|
133
|
+
if (!service) return { environment: "default", targetNodeId: fallbackNodeId };
|
|
134
|
+
let environment = "default";
|
|
135
|
+
try {
|
|
136
|
+
const row = await db
|
|
137
|
+
.prepare(
|
|
138
|
+
`SELECT environment FROM telemetry_spans
|
|
139
|
+
WHERE project_id = ? AND service_name = ?
|
|
140
|
+
ORDER BY received_at DESC
|
|
141
|
+
LIMIT 1`,
|
|
142
|
+
)
|
|
143
|
+
.bind(projectId, service)
|
|
144
|
+
.first<{ environment: string | null }>();
|
|
145
|
+
if (row?.environment) environment = row.environment.trim().toLowerCase();
|
|
146
|
+
} catch (err) {
|
|
147
|
+
logger.warn("[autosre] could not resolve incident environment", {
|
|
148
|
+
projectId,
|
|
149
|
+
service,
|
|
150
|
+
error: err instanceof Error ? err.message : String(err),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
environment,
|
|
155
|
+
targetNodeId: buildCanonicalNodeId({
|
|
156
|
+
kind: "service",
|
|
157
|
+
environment,
|
|
158
|
+
serviceName: service,
|
|
159
|
+
}).id,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
73
163
|
/**
|
|
74
164
|
* Opens or deduplicates an active `incident_investigation` (RFC 0013 § 6.A & P2 #7).
|
|
75
165
|
* Multiple firing alerts or webhook retries for the same `(project_id, environment, target_node_id, symptom_category)`
|
|
@@ -100,20 +190,23 @@ export async function openOrDeduplicateIncident(
|
|
|
100
190
|
`${projectId}::${environment}::${input.targetNodeId}::${category}`,
|
|
101
191
|
);
|
|
102
192
|
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
193
|
+
const selectActive = () =>
|
|
194
|
+
db
|
|
195
|
+
.prepare(
|
|
196
|
+
`SELECT * FROM incident_investigations
|
|
197
|
+
WHERE project_id = ?
|
|
198
|
+
AND environment = ?
|
|
199
|
+
AND dedup_key = ?
|
|
200
|
+
AND status IN ${ACTIVE_STATUS_SQL}
|
|
201
|
+
ORDER BY opened_at DESC
|
|
202
|
+
LIMIT 1`,
|
|
203
|
+
)
|
|
204
|
+
.bind(projectId, environment, dedupKey)
|
|
205
|
+
.first<IncidentRow>();
|
|
115
206
|
|
|
116
|
-
|
|
207
|
+
const coalesceInto = async (
|
|
208
|
+
row: IncidentRow,
|
|
209
|
+
): Promise<{ incident: IncidentInvestigation; deduplicated: boolean }> => {
|
|
117
210
|
await db
|
|
118
211
|
.prepare(
|
|
119
212
|
`UPDATE incident_investigations
|
|
@@ -129,35 +222,39 @@ export async function openOrDeduplicateIncident(
|
|
|
129
222
|
nowIso,
|
|
130
223
|
projectId,
|
|
131
224
|
environment,
|
|
132
|
-
|
|
225
|
+
row.id,
|
|
133
226
|
)
|
|
134
227
|
.run();
|
|
135
228
|
|
|
136
229
|
const updated = await getIncidentInvestigation(
|
|
137
230
|
db,
|
|
138
231
|
projectId,
|
|
139
|
-
|
|
232
|
+
row.id,
|
|
140
233
|
environment,
|
|
141
234
|
);
|
|
142
235
|
return {
|
|
143
|
-
incident: updated ?? mapIncidentRow(
|
|
236
|
+
incident: updated ?? mapIncidentRow(row),
|
|
144
237
|
deduplicated: true,
|
|
145
238
|
};
|
|
146
|
-
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const existing = await selectActive();
|
|
242
|
+
if (existing) return coalesceInto(existing);
|
|
147
243
|
|
|
148
244
|
const id = input.id?.trim() || `inc_${randomHex(10)}`;
|
|
149
|
-
const expiresAt = new Date(
|
|
150
|
-
Date.now() + 30 * 24 * 3600_000,
|
|
151
|
-
).toISOString();
|
|
245
|
+
const expiresAt = new Date(Date.now() + 30 * 24 * 3600_000).toISOString();
|
|
152
246
|
|
|
153
|
-
|
|
247
|
+
// The partial unique index uq_incidents_active_dedup (migration 051) makes
|
|
248
|
+
// a concurrent duplicate insert a no-op instead of a second active incident.
|
|
249
|
+
const inserted = await db
|
|
154
250
|
.prepare(
|
|
155
251
|
`INSERT INTO incident_investigations (
|
|
156
252
|
project_id, environment, id, dedup_key, status, target_node_id,
|
|
157
253
|
symptom_anchor_kind, symptom_anchor_id, symptom_fingerprint,
|
|
158
254
|
alert_count, latest_bundle_json, dispatch_attempts,
|
|
159
255
|
opened_at, updated_at, expires_at
|
|
160
|
-
) VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, 1, ?, 0, ?, ?, ?)
|
|
256
|
+
) VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, 1, ?, 0, ?, ?, ?)
|
|
257
|
+
ON CONFLICT DO NOTHING`,
|
|
161
258
|
)
|
|
162
259
|
.bind(
|
|
163
260
|
projectId,
|
|
@@ -175,7 +272,22 @@ export async function openOrDeduplicateIncident(
|
|
|
175
272
|
)
|
|
176
273
|
.run();
|
|
177
274
|
|
|
178
|
-
|
|
275
|
+
if (inserted.meta.changes === 0) {
|
|
276
|
+
// Lost the race to a concurrent opener: coalesce into the winner.
|
|
277
|
+
const winner = await selectActive();
|
|
278
|
+
if (winner) return coalesceInto(winner);
|
|
279
|
+
throw new RemediationGateError(
|
|
280
|
+
`Incident id ${id} already exists in environment ${environment}`,
|
|
281
|
+
409,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const created = await getIncidentInvestigation(
|
|
286
|
+
db,
|
|
287
|
+
projectId,
|
|
288
|
+
id,
|
|
289
|
+
environment,
|
|
290
|
+
);
|
|
179
291
|
if (!created) {
|
|
180
292
|
throw new Error(`Failed to load created incident ${id}`);
|
|
181
293
|
}
|
|
@@ -229,7 +341,7 @@ export async function listIncidentInvestigations(
|
|
|
229
341
|
conditions.push("status = ?");
|
|
230
342
|
binds.push(options.status);
|
|
231
343
|
}
|
|
232
|
-
const limit = Math.max(1, Math.min(
|
|
344
|
+
const limit = Math.max(1, Math.min(200, options.limit ?? 50));
|
|
233
345
|
binds.push(limit);
|
|
234
346
|
|
|
235
347
|
const rows = await db
|
|
@@ -285,17 +397,12 @@ export async function acquireIncidentLease(
|
|
|
285
397
|
};
|
|
286
398
|
}
|
|
287
399
|
|
|
288
|
-
if (
|
|
289
|
-
incident.leaseOwnerId &&
|
|
290
|
-
incident.leaseOwnerId !== input.runnerId &&
|
|
291
|
-
incident.leaseExpiresAt &&
|
|
292
|
-
incident.leaseExpiresAt > nowIso
|
|
293
|
-
) {
|
|
400
|
+
if (!ACTIVE_INCIDENT_STATUSES.has(incident.status)) {
|
|
294
401
|
return {
|
|
295
402
|
acquired: false,
|
|
296
|
-
reason: `Incident ${incidentId} is
|
|
297
|
-
conflictOwnerId:
|
|
298
|
-
leaseExpiresAt:
|
|
403
|
+
reason: `Incident ${incidentId} is ${incident.status} and can no longer be leased`,
|
|
404
|
+
conflictOwnerId: null,
|
|
405
|
+
leaseExpiresAt: null,
|
|
299
406
|
};
|
|
300
407
|
}
|
|
301
408
|
|
|
@@ -306,28 +413,32 @@ export async function acquireIncidentLease(
|
|
|
306
413
|
const leaseExpiresAt = new Date(
|
|
307
414
|
Date.parse(nowIso) + durationSec * 1000,
|
|
308
415
|
).toISOString();
|
|
309
|
-
const nextStatus: IncidentStatus =
|
|
310
|
-
incident.status === "open" || incident.status === "dispatched"
|
|
311
|
-
? "leased"
|
|
312
|
-
: incident.status;
|
|
313
416
|
|
|
314
|
-
|
|
417
|
+
// The lease precondition lives in the UPDATE itself so two runners racing
|
|
418
|
+
// for the same incident cannot both succeed: only one sees changes === 1.
|
|
419
|
+
const res = await db
|
|
315
420
|
.prepare(
|
|
316
421
|
`UPDATE incident_investigations
|
|
317
422
|
SET lease_owner_id = ?,
|
|
318
423
|
lease_expires_at = ?,
|
|
319
|
-
status =
|
|
424
|
+
status = CASE WHEN status IN ('open', 'dispatched') THEN 'leased' ELSE status END,
|
|
320
425
|
updated_at = ?
|
|
321
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
426
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
427
|
+
AND status IN ${ACTIVE_STATUS_SQL}
|
|
428
|
+
AND (lease_owner_id IS NULL
|
|
429
|
+
OR lease_owner_id = ?
|
|
430
|
+
OR lease_expires_at IS NULL
|
|
431
|
+
OR lease_expires_at <= ?)`,
|
|
322
432
|
)
|
|
323
433
|
.bind(
|
|
324
434
|
input.runnerId,
|
|
325
435
|
leaseExpiresAt,
|
|
326
|
-
nextStatus,
|
|
327
436
|
nowIso,
|
|
328
437
|
projectId,
|
|
329
438
|
incident.environment,
|
|
330
439
|
incident.id,
|
|
440
|
+
input.runnerId,
|
|
441
|
+
nowIso,
|
|
331
442
|
)
|
|
332
443
|
.run();
|
|
333
444
|
|
|
@@ -337,13 +448,20 @@ export async function acquireIncidentLease(
|
|
|
337
448
|
incident.id,
|
|
338
449
|
incident.environment,
|
|
339
450
|
);
|
|
451
|
+
if (res.meta.changes !== 1) {
|
|
452
|
+
return {
|
|
453
|
+
acquired: false,
|
|
454
|
+
reason: `Incident ${incidentId} is already leased by runner ${updated?.leaseOwnerId ?? "unknown"} until ${updated?.leaseExpiresAt ?? "unknown"}`,
|
|
455
|
+
conflictOwnerId: updated?.leaseOwnerId ?? null,
|
|
456
|
+
leaseExpiresAt: updated?.leaseExpiresAt ?? null,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
340
459
|
return {
|
|
341
460
|
acquired: true,
|
|
342
461
|
incident: updated ?? {
|
|
343
462
|
...incident,
|
|
344
463
|
leaseOwnerId: input.runnerId,
|
|
345
464
|
leaseExpiresAt,
|
|
346
|
-
status: nextStatus,
|
|
347
465
|
updatedAt: nowIso,
|
|
348
466
|
},
|
|
349
467
|
};
|
|
@@ -368,9 +486,45 @@ async function computeHmacSha256Hex(
|
|
|
368
486
|
return hex;
|
|
369
487
|
}
|
|
370
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Resolves the DNS-classification hook for hosts that can resolve names
|
|
491
|
+
* (Node: `dns.lookup`). Workers cannot reach private networks from `fetch`, so
|
|
492
|
+
* there the hook is left unset. The specifier is built at runtime so bundlers
|
|
493
|
+
* for Workers do not try to resolve `node:dns` statically.
|
|
494
|
+
*/
|
|
495
|
+
async function defaultHostLookup(): Promise<HostLookup | undefined> {
|
|
496
|
+
if (typeof process === "undefined" || !process.versions?.node) {
|
|
497
|
+
return undefined;
|
|
498
|
+
}
|
|
499
|
+
try {
|
|
500
|
+
const specifier = "node:dns/promises";
|
|
501
|
+
const dns = (await import(/* @vite-ignore */ specifier)) as {
|
|
502
|
+
lookup: (
|
|
503
|
+
host: string,
|
|
504
|
+
opts: { all: true; verbatim: true },
|
|
505
|
+
) => Promise<Array<{ address: string }>>;
|
|
506
|
+
};
|
|
507
|
+
return async (hostname) =>
|
|
508
|
+
(await dns.lookup(hostname, { all: true, verbatim: true })).map(
|
|
509
|
+
(a) => a.address,
|
|
510
|
+
);
|
|
511
|
+
} catch {
|
|
512
|
+
return undefined;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
371
516
|
/**
|
|
372
517
|
* SSRF-safe, HMAC-SHA256 signed AutoSRE incident webhook outbox delivery
|
|
373
|
-
* (RFC 0013 § 6.A).
|
|
518
|
+
* (RFC 0013 § 6.A). Mirrors the alert webhook delivery: literal host check,
|
|
519
|
+
* DNS classification (fail closed on private/unresolvable), no redirects and
|
|
520
|
+
* a 5s timeout.
|
|
521
|
+
*
|
|
522
|
+
* Grant handoff (RFC 0013 § 6.B): when `webhook.grant` is supplied the payload
|
|
523
|
+
* is an `autosre.remediation.grant_issued` event carrying `proposalId`,
|
|
524
|
+
* `status` and the plaintext `grantToken`. The plaintext token exists only at
|
|
525
|
+
* approve/propose time (the DB keeps its hash), so the caller that mints it
|
|
526
|
+
* must hand it to this function in the same request. Grant deliveries do not
|
|
527
|
+
* count against the incident's 5-attempt dispatch budget.
|
|
374
528
|
*/
|
|
375
529
|
export async function dispatchIncidentWebhook(
|
|
376
530
|
db: SqlDb,
|
|
@@ -379,25 +533,74 @@ export async function dispatchIncidentWebhook(
|
|
|
379
533
|
webhook: {
|
|
380
534
|
url: string;
|
|
381
535
|
secret?: string;
|
|
536
|
+
environment?: string;
|
|
382
537
|
allowPrivateNetworks?: boolean;
|
|
538
|
+
hostLookup?: HostLookup;
|
|
383
539
|
fetchImpl?: typeof fetch;
|
|
540
|
+
logger?: Logger;
|
|
541
|
+
grant?: { proposalId: string; status: string; grantToken: string };
|
|
542
|
+
/** Test hook: replaces the real backoff sleep between grant attempts. */
|
|
543
|
+
sleep?: (ms: number) => Promise<void>;
|
|
384
544
|
},
|
|
385
|
-
): Promise<{
|
|
386
|
-
|
|
545
|
+
): Promise<{
|
|
546
|
+
delivered: boolean;
|
|
547
|
+
status?: number;
|
|
548
|
+
error?: string;
|
|
549
|
+
attempts: number;
|
|
550
|
+
}> {
|
|
551
|
+
const logger = webhook.logger ?? consoleLogger;
|
|
552
|
+
const incident = await getIncidentInvestigation(
|
|
553
|
+
db,
|
|
554
|
+
projectId,
|
|
555
|
+
incidentId,
|
|
556
|
+
webhook.environment,
|
|
557
|
+
);
|
|
387
558
|
if (!incident) {
|
|
388
|
-
return { delivered: false, error: "Incident not found" };
|
|
559
|
+
return { delivered: false, error: "Incident not found", attempts: 0 };
|
|
389
560
|
}
|
|
390
|
-
if (incident.dispatchAttempts >= 5) {
|
|
391
|
-
return {
|
|
561
|
+
if (!webhook.grant && incident.dispatchAttempts >= 5) {
|
|
562
|
+
return {
|
|
563
|
+
delivered: false,
|
|
564
|
+
error: "MAX_DISPATCH_ATTEMPTS (5) exceeded",
|
|
565
|
+
attempts: 0,
|
|
566
|
+
};
|
|
392
567
|
}
|
|
393
568
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
569
|
+
let safeUrl: URL;
|
|
570
|
+
try {
|
|
571
|
+
safeUrl = assertSafeWebhookUrl(webhook.url, {
|
|
572
|
+
allowPrivateNetworks: webhook.allowPrivateNetworks,
|
|
573
|
+
});
|
|
574
|
+
if (!webhook.allowPrivateNetworks) {
|
|
575
|
+
const lookup = webhook.hostLookup ?? (await defaultHostLookup());
|
|
576
|
+
if (lookup) {
|
|
577
|
+
const resolution = await resolveWebhookHost(safeUrl.hostname, lookup);
|
|
578
|
+
if (resolution === "private") {
|
|
579
|
+
throw new Error(
|
|
580
|
+
`webhook host "${safeUrl.hostname}" resolves to a private address`,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
if (resolution === "unresolvable") {
|
|
584
|
+
throw new Error(
|
|
585
|
+
`webhook host "${safeUrl.hostname}" could not be resolved; refusing to deliver blind`,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
} catch (err) {
|
|
591
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
592
|
+
logger.warn("[autosre] incident webhook target refused", {
|
|
593
|
+
incidentId,
|
|
594
|
+
error,
|
|
595
|
+
});
|
|
596
|
+
return { delivered: false, error, attempts: 0 };
|
|
597
|
+
}
|
|
397
598
|
|
|
398
599
|
const nowIso = new Date().toISOString();
|
|
399
600
|
const body = JSON.stringify({
|
|
400
|
-
event:
|
|
601
|
+
event: webhook.grant
|
|
602
|
+
? "autosre.remediation.grant_issued"
|
|
603
|
+
: "autosre.incident.opened",
|
|
401
604
|
incidentId: incident.id,
|
|
402
605
|
projectId: incident.projectId,
|
|
403
606
|
environment: incident.environment,
|
|
@@ -408,6 +611,13 @@ export async function dispatchIncidentWebhook(
|
|
|
408
611
|
id: incident.symptomAnchorId,
|
|
409
612
|
},
|
|
410
613
|
bundle: incident.latestBundle,
|
|
614
|
+
...(webhook.grant
|
|
615
|
+
? {
|
|
616
|
+
proposalId: webhook.grant.proposalId,
|
|
617
|
+
status: webhook.grant.status,
|
|
618
|
+
grantToken: webhook.grant.grantToken,
|
|
619
|
+
}
|
|
620
|
+
: {}),
|
|
411
621
|
dispatchedAt: nowIso,
|
|
412
622
|
});
|
|
413
623
|
|
|
@@ -422,23 +632,59 @@ export async function dispatchIncidentWebhook(
|
|
|
422
632
|
}
|
|
423
633
|
|
|
424
634
|
const fetchFn = webhook.fetchImpl ?? fetch;
|
|
635
|
+
const sleep =
|
|
636
|
+
webhook.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
|
|
637
|
+
// Grant handoffs retry on network error, timeout and 5xx (never 4xx or a
|
|
638
|
+
// redirect); each attempt keeps the manual-redirect and 5 s timeout rules.
|
|
639
|
+
const maxAttempts = webhook.grant ? GRANT_DELIVERY_BACKOFF_MS.length + 1 : 1;
|
|
425
640
|
let delivered = false;
|
|
426
641
|
let status: number | undefined;
|
|
427
642
|
let errorMsg: string | undefined;
|
|
643
|
+
let attempts = 0;
|
|
644
|
+
|
|
645
|
+
while (attempts < maxAttempts) {
|
|
646
|
+
attempts++;
|
|
647
|
+
let retryable = false;
|
|
648
|
+
status = undefined;
|
|
649
|
+
errorMsg = undefined;
|
|
650
|
+
try {
|
|
651
|
+
const res = await fetchFn(safeUrl.toString(), {
|
|
652
|
+
method: "POST",
|
|
653
|
+
headers,
|
|
654
|
+
body,
|
|
655
|
+
// Never follow redirects: a 3xx could re-aim the POST (and its
|
|
656
|
+
// signed body, which may carry a grant token) at another host.
|
|
657
|
+
redirect: "manual",
|
|
658
|
+
signal: AbortSignal.timeout(5000),
|
|
659
|
+
});
|
|
660
|
+
status = res.status;
|
|
661
|
+
if (res.status >= 300 && res.status < 400) {
|
|
662
|
+
errorMsg = `Redirect (HTTP ${res.status}) refused`;
|
|
663
|
+
} else {
|
|
664
|
+
delivered = res.ok;
|
|
665
|
+
if (!res.ok) {
|
|
666
|
+
errorMsg = `HTTP ${res.status}`;
|
|
667
|
+
retryable = res.status >= 500;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
} catch (err) {
|
|
671
|
+
errorMsg = err instanceof Error ? err.message : String(err);
|
|
672
|
+
retryable = true;
|
|
673
|
+
}
|
|
674
|
+
if (delivered || !retryable || attempts >= maxAttempts) break;
|
|
675
|
+
await sleep(GRANT_DELIVERY_BACKOFF_MS[attempts - 1]);
|
|
676
|
+
}
|
|
428
677
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
678
|
+
if (!delivered) {
|
|
679
|
+
logger.warn("[autosre] incident webhook delivery failed", {
|
|
680
|
+
incidentId,
|
|
681
|
+
error: errorMsg,
|
|
682
|
+
attempts,
|
|
434
683
|
});
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}
|
|
440
|
-
} catch (err) {
|
|
441
|
-
errorMsg = err instanceof Error ? err.message : String(err);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (webhook.grant) {
|
|
687
|
+
return { delivered, status, error: errorMsg, attempts };
|
|
442
688
|
}
|
|
443
689
|
|
|
444
690
|
await db
|
|
@@ -460,7 +706,37 @@ export async function dispatchIncidentWebhook(
|
|
|
460
706
|
)
|
|
461
707
|
.run();
|
|
462
708
|
|
|
463
|
-
return { delivered, status, error: errorMsg };
|
|
709
|
+
return { delivered, status, error: errorMsg, attempts };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Backoff before attempt 2 and 3 of a grant webhook delivery. */
|
|
713
|
+
const GRANT_DELIVERY_BACKOFF_MS = [500, 1500];
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Persists the last grant-webhook delivery outcome on the proposal. The JSON
|
|
717
|
+
* holds only status/attempts/error/time, never the URL or secret.
|
|
718
|
+
*/
|
|
719
|
+
export async function recordCallbackDelivery(
|
|
720
|
+
db: SqlDb,
|
|
721
|
+
projectId: string,
|
|
722
|
+
environment: string,
|
|
723
|
+
proposalId: string,
|
|
724
|
+
outcome: { delivered: boolean; attempts: number; error?: string },
|
|
725
|
+
): Promise<GrantCallbackDelivery> {
|
|
726
|
+
const record: GrantCallbackDelivery = {
|
|
727
|
+
status: outcome.delivered ? "delivered" : "failed",
|
|
728
|
+
attempts: outcome.attempts,
|
|
729
|
+
lastError: outcome.delivered ? null : (outcome.error ?? null),
|
|
730
|
+
at: new Date().toISOString(),
|
|
731
|
+
};
|
|
732
|
+
await db
|
|
733
|
+
.prepare(
|
|
734
|
+
`UPDATE remediation_proposals SET callback_delivery = ?
|
|
735
|
+
WHERE project_id = ? AND environment = ? AND id = ?`,
|
|
736
|
+
)
|
|
737
|
+
.bind(JSON.stringify(record), projectId, environment, proposalId)
|
|
738
|
+
.run();
|
|
739
|
+
return record;
|
|
464
740
|
}
|
|
465
741
|
|
|
466
742
|
interface ProposalRow {
|
|
@@ -488,11 +764,42 @@ interface ProposalRow {
|
|
|
488
764
|
remediation_agent_run_id: string | null;
|
|
489
765
|
resulting_change_id: string | null;
|
|
490
766
|
failure_reason: string | null;
|
|
767
|
+
callback_url?: string | null;
|
|
768
|
+
callback_secret?: string | null;
|
|
769
|
+
callback_delivery?: string | null;
|
|
491
770
|
created_at: string;
|
|
492
771
|
updated_at: string;
|
|
493
772
|
expires_at: string | null;
|
|
494
773
|
}
|
|
495
774
|
|
|
775
|
+
/** Host (never path, query or credentials) of a stored grant callback URL. */
|
|
776
|
+
function callbackHostOf(url: string | null | undefined): string | null {
|
|
777
|
+
if (!url) return null;
|
|
778
|
+
try {
|
|
779
|
+
return new URL(url).host || null;
|
|
780
|
+
} catch {
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function parseCallbackDelivery(
|
|
786
|
+
raw: string | null | undefined,
|
|
787
|
+
): GrantCallbackDelivery | null {
|
|
788
|
+
if (!raw) return null;
|
|
789
|
+
try {
|
|
790
|
+
const v = JSON.parse(raw) as Partial<GrantCallbackDelivery>;
|
|
791
|
+
if (v.status !== "delivered" && v.status !== "failed") return null;
|
|
792
|
+
return {
|
|
793
|
+
status: v.status,
|
|
794
|
+
attempts: Number(v.attempts ?? 0),
|
|
795
|
+
lastError: typeof v.lastError === "string" ? v.lastError : null,
|
|
796
|
+
at: String(v.at ?? ""),
|
|
797
|
+
};
|
|
798
|
+
} catch {
|
|
799
|
+
return null;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
496
803
|
export const mapProposalRow = (
|
|
497
804
|
row: ProposalRow,
|
|
498
805
|
grantToken?: string,
|
|
@@ -524,6 +831,8 @@ export const mapProposalRow = (
|
|
|
524
831
|
remediationAgentRunId: row.remediation_agent_run_id,
|
|
525
832
|
resultingChangeId: row.resulting_change_id,
|
|
526
833
|
failureReason: row.failure_reason,
|
|
834
|
+
callbackHost: callbackHostOf(row.callback_url),
|
|
835
|
+
callbackDelivery: parseCallbackDelivery(row.callback_delivery),
|
|
527
836
|
createdAt: row.created_at,
|
|
528
837
|
updatedAt: row.updated_at,
|
|
529
838
|
expiresAt: row.expires_at,
|
|
@@ -542,10 +851,12 @@ function evaluateActionPolicyMatrix(
|
|
|
542
851
|
effectiveAutonomy: AgentAutonomyLevel;
|
|
543
852
|
reason: string;
|
|
544
853
|
} {
|
|
854
|
+
// `recentProposalsLastHour` counts prior proposals only, so this proposal
|
|
855
|
+
// would be the 4th within the hour once three already exist (max 3/hr).
|
|
545
856
|
if (recentProposalsLastHour >= 3) {
|
|
546
857
|
return {
|
|
547
858
|
effectiveAutonomy: AgentAutonomyLevel.BlockedByPolicy,
|
|
548
|
-
reason: `Circuit breaker tripped: ${recentProposalsLastHour} remediation proposals already recorded in the last hour for this
|
|
859
|
+
reason: `Circuit breaker tripped: ${recentProposalsLastHour} remediation proposals of this action kind already recorded in the last hour for this finding fingerprint (max 3/hr)`,
|
|
549
860
|
};
|
|
550
861
|
}
|
|
551
862
|
|
|
@@ -630,20 +941,25 @@ export async function proposeRemediationAction(
|
|
|
630
941
|
db: SqlDb,
|
|
631
942
|
projectId: string,
|
|
632
943
|
input: RemediationProposalInput,
|
|
944
|
+
options: {
|
|
945
|
+
logger?: Logger;
|
|
946
|
+
/** Persisted on a pending proposal and used when it is approved later. */
|
|
947
|
+
callback?: { url: string; secret?: string };
|
|
948
|
+
} = {},
|
|
633
949
|
): Promise<RemediationProposal> {
|
|
950
|
+
const logger = options.logger ?? consoleLogger;
|
|
634
951
|
const nowIso = new Date().toISOString();
|
|
635
952
|
const environment = (input.environment || "default").trim().toLowerCase();
|
|
636
953
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
if (existing) {
|
|
954
|
+
const loadByIdempotencyKey = () =>
|
|
955
|
+
db
|
|
956
|
+
.prepare(
|
|
957
|
+
`SELECT * FROM remediation_proposals
|
|
958
|
+
WHERE project_id = ? AND environment = ? AND idempotency_key = ?`,
|
|
959
|
+
)
|
|
960
|
+
.bind(projectId, environment, input.idempotencyKey)
|
|
961
|
+
.first<ProposalRow>();
|
|
962
|
+
const replayExisting = (existing: ProposalRow): RemediationProposal => {
|
|
647
963
|
if (
|
|
648
964
|
existing.action_kind !== input.actionKind ||
|
|
649
965
|
existing.target_node_id !== input.targetNodeId
|
|
@@ -654,6 +970,23 @@ export async function proposeRemediationAction(
|
|
|
654
970
|
);
|
|
655
971
|
}
|
|
656
972
|
return mapProposalRow(existing);
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
// 1. Check idempotency key first
|
|
976
|
+
const existing = await loadByIdempotencyKey();
|
|
977
|
+
if (existing) return replayExisting(existing);
|
|
978
|
+
|
|
979
|
+
const incident = await getIncidentInvestigation(
|
|
980
|
+
db,
|
|
981
|
+
projectId,
|
|
982
|
+
input.incidentId,
|
|
983
|
+
environment,
|
|
984
|
+
);
|
|
985
|
+
if (!incident) {
|
|
986
|
+
throw new RemediationGateError(
|
|
987
|
+
`Incident ${input.incidentId} not found in environment ${environment}`,
|
|
988
|
+
404,
|
|
989
|
+
);
|
|
657
990
|
}
|
|
658
991
|
|
|
659
992
|
const finding = await getInvestigationFinding(
|
|
@@ -669,17 +1002,32 @@ export async function proposeRemediationAction(
|
|
|
669
1002
|
);
|
|
670
1003
|
}
|
|
671
1004
|
|
|
672
|
-
// 2. Circuit breaker
|
|
1005
|
+
// 2. Circuit breaker: proposals of the same action kind for the same finding
|
|
1006
|
+
// fingerprint in the last 60 minutes. Rejected and expired proposals never
|
|
1007
|
+
// executed, so they do not count against the budget.
|
|
673
1008
|
const oneHourAgo = new Date(Date.now() - 3600_000).toISOString();
|
|
674
1009
|
const recentCountRow = await db
|
|
675
1010
|
.prepare(
|
|
676
|
-
`SELECT COUNT(*) AS cnt
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
1011
|
+
`SELECT COUNT(*) AS cnt
|
|
1012
|
+
FROM remediation_proposals p
|
|
1013
|
+
JOIN investigation_findings f
|
|
1014
|
+
ON f.project_id = p.project_id
|
|
1015
|
+
AND f.environment = p.environment
|
|
1016
|
+
AND f.id = p.finding_id
|
|
1017
|
+
WHERE p.project_id = ?
|
|
1018
|
+
AND p.environment = ?
|
|
1019
|
+
AND f.fingerprint = ?
|
|
1020
|
+
AND p.action_kind = ?
|
|
1021
|
+
AND p.status NOT IN ('rejected', 'expired')
|
|
1022
|
+
AND p.created_at >= ?`,
|
|
1023
|
+
)
|
|
1024
|
+
.bind(
|
|
1025
|
+
projectId,
|
|
1026
|
+
environment,
|
|
1027
|
+
finding.fingerprint,
|
|
1028
|
+
input.actionKind,
|
|
1029
|
+
oneHourAgo,
|
|
681
1030
|
)
|
|
682
|
-
.bind(projectId, environment, input.targetNodeId, oneHourAgo)
|
|
683
1031
|
.first<{ cnt: number }>();
|
|
684
1032
|
|
|
685
1033
|
const recentCount = Number(recentCountRow?.cnt ?? 0);
|
|
@@ -701,6 +1049,16 @@ export async function proposeRemediationAction(
|
|
|
701
1049
|
) {
|
|
702
1050
|
effectiveAutonomy = AgentAutonomyLevel.HumanApprovedWrite;
|
|
703
1051
|
policyReason = `Caller explicitly requested human_approved_write (${policy.reason})`;
|
|
1052
|
+
} else if (
|
|
1053
|
+
(requestedAutonomy === AgentAutonomyLevel.ReadOnly ||
|
|
1054
|
+
requestedAutonomy === AgentAutonomyLevel.SuggestedAction) &&
|
|
1055
|
+
(effectiveAutonomy === AgentAutonomyLevel.AutonomousWrite ||
|
|
1056
|
+
effectiveAutonomy === AgentAutonomyLevel.HumanApprovedWrite)
|
|
1057
|
+
) {
|
|
1058
|
+
// A caller that only asked to read or suggest never gets a write grant;
|
|
1059
|
+
// a human can still approve the parked proposal explicitly.
|
|
1060
|
+
effectiveAutonomy = requestedAutonomy;
|
|
1061
|
+
policyReason = `Caller requested ${requestedAutonomy}; parked for human approval (${policy.reason})`;
|
|
704
1062
|
}
|
|
705
1063
|
|
|
706
1064
|
let status: RemediationProposalStatus = "pending_human_approval";
|
|
@@ -717,12 +1075,25 @@ export async function proposeRemediationAction(
|
|
|
717
1075
|
status = "rejected";
|
|
718
1076
|
}
|
|
719
1077
|
|
|
1078
|
+
// Only a proposal that still awaits a human can use a stored callback; an
|
|
1079
|
+
// autonomous grant is delivered inline by the propose request itself.
|
|
1080
|
+
const storedCallback =
|
|
1081
|
+
status === "pending_human_approval" && options.callback?.url
|
|
1082
|
+
? options.callback
|
|
1083
|
+
: null;
|
|
720
1084
|
const id = `rem_${randomHex(10)}`;
|
|
721
|
-
const expiresAt = new Date(
|
|
722
|
-
Date.now() + 30 * 24 * 3600_000,
|
|
723
|
-
).toISOString();
|
|
1085
|
+
const expiresAt = new Date(Date.now() + 30 * 24 * 3600_000).toISOString();
|
|
724
1086
|
|
|
725
|
-
|
|
1087
|
+
if (effectiveAutonomy === AgentAutonomyLevel.BlockedByPolicy) {
|
|
1088
|
+
logger.warn("[autosre] remediation proposal blocked by policy", {
|
|
1089
|
+
projectId,
|
|
1090
|
+
incidentId: input.incidentId,
|
|
1091
|
+
actionKind: input.actionKind,
|
|
1092
|
+
reason: policyReason,
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const insertRes = await db
|
|
726
1097
|
.prepare(
|
|
727
1098
|
`INSERT INTO remediation_proposals (
|
|
728
1099
|
project_id, environment, id, incident_id, finding_id,
|
|
@@ -730,8 +1101,10 @@ export async function proposeRemediationAction(
|
|
|
730
1101
|
expected_current_state_json, desired_target_state_json,
|
|
731
1102
|
requested_autonomy, effective_autonomy, policy_decision_reason,
|
|
732
1103
|
status, grant_token_hash, grant_expires_at,
|
|
1104
|
+
callback_url, callback_secret,
|
|
733
1105
|
created_at, updated_at, expires_at
|
|
734
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1106
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1107
|
+
ON CONFLICT DO NOTHING`,
|
|
735
1108
|
)
|
|
736
1109
|
.bind(
|
|
737
1110
|
projectId,
|
|
@@ -750,12 +1123,25 @@ export async function proposeRemediationAction(
|
|
|
750
1123
|
status,
|
|
751
1124
|
grantTokenHash,
|
|
752
1125
|
grantExpiresAt,
|
|
1126
|
+
storedCallback?.url ?? null,
|
|
1127
|
+
storedCallback?.secret ?? null,
|
|
753
1128
|
nowIso,
|
|
754
1129
|
nowIso,
|
|
755
1130
|
expiresAt,
|
|
756
1131
|
)
|
|
757
1132
|
.run();
|
|
758
1133
|
|
|
1134
|
+
if (insertRes.meta.changes === 0) {
|
|
1135
|
+
// A concurrent request with the same idempotency key won the race
|
|
1136
|
+
// (UNIQUE project_id, environment, idempotency_key): replay its row.
|
|
1137
|
+
const winner = await loadByIdempotencyKey();
|
|
1138
|
+
if (winner) return replayExisting(winner);
|
|
1139
|
+
throw new RemediationGateError(
|
|
1140
|
+
`Remediation proposal ${id} conflicted with an existing row`,
|
|
1141
|
+
409,
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
759
1145
|
const created = await db
|
|
760
1146
|
.prepare(
|
|
761
1147
|
`SELECT * FROM remediation_proposals
|
|
@@ -828,7 +1214,7 @@ export async function listRemediationProposals(
|
|
|
828
1214
|
binds.push(options.findingId);
|
|
829
1215
|
}
|
|
830
1216
|
|
|
831
|
-
const limit = Math.max(1, Math.min(
|
|
1217
|
+
const limit = Math.max(1, Math.min(200, options.limit ?? 50));
|
|
832
1218
|
binds.push(limit);
|
|
833
1219
|
|
|
834
1220
|
const rows = await db
|
|
@@ -844,9 +1230,32 @@ export async function listRemediationProposals(
|
|
|
844
1230
|
return rows.results.map((r) => mapProposalRow(r));
|
|
845
1231
|
}
|
|
846
1232
|
|
|
1233
|
+
/**
|
|
1234
|
+
* Reads the grant callback persisted at propose time. Server-side use only:
|
|
1235
|
+
* the secret is never mapped into API responses. Call before
|
|
1236
|
+
* `approveRemediationProposal`, which clears the stored callback.
|
|
1237
|
+
*/
|
|
1238
|
+
export async function getRemediationCallback(
|
|
1239
|
+
db: SqlDb,
|
|
1240
|
+
projectId: string,
|
|
1241
|
+
proposalId: string,
|
|
1242
|
+
environment?: string,
|
|
1243
|
+
): Promise<{ url: string; secret?: string } | null> {
|
|
1244
|
+
const row = await loadProposalRow(db, projectId, proposalId, environment);
|
|
1245
|
+
if (!row?.callback_url) return null;
|
|
1246
|
+
return {
|
|
1247
|
+
url: row.callback_url,
|
|
1248
|
+
...(row.callback_secret ? { secret: row.callback_secret } : {}),
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
|
|
847
1252
|
/**
|
|
848
1253
|
* Human dashboard session approval for `pending_human_approval` proposals
|
|
849
1254
|
* (RFC 0013 § 6.B & P1 #4). Mints a single-use `grantToken`.
|
|
1255
|
+
*
|
|
1256
|
+
* The status precondition lives in the UPDATE's WHERE clause (D1 and Postgres
|
|
1257
|
+
* have no multi-statement transactions beyond `batch`), so two concurrent
|
|
1258
|
+
* approvals cannot both mint a token: only one sees `meta.changes === 1`.
|
|
850
1259
|
*/
|
|
851
1260
|
export async function approveRemediationProposal(
|
|
852
1261
|
db: SqlDb,
|
|
@@ -857,23 +1266,12 @@ export async function approveRemediationProposal(
|
|
|
857
1266
|
approvedByUserId: string;
|
|
858
1267
|
},
|
|
859
1268
|
): Promise<RemediationProposal> {
|
|
860
|
-
const row =
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
.bind(projectId, options.environment.toLowerCase(), proposalId)
|
|
867
|
-
.first<ProposalRow>()
|
|
868
|
-
: await db
|
|
869
|
-
.prepare(
|
|
870
|
-
`SELECT * FROM remediation_proposals
|
|
871
|
-
WHERE project_id = ? AND id = ?
|
|
872
|
-
LIMIT 1`,
|
|
873
|
-
)
|
|
874
|
-
.bind(projectId, proposalId)
|
|
875
|
-
.first<ProposalRow>();
|
|
876
|
-
|
|
1269
|
+
const row = await loadProposalRow(
|
|
1270
|
+
db,
|
|
1271
|
+
projectId,
|
|
1272
|
+
proposalId,
|
|
1273
|
+
options.environment,
|
|
1274
|
+
);
|
|
877
1275
|
if (!row) {
|
|
878
1276
|
throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
|
|
879
1277
|
}
|
|
@@ -889,7 +1287,7 @@ export async function approveRemediationProposal(
|
|
|
889
1287
|
const grantTokenHash = await sha256Hex(grantToken);
|
|
890
1288
|
const grantExpiresAt = new Date(Date.now() + 5 * 60_000).toISOString();
|
|
891
1289
|
|
|
892
|
-
await db
|
|
1290
|
+
const res = await db
|
|
893
1291
|
.prepare(
|
|
894
1292
|
`UPDATE remediation_proposals
|
|
895
1293
|
SET status = 'grant_issued',
|
|
@@ -897,8 +1295,11 @@ export async function approveRemediationProposal(
|
|
|
897
1295
|
grant_expires_at = ?,
|
|
898
1296
|
approved_by_user_id = ?,
|
|
899
1297
|
approved_at = ?,
|
|
1298
|
+
callback_url = NULL,
|
|
1299
|
+
callback_secret = NULL,
|
|
900
1300
|
updated_at = ?
|
|
901
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1301
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1302
|
+
AND status = 'pending_human_approval'`,
|
|
902
1303
|
)
|
|
903
1304
|
.bind(
|
|
904
1305
|
grantTokenHash,
|
|
@@ -911,6 +1312,12 @@ export async function approveRemediationProposal(
|
|
|
911
1312
|
row.id,
|
|
912
1313
|
)
|
|
913
1314
|
.run();
|
|
1315
|
+
if (res.meta.changes !== 1) {
|
|
1316
|
+
throw new RemediationGateError(
|
|
1317
|
+
`Proposal ${proposalId} was modified concurrently and can no longer be approved`,
|
|
1318
|
+
409,
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
914
1321
|
|
|
915
1322
|
const updated = await db
|
|
916
1323
|
.prepare(
|
|
@@ -933,23 +1340,12 @@ export async function rejectRemediationProposal(
|
|
|
933
1340
|
reason?: string;
|
|
934
1341
|
},
|
|
935
1342
|
): Promise<RemediationProposal> {
|
|
936
|
-
const row =
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
.bind(projectId, options.environment.toLowerCase(), proposalId)
|
|
943
|
-
.first<ProposalRow>()
|
|
944
|
-
: await db
|
|
945
|
-
.prepare(
|
|
946
|
-
`SELECT * FROM remediation_proposals
|
|
947
|
-
WHERE project_id = ? AND id = ?
|
|
948
|
-
LIMIT 1`,
|
|
949
|
-
)
|
|
950
|
-
.bind(projectId, proposalId)
|
|
951
|
-
.first<ProposalRow>();
|
|
952
|
-
|
|
1343
|
+
const row = await loadProposalRow(
|
|
1344
|
+
db,
|
|
1345
|
+
projectId,
|
|
1346
|
+
proposalId,
|
|
1347
|
+
options.environment,
|
|
1348
|
+
);
|
|
953
1349
|
if (!row) {
|
|
954
1350
|
throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
|
|
955
1351
|
}
|
|
@@ -968,17 +1364,26 @@ export async function rejectRemediationProposal(
|
|
|
968
1364
|
? `Rejected by ${options.rejectedByUserId}: ${options.reason}`
|
|
969
1365
|
: `Rejected by ${options.rejectedByUserId}`;
|
|
970
1366
|
|
|
971
|
-
await db
|
|
1367
|
+
const res = await db
|
|
972
1368
|
.prepare(
|
|
973
1369
|
`UPDATE remediation_proposals
|
|
974
1370
|
SET status = 'rejected',
|
|
975
1371
|
grant_token_hash = NULL,
|
|
1372
|
+
callback_url = NULL,
|
|
1373
|
+
callback_secret = NULL,
|
|
976
1374
|
failure_reason = ?,
|
|
977
1375
|
updated_at = ?
|
|
978
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1376
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1377
|
+
AND status IN ('pending_human_approval', 'grant_issued')`,
|
|
979
1378
|
)
|
|
980
1379
|
.bind(reason, nowIso, projectId, row.environment, row.id)
|
|
981
1380
|
.run();
|
|
1381
|
+
if (res.meta.changes !== 1) {
|
|
1382
|
+
throw new RemediationGateError(
|
|
1383
|
+
`Proposal ${proposalId} was modified concurrently and can no longer be rejected`,
|
|
1384
|
+
409,
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
982
1387
|
|
|
983
1388
|
const updated = await db
|
|
984
1389
|
.prepare(
|
|
@@ -994,6 +1399,12 @@ export async function rejectRemediationProposal(
|
|
|
994
1399
|
/**
|
|
995
1400
|
* Single-use grant redemption with optimistic `expectedCurrentState` verification
|
|
996
1401
|
* against `ontology_nodes` (RFC 0013 § 6.B & P1 #4).
|
|
1402
|
+
*
|
|
1403
|
+
* The status, token-hash and expiry preconditions are part of the claiming
|
|
1404
|
+
* UPDATE, and the outcome is read from `meta.changes`: of N concurrent claims
|
|
1405
|
+
* for one token exactly one sees `changes === 1`; the rest get a 409. The
|
|
1406
|
+
* incident transition rides in the same `batch` and is guarded by an EXISTS on
|
|
1407
|
+
* the winning claim, so a losing claim cannot touch the incident.
|
|
997
1408
|
*/
|
|
998
1409
|
export async function claimRemediationGrant(
|
|
999
1410
|
db: SqlDb,
|
|
@@ -1004,25 +1415,17 @@ export async function claimRemediationGrant(
|
|
|
1004
1415
|
grantToken: string;
|
|
1005
1416
|
runnerId?: string;
|
|
1006
1417
|
nowIso?: string;
|
|
1418
|
+
logger?: Logger;
|
|
1007
1419
|
},
|
|
1008
1420
|
): Promise<RemediationProposal> {
|
|
1421
|
+
const logger = options.logger ?? consoleLogger;
|
|
1009
1422
|
const nowIso = options.nowIso ?? new Date().toISOString();
|
|
1010
|
-
const row =
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
.bind(projectId, options.environment.toLowerCase(), proposalId)
|
|
1017
|
-
.first<ProposalRow>()
|
|
1018
|
-
: await db
|
|
1019
|
-
.prepare(
|
|
1020
|
-
`SELECT * FROM remediation_proposals
|
|
1021
|
-
WHERE project_id = ? AND id = ?
|
|
1022
|
-
LIMIT 1`,
|
|
1023
|
-
)
|
|
1024
|
-
.bind(projectId, proposalId)
|
|
1025
|
-
.first<ProposalRow>();
|
|
1423
|
+
const row = await loadProposalRow(
|
|
1424
|
+
db,
|
|
1425
|
+
projectId,
|
|
1426
|
+
proposalId,
|
|
1427
|
+
options.environment,
|
|
1428
|
+
);
|
|
1026
1429
|
|
|
1027
1430
|
if (!row) {
|
|
1028
1431
|
throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
|
|
@@ -1037,8 +1440,9 @@ export async function claimRemediationGrant(
|
|
|
1037
1440
|
await db
|
|
1038
1441
|
.prepare(
|
|
1039
1442
|
`UPDATE remediation_proposals
|
|
1040
|
-
SET status = 'expired', updated_at = ?
|
|
1041
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1443
|
+
SET status = 'expired', grant_token_hash = NULL, updated_at = ?
|
|
1444
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1445
|
+
AND status = 'grant_issued'`,
|
|
1042
1446
|
)
|
|
1043
1447
|
.bind(nowIso, projectId, row.environment, row.id)
|
|
1044
1448
|
.run();
|
|
@@ -1049,7 +1453,10 @@ export async function claimRemediationGrant(
|
|
|
1049
1453
|
}
|
|
1050
1454
|
|
|
1051
1455
|
const incomingHash = await sha256Hex(options.grantToken);
|
|
1052
|
-
if (
|
|
1456
|
+
if (
|
|
1457
|
+
!row.grant_token_hash ||
|
|
1458
|
+
!constantTimeEqual(incomingHash, row.grant_token_hash)
|
|
1459
|
+
) {
|
|
1053
1460
|
throw new RemediationGateError("Invalid grantToken", 403);
|
|
1054
1461
|
}
|
|
1055
1462
|
|
|
@@ -1071,43 +1478,35 @@ export async function claimRemediationGrant(
|
|
|
1071
1478
|
}>();
|
|
1072
1479
|
|
|
1073
1480
|
if (targetNode) {
|
|
1074
|
-
|
|
1481
|
+
const lockFailure =
|
|
1075
1482
|
expected.version &&
|
|
1076
1483
|
targetNode.current_version &&
|
|
1077
1484
|
targetNode.current_version !== expected.version
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
) {
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
);
|
|
1093
|
-
|
|
1094
|
-
if (
|
|
1095
|
-
expected.configDigest &&
|
|
1096
|
-
targetNode.current_config_digest &&
|
|
1097
|
-
targetNode.current_config_digest !== expected.configDigest
|
|
1098
|
-
) {
|
|
1099
|
-
throw new RemediationGateError(
|
|
1100
|
-
`Optimistic config lock failed on ${row.target_node_id}: expected digest "${expected.configDigest}", but current digest is "${targetNode.current_config_digest}"`,
|
|
1101
|
-
409,
|
|
1102
|
-
);
|
|
1485
|
+
? `Optimistic version lock failed on ${row.target_node_id}: expected version "${expected.version}", but current version is "${targetNode.current_version}"`
|
|
1486
|
+
: expected.commitSha &&
|
|
1487
|
+
targetNode.current_commit_sha &&
|
|
1488
|
+
targetNode.current_commit_sha !== expected.commitSha
|
|
1489
|
+
? `Optimistic commit lock failed on ${row.target_node_id}: expected commit "${expected.commitSha}", but current commit is "${targetNode.current_commit_sha}"`
|
|
1490
|
+
: expected.configDigest &&
|
|
1491
|
+
targetNode.current_config_digest &&
|
|
1492
|
+
targetNode.current_config_digest !== expected.configDigest
|
|
1493
|
+
? `Optimistic config lock failed on ${row.target_node_id}: expected digest "${expected.configDigest}", but current digest is "${targetNode.current_config_digest}"`
|
|
1494
|
+
: null;
|
|
1495
|
+
if (lockFailure) {
|
|
1496
|
+
logger.warn("[autosre] remediation claim rejected by state lock", {
|
|
1497
|
+
proposalId,
|
|
1498
|
+
error: lockFailure,
|
|
1499
|
+
});
|
|
1500
|
+
throw new RemediationGateError(lockFailure, 409);
|
|
1103
1501
|
}
|
|
1104
1502
|
}
|
|
1105
1503
|
|
|
1106
1504
|
const claimExpiresAt = new Date(
|
|
1107
1505
|
Date.parse(nowIso) + 5 * 60_000,
|
|
1108
1506
|
).toISOString();
|
|
1507
|
+
const runnerId = options.runnerId ?? "autosre-runner";
|
|
1109
1508
|
|
|
1110
|
-
await db.batch([
|
|
1509
|
+
const results = await db.batch([
|
|
1111
1510
|
db
|
|
1112
1511
|
.prepare(
|
|
1113
1512
|
`UPDATE remediation_proposals
|
|
@@ -1117,25 +1516,53 @@ export async function claimRemediationGrant(
|
|
|
1117
1516
|
claimed_at = ?,
|
|
1118
1517
|
claim_expires_at = ?,
|
|
1119
1518
|
updated_at = ?
|
|
1120
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1519
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1520
|
+
AND status = 'grant_issued'
|
|
1521
|
+
AND grant_token_hash = ?
|
|
1522
|
+
AND (grant_expires_at IS NULL OR grant_expires_at > ?)`,
|
|
1121
1523
|
)
|
|
1122
1524
|
.bind(
|
|
1123
|
-
|
|
1525
|
+
runnerId,
|
|
1124
1526
|
nowIso,
|
|
1125
1527
|
claimExpiresAt,
|
|
1126
1528
|
nowIso,
|
|
1127
1529
|
projectId,
|
|
1128
1530
|
row.environment,
|
|
1129
1531
|
row.id,
|
|
1532
|
+
row.grant_token_hash,
|
|
1533
|
+
nowIso,
|
|
1130
1534
|
),
|
|
1131
1535
|
db
|
|
1132
1536
|
.prepare(
|
|
1133
1537
|
`UPDATE incident_investigations
|
|
1134
1538
|
SET status = 'remediating', updated_at = ?
|
|
1135
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1539
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1540
|
+
AND EXISTS (
|
|
1541
|
+
SELECT 1 FROM remediation_proposals g
|
|
1542
|
+
WHERE g.project_id = ? AND g.environment = ? AND g.id = ?
|
|
1543
|
+
AND g.status = 'claimed_executing'
|
|
1544
|
+
AND g.claimed_by_runner_id = ?
|
|
1545
|
+
AND g.claimed_at = ?
|
|
1546
|
+
)`,
|
|
1136
1547
|
)
|
|
1137
|
-
.bind(
|
|
1548
|
+
.bind(
|
|
1549
|
+
nowIso,
|
|
1550
|
+
projectId,
|
|
1551
|
+
row.environment,
|
|
1552
|
+
row.incident_id,
|
|
1553
|
+
projectId,
|
|
1554
|
+
row.environment,
|
|
1555
|
+
row.id,
|
|
1556
|
+
runnerId,
|
|
1557
|
+
nowIso,
|
|
1558
|
+
),
|
|
1138
1559
|
]);
|
|
1560
|
+
if (results[0]?.meta.changes !== 1) {
|
|
1561
|
+
throw new RemediationGateError(
|
|
1562
|
+
`Grant for proposal ${proposalId} was already claimed or is no longer valid (grants are single-use)`,
|
|
1563
|
+
409,
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1139
1566
|
|
|
1140
1567
|
const updated = await db
|
|
1141
1568
|
.prepare(
|
|
@@ -1151,6 +1578,10 @@ export async function claimRemediationGrant(
|
|
|
1151
1578
|
/**
|
|
1152
1579
|
* Records remediation completion, updates `ontology_nodes` to desired state,
|
|
1153
1580
|
* and arms the +2m/+5m/+15m post-remediation watchdog on the finding.
|
|
1581
|
+
*
|
|
1582
|
+
* Only the runner that holds the claim may complete, and only while the claim
|
|
1583
|
+
* window is open (`now <= claim_expires_at`); otherwise 409. All follow-up
|
|
1584
|
+
* writes are guarded on the completion UPDATE having applied.
|
|
1154
1585
|
*/
|
|
1155
1586
|
export async function completeRemediationProposal(
|
|
1156
1587
|
db: SqlDb,
|
|
@@ -1158,6 +1589,7 @@ export async function completeRemediationProposal(
|
|
|
1158
1589
|
proposalId: string,
|
|
1159
1590
|
options: {
|
|
1160
1591
|
environment?: string;
|
|
1592
|
+
runnerId?: string;
|
|
1161
1593
|
resultingChangeId?: string | null;
|
|
1162
1594
|
remediationAgentRunId?: string | null;
|
|
1163
1595
|
baselineRequestRatePerMin?: number | null;
|
|
@@ -1165,22 +1597,12 @@ export async function completeRemediationProposal(
|
|
|
1165
1597
|
},
|
|
1166
1598
|
): Promise<RemediationProposal> {
|
|
1167
1599
|
const nowIso = options.nowIso ?? new Date().toISOString();
|
|
1168
|
-
const row =
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
.bind(projectId, options.environment.toLowerCase(), proposalId)
|
|
1175
|
-
.first<ProposalRow>()
|
|
1176
|
-
: await db
|
|
1177
|
-
.prepare(
|
|
1178
|
-
`SELECT * FROM remediation_proposals
|
|
1179
|
-
WHERE project_id = ? AND id = ?
|
|
1180
|
-
LIMIT 1`,
|
|
1181
|
-
)
|
|
1182
|
-
.bind(projectId, proposalId)
|
|
1183
|
-
.first<ProposalRow>();
|
|
1600
|
+
const row = await loadProposalRow(
|
|
1601
|
+
db,
|
|
1602
|
+
projectId,
|
|
1603
|
+
proposalId,
|
|
1604
|
+
options.environment,
|
|
1605
|
+
);
|
|
1184
1606
|
|
|
1185
1607
|
if (!row) {
|
|
1186
1608
|
throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
|
|
@@ -1191,6 +1613,18 @@ export async function completeRemediationProposal(
|
|
|
1191
1613
|
409,
|
|
1192
1614
|
);
|
|
1193
1615
|
}
|
|
1616
|
+
if (!options.runnerId || options.runnerId !== row.claimed_by_runner_id) {
|
|
1617
|
+
throw new RemediationGateError(
|
|
1618
|
+
`Proposal ${proposalId} is claimed by a different runner; only the claiming runner may complete it`,
|
|
1619
|
+
409,
|
|
1620
|
+
);
|
|
1621
|
+
}
|
|
1622
|
+
if (row.claim_expires_at && row.claim_expires_at < nowIso) {
|
|
1623
|
+
throw new RemediationGateError(
|
|
1624
|
+
`Claim on proposal ${proposalId} expired at ${row.claim_expires_at}`,
|
|
1625
|
+
409,
|
|
1626
|
+
);
|
|
1627
|
+
}
|
|
1194
1628
|
|
|
1195
1629
|
const desired = parseJsonRecord(
|
|
1196
1630
|
row.desired_target_state_json,
|
|
@@ -1233,7 +1667,15 @@ export async function completeRemediationProposal(
|
|
|
1233
1667
|
});
|
|
1234
1668
|
}
|
|
1235
1669
|
|
|
1236
|
-
|
|
1670
|
+
// Follow-up writes only apply when the completion UPDATE below won.
|
|
1671
|
+
const completedGuard = `EXISTS (
|
|
1672
|
+
SELECT 1 FROM remediation_proposals g
|
|
1673
|
+
WHERE g.project_id = ? AND g.environment = ? AND g.id = ?
|
|
1674
|
+
AND g.status = 'completed' AND g.updated_at = ?
|
|
1675
|
+
)`;
|
|
1676
|
+
const guardBinds = [projectId, row.environment, row.id, nowIso];
|
|
1677
|
+
|
|
1678
|
+
const results = await db.batch([
|
|
1237
1679
|
db
|
|
1238
1680
|
.prepare(
|
|
1239
1681
|
`UPDATE remediation_proposals
|
|
@@ -1241,7 +1683,10 @@ export async function completeRemediationProposal(
|
|
|
1241
1683
|
resulting_change_id = COALESCE(?, resulting_change_id),
|
|
1242
1684
|
remediation_agent_run_id = COALESCE(?, remediation_agent_run_id),
|
|
1243
1685
|
updated_at = ?
|
|
1244
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1686
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1687
|
+
AND status = 'claimed_executing'
|
|
1688
|
+
AND claimed_by_runner_id = ?
|
|
1689
|
+
AND (claim_expires_at IS NULL OR claim_expires_at >= ?)`,
|
|
1245
1690
|
)
|
|
1246
1691
|
.bind(
|
|
1247
1692
|
options.resultingChangeId ?? null,
|
|
@@ -1250,6 +1695,8 @@ export async function completeRemediationProposal(
|
|
|
1250
1695
|
projectId,
|
|
1251
1696
|
row.environment,
|
|
1252
1697
|
row.id,
|
|
1698
|
+
options.runnerId,
|
|
1699
|
+
nowIso,
|
|
1253
1700
|
),
|
|
1254
1701
|
db
|
|
1255
1702
|
.prepare(
|
|
@@ -1258,7 +1705,8 @@ export async function completeRemediationProposal(
|
|
|
1258
1705
|
current_commit_sha = COALESCE(?, current_commit_sha),
|
|
1259
1706
|
current_config_digest = COALESCE(?, current_config_digest),
|
|
1260
1707
|
last_seen_at = ?
|
|
1261
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1708
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1709
|
+
AND ${completedGuard}`,
|
|
1262
1710
|
)
|
|
1263
1711
|
.bind(
|
|
1264
1712
|
desired.version ?? null,
|
|
@@ -1268,17 +1716,20 @@ export async function completeRemediationProposal(
|
|
|
1268
1716
|
projectId,
|
|
1269
1717
|
row.environment,
|
|
1270
1718
|
row.target_node_id,
|
|
1719
|
+
...guardBinds,
|
|
1271
1720
|
),
|
|
1272
1721
|
db
|
|
1273
1722
|
.prepare(
|
|
1274
1723
|
`UPDATE investigation_findings
|
|
1275
1724
|
SET recovery_status = 'monitoring',
|
|
1276
1725
|
watchdog_step = 1,
|
|
1726
|
+
consecutive_ok_checks = 0,
|
|
1277
1727
|
baseline_request_rate_per_min = COALESCE(?, baseline_request_rate_per_min),
|
|
1278
1728
|
next_watchdog_check_at = ?,
|
|
1279
1729
|
causal_chain_json = ?,
|
|
1280
1730
|
updated_at = ?
|
|
1281
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1731
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1732
|
+
AND ${completedGuard}`,
|
|
1282
1733
|
)
|
|
1283
1734
|
.bind(
|
|
1284
1735
|
options.baselineRequestRatePerMin ?? null,
|
|
@@ -1288,15 +1739,23 @@ export async function completeRemediationProposal(
|
|
|
1288
1739
|
projectId,
|
|
1289
1740
|
row.environment,
|
|
1290
1741
|
row.finding_id,
|
|
1742
|
+
...guardBinds,
|
|
1291
1743
|
),
|
|
1292
1744
|
db
|
|
1293
1745
|
.prepare(
|
|
1294
1746
|
`UPDATE incident_investigations
|
|
1295
1747
|
SET status = 'verifying', updated_at = ?
|
|
1296
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1748
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1749
|
+
AND ${completedGuard}`,
|
|
1297
1750
|
)
|
|
1298
|
-
.bind(nowIso, projectId, row.environment, row.incident_id),
|
|
1751
|
+
.bind(nowIso, projectId, row.environment, row.incident_id, ...guardBinds),
|
|
1299
1752
|
]);
|
|
1753
|
+
if (results[0]?.meta.changes !== 1) {
|
|
1754
|
+
throw new RemediationGateError(
|
|
1755
|
+
`Proposal ${proposalId} changed state concurrently and can no longer be completed`,
|
|
1756
|
+
409,
|
|
1757
|
+
);
|
|
1758
|
+
}
|
|
1300
1759
|
|
|
1301
1760
|
const updated = await db
|
|
1302
1761
|
.prepare(
|
|
@@ -1315,50 +1774,82 @@ export async function failRemediationProposal(
|
|
|
1315
1774
|
proposalId: string,
|
|
1316
1775
|
options: {
|
|
1317
1776
|
environment?: string;
|
|
1777
|
+
runnerId?: string;
|
|
1318
1778
|
failureReason: string;
|
|
1319
1779
|
nowIso?: string;
|
|
1320
1780
|
},
|
|
1321
1781
|
): Promise<RemediationProposal> {
|
|
1322
1782
|
const nowIso = options.nowIso ?? new Date().toISOString();
|
|
1323
|
-
const row =
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
.bind(projectId, options.environment.toLowerCase(), proposalId)
|
|
1330
|
-
.first<ProposalRow>()
|
|
1331
|
-
: await db
|
|
1332
|
-
.prepare(
|
|
1333
|
-
`SELECT * FROM remediation_proposals
|
|
1334
|
-
WHERE project_id = ? AND id = ?
|
|
1335
|
-
LIMIT 1`,
|
|
1336
|
-
)
|
|
1337
|
-
.bind(projectId, proposalId)
|
|
1338
|
-
.first<ProposalRow>();
|
|
1783
|
+
const row = await loadProposalRow(
|
|
1784
|
+
db,
|
|
1785
|
+
projectId,
|
|
1786
|
+
proposalId,
|
|
1787
|
+
options.environment,
|
|
1788
|
+
);
|
|
1339
1789
|
|
|
1340
1790
|
if (!row) {
|
|
1341
1791
|
throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
|
|
1342
1792
|
}
|
|
1793
|
+
if (row.status !== "claimed_executing") {
|
|
1794
|
+
throw new RemediationGateError(
|
|
1795
|
+
`Proposal ${proposalId} cannot be failed from status=${row.status} (must be claimed_executing)`,
|
|
1796
|
+
409,
|
|
1797
|
+
);
|
|
1798
|
+
}
|
|
1799
|
+
if (!options.runnerId || options.runnerId !== row.claimed_by_runner_id) {
|
|
1800
|
+
throw new RemediationGateError(
|
|
1801
|
+
`Proposal ${proposalId} is claimed by a different runner; only the claiming runner may fail it`,
|
|
1802
|
+
409,
|
|
1803
|
+
);
|
|
1804
|
+
}
|
|
1343
1805
|
|
|
1344
|
-
await db.batch([
|
|
1806
|
+
const results = await db.batch([
|
|
1345
1807
|
db
|
|
1346
1808
|
.prepare(
|
|
1347
1809
|
`UPDATE remediation_proposals
|
|
1348
1810
|
SET status = 'failed_execution',
|
|
1349
1811
|
failure_reason = ?,
|
|
1350
1812
|
updated_at = ?
|
|
1351
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1813
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1814
|
+
AND status = 'claimed_executing'
|
|
1815
|
+
AND claimed_by_runner_id = ?`,
|
|
1352
1816
|
)
|
|
1353
|
-
.bind(
|
|
1817
|
+
.bind(
|
|
1818
|
+
options.failureReason,
|
|
1819
|
+
nowIso,
|
|
1820
|
+
projectId,
|
|
1821
|
+
row.environment,
|
|
1822
|
+
row.id,
|
|
1823
|
+
options.runnerId,
|
|
1824
|
+
),
|
|
1354
1825
|
db
|
|
1355
1826
|
.prepare(
|
|
1356
1827
|
`UPDATE incident_investigations
|
|
1357
1828
|
SET status = 'escalated', updated_at = ?
|
|
1358
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1829
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1830
|
+
AND EXISTS (
|
|
1831
|
+
SELECT 1 FROM remediation_proposals g
|
|
1832
|
+
WHERE g.project_id = ? AND g.environment = ? AND g.id = ?
|
|
1833
|
+
AND g.status = 'failed_execution' AND g.updated_at = ?
|
|
1834
|
+
)`,
|
|
1359
1835
|
)
|
|
1360
|
-
.bind(
|
|
1836
|
+
.bind(
|
|
1837
|
+
nowIso,
|
|
1838
|
+
projectId,
|
|
1839
|
+
row.environment,
|
|
1840
|
+
row.incident_id,
|
|
1841
|
+
projectId,
|
|
1842
|
+
row.environment,
|
|
1843
|
+
row.id,
|
|
1844
|
+
nowIso,
|
|
1845
|
+
),
|
|
1361
1846
|
]);
|
|
1847
|
+
if (results[0]?.meta.changes !== 1) {
|
|
1848
|
+
throw new RemediationGateError(
|
|
1849
|
+
`Proposal ${proposalId} changed state concurrently and can no longer be failed`,
|
|
1850
|
+
409,
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1362
1853
|
|
|
1363
1854
|
const updated = await db
|
|
1364
1855
|
.prepare(
|
|
@@ -1378,8 +1869,19 @@ export interface WatchdogEvaluationSummary {
|
|
|
1378
1869
|
stillDegraded: number;
|
|
1379
1870
|
timedOutClaims: number;
|
|
1380
1871
|
expiredGrants: number;
|
|
1872
|
+
/** Findings whose evaluation threw; logged and retried on the next tick. */
|
|
1873
|
+
failed: number;
|
|
1381
1874
|
}
|
|
1382
1875
|
|
|
1876
|
+
/** Watchdog checkpoints, in minutes after the remediation completed (RFC 0013 § 5.B). */
|
|
1877
|
+
const WATCHDOG_STEP_OFFSET_MINUTES: Record<number, number> = {
|
|
1878
|
+
1: 2,
|
|
1879
|
+
2: 5,
|
|
1880
|
+
3: 15,
|
|
1881
|
+
};
|
|
1882
|
+
/** Consecutive sufficient-traffic healthy checkpoints required for `recovered`. */
|
|
1883
|
+
const REQUIRED_CONSECUTIVE_OK_CHECKS = 2;
|
|
1884
|
+
|
|
1383
1885
|
/**
|
|
1384
1886
|
* Post-Remediation Watchdog & Lease/Claim Timeout Evaluator (RFC 0013 § 5.B, § 6.B & P1 #3).
|
|
1385
1887
|
*
|
|
@@ -1387,16 +1889,28 @@ export interface WatchdogEvaluationSummary {
|
|
|
1387
1889
|
* - `recovery_status`: whether the service recovered under comparable traffic (`N >= minComparableRequests`)
|
|
1388
1890
|
* - `causal_verification_status`: `"operationally_corroborated"` (never promoted to `"counterfactual_verified"`
|
|
1389
1891
|
* by telemetry recovery alone!).
|
|
1892
|
+
*
|
|
1893
|
+
* Checkpoints are scheduled at +2m/+5m/+15m measured from the remediation's
|
|
1894
|
+
* completion time (not from whenever a tick happened to run), and a finding
|
|
1895
|
+
* only becomes `recovered` after two consecutive healthy checkpoints that each
|
|
1896
|
+
* had sufficient traffic. Pass `projectId` to scope the sweep to one tenant
|
|
1897
|
+
* (HTTP path); the cron path omits it and sweeps every project.
|
|
1390
1898
|
*/
|
|
1391
1899
|
export async function evaluateRemediationWatchdogs(
|
|
1392
1900
|
db: SqlDb,
|
|
1393
1901
|
options: {
|
|
1394
1902
|
nowIso?: string;
|
|
1395
1903
|
minComparableRequests?: number;
|
|
1904
|
+
projectId?: string;
|
|
1905
|
+
logger?: Logger;
|
|
1396
1906
|
} = {},
|
|
1397
1907
|
): Promise<WatchdogEvaluationSummary> {
|
|
1908
|
+
const logger = options.logger ?? consoleLogger;
|
|
1398
1909
|
const nowIso = options.nowIso ?? new Date().toISOString();
|
|
1399
1910
|
const minComparableRequests = options.minComparableRequests ?? 50;
|
|
1911
|
+
const projectId = options.projectId;
|
|
1912
|
+
const projectClause = projectId ? " AND project_id = ?" : "";
|
|
1913
|
+
const projectBinds = projectId ? [projectId] : [];
|
|
1400
1914
|
|
|
1401
1915
|
// 1. Expire unclaimed grants past grant_expires_at
|
|
1402
1916
|
const expiredGrantsRes = await db
|
|
@@ -1405,9 +1919,9 @@ export async function evaluateRemediationWatchdogs(
|
|
|
1405
1919
|
SET status = 'expired', grant_token_hash = NULL, updated_at = ?
|
|
1406
1920
|
WHERE status = 'grant_issued'
|
|
1407
1921
|
AND grant_expires_at IS NOT NULL
|
|
1408
|
-
AND grant_expires_at <=
|
|
1922
|
+
AND grant_expires_at <= ?${projectClause}`,
|
|
1409
1923
|
)
|
|
1410
|
-
.bind(nowIso, nowIso)
|
|
1924
|
+
.bind(nowIso, nowIso, ...projectBinds)
|
|
1411
1925
|
.run();
|
|
1412
1926
|
|
|
1413
1927
|
// 2. Time out claimed_executing proposals past claim_expires_at and escalate their incidents
|
|
@@ -1417,9 +1931,9 @@ export async function evaluateRemediationWatchdogs(
|
|
|
1417
1931
|
FROM remediation_proposals
|
|
1418
1932
|
WHERE status = 'claimed_executing'
|
|
1419
1933
|
AND claim_expires_at IS NOT NULL
|
|
1420
|
-
AND claim_expires_at <=
|
|
1934
|
+
AND claim_expires_at <= ?${projectClause}`,
|
|
1421
1935
|
)
|
|
1422
|
-
.bind(nowIso)
|
|
1936
|
+
.bind(nowIso, ...projectBinds)
|
|
1423
1937
|
.all<{
|
|
1424
1938
|
project_id: string;
|
|
1425
1939
|
environment: string;
|
|
@@ -1427,25 +1941,42 @@ export async function evaluateRemediationWatchdogs(
|
|
|
1427
1941
|
incident_id: string;
|
|
1428
1942
|
}>();
|
|
1429
1943
|
|
|
1944
|
+
let timedOutClaims = 0;
|
|
1430
1945
|
for (const t of timedOutRows.results) {
|
|
1431
|
-
await db.batch([
|
|
1946
|
+
const res = await db.batch([
|
|
1432
1947
|
db
|
|
1433
1948
|
.prepare(
|
|
1434
1949
|
`UPDATE remediation_proposals
|
|
1435
1950
|
SET status = 'execution_timed_out',
|
|
1436
1951
|
failure_reason = 'Runner did not complete or fail within 5m claim window',
|
|
1437
1952
|
updated_at = ?
|
|
1438
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1953
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1954
|
+
AND status = 'claimed_executing'`,
|
|
1439
1955
|
)
|
|
1440
1956
|
.bind(nowIso, t.project_id, t.environment, t.id),
|
|
1441
1957
|
db
|
|
1442
1958
|
.prepare(
|
|
1443
1959
|
`UPDATE incident_investigations
|
|
1444
1960
|
SET status = 'escalated', updated_at = ?
|
|
1445
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1961
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1962
|
+
AND EXISTS (
|
|
1963
|
+
SELECT 1 FROM remediation_proposals g
|
|
1964
|
+
WHERE g.project_id = ? AND g.environment = ? AND g.id = ?
|
|
1965
|
+
AND g.status = 'execution_timed_out' AND g.updated_at = ?
|
|
1966
|
+
)`,
|
|
1446
1967
|
)
|
|
1447
|
-
.bind(
|
|
1968
|
+
.bind(
|
|
1969
|
+
nowIso,
|
|
1970
|
+
t.project_id,
|
|
1971
|
+
t.environment,
|
|
1972
|
+
t.incident_id,
|
|
1973
|
+
t.project_id,
|
|
1974
|
+
t.environment,
|
|
1975
|
+
t.id,
|
|
1976
|
+
nowIso,
|
|
1977
|
+
),
|
|
1448
1978
|
]);
|
|
1979
|
+
if (res[0]?.meta.changes === 1) timedOutClaims += 1;
|
|
1449
1980
|
}
|
|
1450
1981
|
|
|
1451
1982
|
// 3. Evaluate findings in `monitoring` where `next_watchdog_check_at <= nowIso`
|
|
@@ -1454,15 +1985,15 @@ export async function evaluateRemediationWatchdogs(
|
|
|
1454
1985
|
`SELECT
|
|
1455
1986
|
project_id, environment, id, incident_id,
|
|
1456
1987
|
fault_locus_entity_id, root_cause_entity_id,
|
|
1457
|
-
watchdog_step, baseline_request_rate_per_min,
|
|
1988
|
+
watchdog_step, consecutive_ok_checks, baseline_request_rate_per_min,
|
|
1458
1989
|
causal_verification_status, updated_at
|
|
1459
1990
|
FROM investigation_findings
|
|
1460
1991
|
WHERE recovery_status = 'monitoring'
|
|
1461
1992
|
AND next_watchdog_check_at IS NOT NULL
|
|
1462
|
-
AND next_watchdog_check_at <=
|
|
1993
|
+
AND next_watchdog_check_at <= ?${projectClause}
|
|
1463
1994
|
LIMIT 50`,
|
|
1464
1995
|
)
|
|
1465
|
-
.bind(nowIso)
|
|
1996
|
+
.bind(nowIso, ...projectBinds)
|
|
1466
1997
|
.all<{
|
|
1467
1998
|
project_id: string;
|
|
1468
1999
|
environment: string;
|
|
@@ -1471,6 +2002,7 @@ export async function evaluateRemediationWatchdogs(
|
|
|
1471
2002
|
fault_locus_entity_id: string;
|
|
1472
2003
|
root_cause_entity_id: string | null;
|
|
1473
2004
|
watchdog_step: number;
|
|
2005
|
+
consecutive_ok_checks: number | null;
|
|
1474
2006
|
baseline_request_rate_per_min: number | null;
|
|
1475
2007
|
causal_verification_status: string;
|
|
1476
2008
|
updated_at: string;
|
|
@@ -1479,141 +2011,193 @@ export async function evaluateRemediationWatchdogs(
|
|
|
1479
2011
|
let recovered = 0;
|
|
1480
2012
|
let inconclusiveLowTraffic = 0;
|
|
1481
2013
|
let stillDegraded = 0;
|
|
2014
|
+
let failed = 0;
|
|
1482
2015
|
|
|
1483
2016
|
for (const f of dueFindings.results) {
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
.prepare(
|
|
1523
|
-
`SELECT
|
|
1524
|
-
COUNT(*) AS total_requests,
|
|
1525
|
-
SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS error_requests
|
|
1526
|
-
FROM telemetry_spans
|
|
1527
|
-
WHERE project_id = ?
|
|
1528
|
-
AND environment = ?
|
|
1529
|
-
AND service_name = ?
|
|
1530
|
-
AND (parent_span_id IS NULL OR span_kind = 1)
|
|
1531
|
-
AND (start_time >= ? OR received_at >= ?)`,
|
|
1532
|
-
)
|
|
1533
|
-
.bind(f.project_id, f.environment, serviceName, sinceTs, sinceTs)
|
|
1534
|
-
.first<{ total_requests: number; error_requests: number }>()
|
|
1535
|
-
: await db
|
|
1536
|
-
.prepare(
|
|
1537
|
-
`SELECT
|
|
1538
|
-
COUNT(*) AS total_requests,
|
|
1539
|
-
SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS error_requests
|
|
1540
|
-
FROM telemetry_spans
|
|
1541
|
-
WHERE project_id = ?
|
|
1542
|
-
AND environment = ?
|
|
1543
|
-
AND (parent_span_id IS NULL OR span_kind = 1)
|
|
1544
|
-
AND (start_time >= ? OR received_at >= ?)`,
|
|
1545
|
-
)
|
|
1546
|
-
.bind(f.project_id, f.environment, sinceTs, sinceTs)
|
|
1547
|
-
.first<{ total_requests: number; error_requests: number }>();
|
|
1548
|
-
|
|
1549
|
-
const totalReq = Number(stats?.total_requests ?? 0);
|
|
1550
|
-
const errorReq = Number(stats?.error_requests ?? 0);
|
|
1551
|
-
const errorRate = totalReq > 0 ? errorReq / totalReq : 0;
|
|
1552
|
-
|
|
1553
|
-
const requiredTraffic = Math.max(
|
|
1554
|
-
minComparableRequests,
|
|
1555
|
-
f.baseline_request_rate_per_min
|
|
1556
|
-
? Math.floor(0.5 * f.baseline_request_rate_per_min * 5)
|
|
1557
|
-
: minComparableRequests,
|
|
1558
|
-
);
|
|
1559
|
-
|
|
1560
|
-
if (totalReq < requiredTraffic) {
|
|
1561
|
-
if (f.watchdog_step < 3) {
|
|
1562
|
-
const nextStep = f.watchdog_step + 1;
|
|
1563
|
-
const offsetMins = nextStep === 2 ? 5 : 15;
|
|
1564
|
-
const nextCheck = new Date(
|
|
1565
|
-
Date.parse(nowIso) + offsetMins * 60_000,
|
|
2017
|
+
try {
|
|
2018
|
+
// Find the completed remediation timestamp for this finding
|
|
2019
|
+
const completedProposal = await db
|
|
2020
|
+
.prepare(
|
|
2021
|
+
`SELECT
|
|
2022
|
+
p.updated_at,
|
|
2023
|
+
p.target_node_id,
|
|
2024
|
+
p.resulting_change_id,
|
|
2025
|
+
c.occurred_at AS change_occurred_at
|
|
2026
|
+
FROM remediation_proposals p
|
|
2027
|
+
LEFT JOIN change_events c
|
|
2028
|
+
ON c.project_id = p.project_id
|
|
2029
|
+
AND c.environment = p.environment
|
|
2030
|
+
AND c.id = p.resulting_change_id
|
|
2031
|
+
WHERE p.project_id = ? AND p.environment = ? AND p.finding_id = ? AND p.status = 'completed'
|
|
2032
|
+
ORDER BY p.updated_at DESC
|
|
2033
|
+
LIMIT 1`,
|
|
2034
|
+
)
|
|
2035
|
+
.bind(f.project_id, f.environment, f.id)
|
|
2036
|
+
.first<{
|
|
2037
|
+
updated_at: string;
|
|
2038
|
+
target_node_id: string;
|
|
2039
|
+
resulting_change_id: string | null;
|
|
2040
|
+
change_occurred_at: string | null;
|
|
2041
|
+
}>();
|
|
2042
|
+
|
|
2043
|
+
// Traffic is measured since the remediation change took effect; the
|
|
2044
|
+
// checkpoint schedule is anchored on when the remediation completed.
|
|
2045
|
+
const sinceTs =
|
|
2046
|
+
completedProposal?.change_occurred_at ??
|
|
2047
|
+
completedProposal?.updated_at ??
|
|
2048
|
+
f.updated_at;
|
|
2049
|
+
const completedAtMs = Date.parse(
|
|
2050
|
+
completedProposal?.updated_at ?? f.updated_at,
|
|
2051
|
+
);
|
|
2052
|
+
const nextCheckFor = (step: number): string =>
|
|
2053
|
+
new Date(
|
|
2054
|
+
completedAtMs + (WATCHDOG_STEP_OFFSET_MINUTES[step] ?? 15) * 60_000,
|
|
1566
2055
|
).toISOString();
|
|
2056
|
+
const targetNodeId =
|
|
2057
|
+
completedProposal?.target_node_id ?? f.fault_locus_entity_id;
|
|
2058
|
+
|
|
2059
|
+
// Extract service name from canonical service:{env}:{ns}:{serviceName} if applicable
|
|
2060
|
+
const parts = targetNodeId.split(":");
|
|
2061
|
+
const serviceName =
|
|
2062
|
+
parts[0] === "service" && parts.length >= 4
|
|
2063
|
+
? parts.slice(3).join(":")
|
|
2064
|
+
: null;
|
|
2065
|
+
|
|
2066
|
+
const stats = serviceName
|
|
2067
|
+
? await db
|
|
2068
|
+
.prepare(
|
|
2069
|
+
`SELECT
|
|
2070
|
+
COUNT(*) AS total_requests,
|
|
2071
|
+
SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS error_requests
|
|
2072
|
+
FROM telemetry_spans
|
|
2073
|
+
WHERE project_id = ?
|
|
2074
|
+
AND environment = ?
|
|
2075
|
+
AND service_name = ?
|
|
2076
|
+
AND (parent_span_id IS NULL OR span_kind = 1)
|
|
2077
|
+
AND (start_time >= ? OR received_at >= ?)`,
|
|
2078
|
+
)
|
|
2079
|
+
.bind(f.project_id, f.environment, serviceName, sinceTs, sinceTs)
|
|
2080
|
+
.first<{ total_requests: number; error_requests: number }>()
|
|
2081
|
+
: await db
|
|
2082
|
+
.prepare(
|
|
2083
|
+
`SELECT
|
|
2084
|
+
COUNT(*) AS total_requests,
|
|
2085
|
+
SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS error_requests
|
|
2086
|
+
FROM telemetry_spans
|
|
2087
|
+
WHERE project_id = ?
|
|
2088
|
+
AND environment = ?
|
|
2089
|
+
AND (parent_span_id IS NULL OR span_kind = 1)
|
|
2090
|
+
AND (start_time >= ? OR received_at >= ?)`,
|
|
2091
|
+
)
|
|
2092
|
+
.bind(f.project_id, f.environment, sinceTs, sinceTs)
|
|
2093
|
+
.first<{ total_requests: number; error_requests: number }>();
|
|
2094
|
+
|
|
2095
|
+
const totalReq = Number(stats?.total_requests ?? 0);
|
|
2096
|
+
const errorReq = Number(stats?.error_requests ?? 0);
|
|
2097
|
+
const errorRate = totalReq > 0 ? errorReq / totalReq : 0;
|
|
2098
|
+
|
|
2099
|
+
const requiredTraffic = Math.max(
|
|
2100
|
+
minComparableRequests,
|
|
2101
|
+
f.baseline_request_rate_per_min
|
|
2102
|
+
? Math.floor(0.5 * f.baseline_request_rate_per_min * 5)
|
|
2103
|
+
: minComparableRequests,
|
|
2104
|
+
);
|
|
2105
|
+
|
|
2106
|
+
if (totalReq < requiredTraffic) {
|
|
2107
|
+
// Insufficient traffic breaks the consecutive-ok streak.
|
|
2108
|
+
if (f.watchdog_step < 3) {
|
|
2109
|
+
const nextStep = f.watchdog_step + 1;
|
|
2110
|
+
await db
|
|
2111
|
+
.prepare(
|
|
2112
|
+
`UPDATE investigation_findings
|
|
2113
|
+
SET watchdog_step = ?, consecutive_ok_checks = 0,
|
|
2114
|
+
next_watchdog_check_at = ?, updated_at = ?
|
|
2115
|
+
WHERE project_id = ? AND environment = ? AND id = ?`,
|
|
2116
|
+
)
|
|
2117
|
+
.bind(
|
|
2118
|
+
nextStep,
|
|
2119
|
+
nextCheckFor(nextStep),
|
|
2120
|
+
nowIso,
|
|
2121
|
+
f.project_id,
|
|
2122
|
+
f.environment,
|
|
2123
|
+
f.id,
|
|
2124
|
+
)
|
|
2125
|
+
.run();
|
|
2126
|
+
} else {
|
|
2127
|
+
// Step 3 (+15m) with insufficient comparable traffic -> inconclusive_low_traffic
|
|
2128
|
+
inconclusiveLowTraffic += 1;
|
|
2129
|
+
await db
|
|
2130
|
+
.prepare(
|
|
2131
|
+
`UPDATE investigation_findings
|
|
2132
|
+
SET recovery_status = 'inconclusive_low_traffic',
|
|
2133
|
+
consecutive_ok_checks = 0,
|
|
2134
|
+
next_watchdog_check_at = NULL,
|
|
2135
|
+
updated_at = ?
|
|
2136
|
+
WHERE project_id = ? AND environment = ? AND id = ?`,
|
|
2137
|
+
)
|
|
2138
|
+
.bind(nowIso, f.project_id, f.environment, f.id)
|
|
2139
|
+
.run();
|
|
2140
|
+
}
|
|
2141
|
+
continue;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
if (errorRate >= 0.05) {
|
|
2145
|
+
stillDegraded += 1;
|
|
1567
2146
|
await db
|
|
1568
2147
|
.prepare(
|
|
1569
2148
|
`UPDATE investigation_findings
|
|
1570
|
-
SET
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
.bind(nextStep, nextCheck, nowIso, f.project_id, f.environment, f.id)
|
|
1574
|
-
.run();
|
|
1575
|
-
} else {
|
|
1576
|
-
// Step 3 (+15m) with insufficient comparable traffic -> inconclusive_low_traffic
|
|
1577
|
-
inconclusiveLowTraffic += 1;
|
|
1578
|
-
await db
|
|
1579
|
-
.prepare(
|
|
1580
|
-
`UPDATE investigation_findings
|
|
1581
|
-
SET recovery_status = 'inconclusive_low_traffic',
|
|
2149
|
+
SET recovery_status = 'still_degraded',
|
|
2150
|
+
causal_verification_status = 'refuted',
|
|
2151
|
+
consecutive_ok_checks = 0,
|
|
1582
2152
|
next_watchdog_check_at = NULL,
|
|
1583
2153
|
updated_at = ?
|
|
1584
2154
|
WHERE project_id = ? AND environment = ? AND id = ?`,
|
|
1585
2155
|
)
|
|
1586
2156
|
.bind(nowIso, f.project_id, f.environment, f.id)
|
|
1587
2157
|
.run();
|
|
2158
|
+
if (f.incident_id) {
|
|
2159
|
+
await db
|
|
2160
|
+
.prepare(
|
|
2161
|
+
`UPDATE incident_investigations
|
|
2162
|
+
SET status = 'escalated', updated_at = ?
|
|
2163
|
+
WHERE project_id = ? AND environment = ? AND id = ?`,
|
|
2164
|
+
)
|
|
2165
|
+
.bind(nowIso, f.project_id, f.environment, f.incident_id)
|
|
2166
|
+
.run();
|
|
2167
|
+
}
|
|
2168
|
+
continue;
|
|
1588
2169
|
}
|
|
1589
|
-
continue;
|
|
1590
|
-
}
|
|
1591
2170
|
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
.
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
)
|
|
1603
|
-
.bind(nowIso, f.project_id, f.environment, f.id)
|
|
1604
|
-
.run();
|
|
1605
|
-
if (f.incident_id) {
|
|
2171
|
+
// Traffic is sufficient AND error rate is healthy (<5%) at this checkpoint.
|
|
2172
|
+
const okChecks = Number(f.consecutive_ok_checks ?? 0) + 1;
|
|
2173
|
+
if (okChecks < REQUIRED_CONSECUTIVE_OK_CHECKS) {
|
|
2174
|
+
// Need one more healthy checkpoint. Advance along the schedule; past
|
|
2175
|
+
// the last step, re-check shortly after now.
|
|
2176
|
+
const nextStep = Math.min(3, f.watchdog_step + 1);
|
|
2177
|
+
const nextCheck =
|
|
2178
|
+
f.watchdog_step < 3
|
|
2179
|
+
? nextCheckFor(nextStep)
|
|
2180
|
+
: new Date(Date.parse(nowIso) + 5 * 60_000).toISOString();
|
|
1606
2181
|
await db
|
|
1607
2182
|
.prepare(
|
|
1608
|
-
`UPDATE
|
|
1609
|
-
SET
|
|
2183
|
+
`UPDATE investigation_findings
|
|
2184
|
+
SET watchdog_step = ?, consecutive_ok_checks = ?,
|
|
2185
|
+
next_watchdog_check_at = ?, updated_at = ?
|
|
1610
2186
|
WHERE project_id = ? AND environment = ? AND id = ?`,
|
|
1611
2187
|
)
|
|
1612
|
-
.bind(
|
|
2188
|
+
.bind(
|
|
2189
|
+
nextStep,
|
|
2190
|
+
okChecks,
|
|
2191
|
+
nextCheck,
|
|
2192
|
+
nowIso,
|
|
2193
|
+
f.project_id,
|
|
2194
|
+
f.environment,
|
|
2195
|
+
f.id,
|
|
2196
|
+
)
|
|
1613
2197
|
.run();
|
|
2198
|
+
continue;
|
|
1614
2199
|
}
|
|
1615
|
-
|
|
1616
|
-
// Traffic is sufficient AND error rate recovered (<5%)
|
|
2200
|
+
|
|
1617
2201
|
recovered += 1;
|
|
1618
2202
|
const nextCausalVerification =
|
|
1619
2203
|
f.causal_verification_status === "counterfactual_verified"
|
|
@@ -1626,12 +2210,14 @@ export async function evaluateRemediationWatchdogs(
|
|
|
1626
2210
|
SET recovery_status = 'recovered',
|
|
1627
2211
|
causal_verification_status = ?,
|
|
1628
2212
|
watchdog_step = 3,
|
|
2213
|
+
consecutive_ok_checks = ?,
|
|
1629
2214
|
next_watchdog_check_at = NULL,
|
|
1630
2215
|
updated_at = ?
|
|
1631
2216
|
WHERE project_id = ? AND environment = ? AND id = ?`,
|
|
1632
2217
|
)
|
|
1633
2218
|
.bind(
|
|
1634
2219
|
nextCausalVerification,
|
|
2220
|
+
okChecks,
|
|
1635
2221
|
nowIso,
|
|
1636
2222
|
f.project_id,
|
|
1637
2223
|
f.environment,
|
|
@@ -1649,6 +2235,13 @@ export async function evaluateRemediationWatchdogs(
|
|
|
1649
2235
|
.bind(nowIso, nowIso, f.project_id, f.environment, f.incident_id)
|
|
1650
2236
|
.run();
|
|
1651
2237
|
}
|
|
2238
|
+
} catch (err) {
|
|
2239
|
+
failed += 1;
|
|
2240
|
+
logger.error("[autosre] watchdog evaluation failed for finding", {
|
|
2241
|
+
findingId: f.id,
|
|
2242
|
+
projectId: f.project_id,
|
|
2243
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2244
|
+
});
|
|
1652
2245
|
}
|
|
1653
2246
|
}
|
|
1654
2247
|
|
|
@@ -1657,7 +2250,8 @@ export async function evaluateRemediationWatchdogs(
|
|
|
1657
2250
|
recovered,
|
|
1658
2251
|
inconclusiveLowTraffic,
|
|
1659
2252
|
stillDegraded,
|
|
1660
|
-
timedOutClaims
|
|
2253
|
+
timedOutClaims,
|
|
1661
2254
|
expiredGrants: expiredGrantsRes.meta.changes,
|
|
2255
|
+
failed,
|
|
1662
2256
|
};
|
|
1663
2257
|
}
|