@obsunified/collector 2.2.0 → 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/index.d.ts +53 -3
- package/dist/index.js +10459 -9696
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/autosre-closed-loop.test.ts +964 -17
- package/src/lib/analyses-runner.ts +44 -7
- package/src/lib/autosre-change-correlation.ts +39 -12
- package/src/lib/autosre-findings.ts +68 -30
- 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 +799 -335
- package/src/lib/housekeeping.test.ts +70 -0
- package/src/lib/housekeeping.ts +91 -83
- package/src/lib/store/ingest.ts +8 -56
- package/src/migrations/051_incident_active_dedup_unique.sql +31 -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/plugins/alerts-evaluator.ts +15 -3
- package/src/plugins/autosre-routes.ts +223 -108
- package/src/plugins/connected-routes/manifest.ts +1 -4
- package/src/plugins/connected-routes.ts +48 -78
- package/src/plugins/evidence-retrieval-routes.test.ts +32 -0
- package/src/plugins/evidence-retrieval-routes.ts +372 -30
|
@@ -9,12 +9,18 @@ import type {
|
|
|
9
9
|
RemediationProposalStatus,
|
|
10
10
|
RemediationStateSpec,
|
|
11
11
|
} from "@obsunified/types";
|
|
12
|
+
import { buildCanonicalNodeId } from "@obsunified/types";
|
|
12
13
|
import { AgentAutonomyLevel } from "@obsunified/types/constants";
|
|
14
|
+
import { consoleLogger, type Logger } from "../framework/logger";
|
|
13
15
|
import { getInvestigationFinding } from "./autosre-findings";
|
|
14
16
|
import { randomHex, sha256Hex } from "./hash";
|
|
15
17
|
import { parseJsonRecord } from "./json";
|
|
16
18
|
import type { SqlDb } from "./sql-db";
|
|
17
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
assertSafeWebhookUrl,
|
|
21
|
+
type HostLookup,
|
|
22
|
+
resolveWebhookHost,
|
|
23
|
+
} from "./webhook-url";
|
|
18
24
|
|
|
19
25
|
interface IncidentRow {
|
|
20
26
|
project_id: string;
|
|
@@ -70,6 +76,89 @@ export const ACTIVE_INCIDENT_STATUSES: ReadonlySet<IncidentStatus> = new Set([
|
|
|
70
76
|
"verifying",
|
|
71
77
|
]);
|
|
72
78
|
|
|
79
|
+
const ACTIVE_STATUS_SQL =
|
|
80
|
+
"('open', 'dispatched', 'leased', 'remediating', 'verifying')";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Constant-time comparison for equal-purpose hex digests. Length is not secret
|
|
84
|
+
* (always a SHA-256 hex digest), so a length mismatch returns early.
|
|
85
|
+
*/
|
|
86
|
+
function constantTimeEqual(a: string, b: string): boolean {
|
|
87
|
+
if (a.length !== b.length) return false;
|
|
88
|
+
let diff = 0;
|
|
89
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
90
|
+
return diff === 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function loadProposalRow(
|
|
94
|
+
db: SqlDb,
|
|
95
|
+
projectId: string,
|
|
96
|
+
proposalId: string,
|
|
97
|
+
environment?: string,
|
|
98
|
+
): Promise<ProposalRow | null> {
|
|
99
|
+
return environment
|
|
100
|
+
? await db
|
|
101
|
+
.prepare(
|
|
102
|
+
`SELECT * FROM remediation_proposals
|
|
103
|
+
WHERE project_id = ? AND environment = ? AND id = ?`,
|
|
104
|
+
)
|
|
105
|
+
.bind(projectId, environment.toLowerCase(), proposalId)
|
|
106
|
+
.first<ProposalRow>()
|
|
107
|
+
: await db
|
|
108
|
+
.prepare(
|
|
109
|
+
`SELECT * FROM remediation_proposals
|
|
110
|
+
WHERE project_id = ? AND id = ?
|
|
111
|
+
ORDER BY created_at DESC
|
|
112
|
+
LIMIT 1`,
|
|
113
|
+
)
|
|
114
|
+
.bind(projectId, proposalId)
|
|
115
|
+
.first<ProposalRow>();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolves the environment and canonical node id an alert/analysis-triggered
|
|
120
|
+
* incident should target. The environment is taken from the most recent span of
|
|
121
|
+
* the service (rules and analyses carry no environment of their own); with no
|
|
122
|
+
* service or no spans it falls back to `default` and the supplied node id.
|
|
123
|
+
*/
|
|
124
|
+
export async function resolveServiceIncidentTarget(
|
|
125
|
+
db: SqlDb,
|
|
126
|
+
projectId: string,
|
|
127
|
+
serviceName: string | null | undefined,
|
|
128
|
+
fallbackNodeId: string,
|
|
129
|
+
logger: Logger = consoleLogger,
|
|
130
|
+
): Promise<{ environment: string; targetNodeId: string }> {
|
|
131
|
+
const service = serviceName?.trim();
|
|
132
|
+
if (!service) return { environment: "default", targetNodeId: fallbackNodeId };
|
|
133
|
+
let environment = "default";
|
|
134
|
+
try {
|
|
135
|
+
const row = await db
|
|
136
|
+
.prepare(
|
|
137
|
+
`SELECT environment FROM telemetry_spans
|
|
138
|
+
WHERE project_id = ? AND service_name = ?
|
|
139
|
+
ORDER BY received_at DESC
|
|
140
|
+
LIMIT 1`,
|
|
141
|
+
)
|
|
142
|
+
.bind(projectId, service)
|
|
143
|
+
.first<{ environment: string | null }>();
|
|
144
|
+
if (row?.environment) environment = row.environment.trim().toLowerCase();
|
|
145
|
+
} catch (err) {
|
|
146
|
+
logger.warn("[autosre] could not resolve incident environment", {
|
|
147
|
+
projectId,
|
|
148
|
+
service,
|
|
149
|
+
error: err instanceof Error ? err.message : String(err),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
environment,
|
|
154
|
+
targetNodeId: buildCanonicalNodeId({
|
|
155
|
+
kind: "service",
|
|
156
|
+
environment,
|
|
157
|
+
serviceName: service,
|
|
158
|
+
}).id,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
73
162
|
/**
|
|
74
163
|
* Opens or deduplicates an active `incident_investigation` (RFC 0013 § 6.A & P2 #7).
|
|
75
164
|
* Multiple firing alerts or webhook retries for the same `(project_id, environment, target_node_id, symptom_category)`
|
|
@@ -100,20 +189,23 @@ export async function openOrDeduplicateIncident(
|
|
|
100
189
|
`${projectId}::${environment}::${input.targetNodeId}::${category}`,
|
|
101
190
|
);
|
|
102
191
|
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
192
|
+
const selectActive = () =>
|
|
193
|
+
db
|
|
194
|
+
.prepare(
|
|
195
|
+
`SELECT * FROM incident_investigations
|
|
196
|
+
WHERE project_id = ?
|
|
197
|
+
AND environment = ?
|
|
198
|
+
AND dedup_key = ?
|
|
199
|
+
AND status IN ${ACTIVE_STATUS_SQL}
|
|
200
|
+
ORDER BY opened_at DESC
|
|
201
|
+
LIMIT 1`,
|
|
202
|
+
)
|
|
203
|
+
.bind(projectId, environment, dedupKey)
|
|
204
|
+
.first<IncidentRow>();
|
|
115
205
|
|
|
116
|
-
|
|
206
|
+
const coalesceInto = async (
|
|
207
|
+
row: IncidentRow,
|
|
208
|
+
): Promise<{ incident: IncidentInvestigation; deduplicated: boolean }> => {
|
|
117
209
|
await db
|
|
118
210
|
.prepare(
|
|
119
211
|
`UPDATE incident_investigations
|
|
@@ -129,35 +221,39 @@ export async function openOrDeduplicateIncident(
|
|
|
129
221
|
nowIso,
|
|
130
222
|
projectId,
|
|
131
223
|
environment,
|
|
132
|
-
|
|
224
|
+
row.id,
|
|
133
225
|
)
|
|
134
226
|
.run();
|
|
135
227
|
|
|
136
228
|
const updated = await getIncidentInvestigation(
|
|
137
229
|
db,
|
|
138
230
|
projectId,
|
|
139
|
-
|
|
231
|
+
row.id,
|
|
140
232
|
environment,
|
|
141
233
|
);
|
|
142
234
|
return {
|
|
143
|
-
incident: updated ?? mapIncidentRow(
|
|
235
|
+
incident: updated ?? mapIncidentRow(row),
|
|
144
236
|
deduplicated: true,
|
|
145
237
|
};
|
|
146
|
-
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const existing = await selectActive();
|
|
241
|
+
if (existing) return coalesceInto(existing);
|
|
147
242
|
|
|
148
243
|
const id = input.id?.trim() || `inc_${randomHex(10)}`;
|
|
149
|
-
const expiresAt = new Date(
|
|
150
|
-
Date.now() + 30 * 24 * 3600_000,
|
|
151
|
-
).toISOString();
|
|
244
|
+
const expiresAt = new Date(Date.now() + 30 * 24 * 3600_000).toISOString();
|
|
152
245
|
|
|
153
|
-
|
|
246
|
+
// The partial unique index uq_incidents_active_dedup (migration 051) makes
|
|
247
|
+
// a concurrent duplicate insert a no-op instead of a second active incident.
|
|
248
|
+
const inserted = await db
|
|
154
249
|
.prepare(
|
|
155
250
|
`INSERT INTO incident_investigations (
|
|
156
251
|
project_id, environment, id, dedup_key, status, target_node_id,
|
|
157
252
|
symptom_anchor_kind, symptom_anchor_id, symptom_fingerprint,
|
|
158
253
|
alert_count, latest_bundle_json, dispatch_attempts,
|
|
159
254
|
opened_at, updated_at, expires_at
|
|
160
|
-
) VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, 1, ?, 0, ?, ?, ?)
|
|
255
|
+
) VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, 1, ?, 0, ?, ?, ?)
|
|
256
|
+
ON CONFLICT DO NOTHING`,
|
|
161
257
|
)
|
|
162
258
|
.bind(
|
|
163
259
|
projectId,
|
|
@@ -175,7 +271,22 @@ export async function openOrDeduplicateIncident(
|
|
|
175
271
|
)
|
|
176
272
|
.run();
|
|
177
273
|
|
|
178
|
-
|
|
274
|
+
if (inserted.meta.changes === 0) {
|
|
275
|
+
// Lost the race to a concurrent opener: coalesce into the winner.
|
|
276
|
+
const winner = await selectActive();
|
|
277
|
+
if (winner) return coalesceInto(winner);
|
|
278
|
+
throw new RemediationGateError(
|
|
279
|
+
`Incident id ${id} already exists in environment ${environment}`,
|
|
280
|
+
409,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const created = await getIncidentInvestigation(
|
|
285
|
+
db,
|
|
286
|
+
projectId,
|
|
287
|
+
id,
|
|
288
|
+
environment,
|
|
289
|
+
);
|
|
179
290
|
if (!created) {
|
|
180
291
|
throw new Error(`Failed to load created incident ${id}`);
|
|
181
292
|
}
|
|
@@ -229,7 +340,7 @@ export async function listIncidentInvestigations(
|
|
|
229
340
|
conditions.push("status = ?");
|
|
230
341
|
binds.push(options.status);
|
|
231
342
|
}
|
|
232
|
-
const limit = Math.max(1, Math.min(
|
|
343
|
+
const limit = Math.max(1, Math.min(200, options.limit ?? 50));
|
|
233
344
|
binds.push(limit);
|
|
234
345
|
|
|
235
346
|
const rows = await db
|
|
@@ -285,17 +396,12 @@ export async function acquireIncidentLease(
|
|
|
285
396
|
};
|
|
286
397
|
}
|
|
287
398
|
|
|
288
|
-
if (
|
|
289
|
-
incident.leaseOwnerId &&
|
|
290
|
-
incident.leaseOwnerId !== input.runnerId &&
|
|
291
|
-
incident.leaseExpiresAt &&
|
|
292
|
-
incident.leaseExpiresAt > nowIso
|
|
293
|
-
) {
|
|
399
|
+
if (!ACTIVE_INCIDENT_STATUSES.has(incident.status)) {
|
|
294
400
|
return {
|
|
295
401
|
acquired: false,
|
|
296
|
-
reason: `Incident ${incidentId} is
|
|
297
|
-
conflictOwnerId:
|
|
298
|
-
leaseExpiresAt:
|
|
402
|
+
reason: `Incident ${incidentId} is ${incident.status} and can no longer be leased`,
|
|
403
|
+
conflictOwnerId: null,
|
|
404
|
+
leaseExpiresAt: null,
|
|
299
405
|
};
|
|
300
406
|
}
|
|
301
407
|
|
|
@@ -306,28 +412,32 @@ export async function acquireIncidentLease(
|
|
|
306
412
|
const leaseExpiresAt = new Date(
|
|
307
413
|
Date.parse(nowIso) + durationSec * 1000,
|
|
308
414
|
).toISOString();
|
|
309
|
-
const nextStatus: IncidentStatus =
|
|
310
|
-
incident.status === "open" || incident.status === "dispatched"
|
|
311
|
-
? "leased"
|
|
312
|
-
: incident.status;
|
|
313
415
|
|
|
314
|
-
|
|
416
|
+
// The lease precondition lives in the UPDATE itself so two runners racing
|
|
417
|
+
// for the same incident cannot both succeed: only one sees changes === 1.
|
|
418
|
+
const res = await db
|
|
315
419
|
.prepare(
|
|
316
420
|
`UPDATE incident_investigations
|
|
317
421
|
SET lease_owner_id = ?,
|
|
318
422
|
lease_expires_at = ?,
|
|
319
|
-
status =
|
|
423
|
+
status = CASE WHEN status IN ('open', 'dispatched') THEN 'leased' ELSE status END,
|
|
320
424
|
updated_at = ?
|
|
321
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
425
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
426
|
+
AND status IN ${ACTIVE_STATUS_SQL}
|
|
427
|
+
AND (lease_owner_id IS NULL
|
|
428
|
+
OR lease_owner_id = ?
|
|
429
|
+
OR lease_expires_at IS NULL
|
|
430
|
+
OR lease_expires_at <= ?)`,
|
|
322
431
|
)
|
|
323
432
|
.bind(
|
|
324
433
|
input.runnerId,
|
|
325
434
|
leaseExpiresAt,
|
|
326
|
-
nextStatus,
|
|
327
435
|
nowIso,
|
|
328
436
|
projectId,
|
|
329
437
|
incident.environment,
|
|
330
438
|
incident.id,
|
|
439
|
+
input.runnerId,
|
|
440
|
+
nowIso,
|
|
331
441
|
)
|
|
332
442
|
.run();
|
|
333
443
|
|
|
@@ -337,13 +447,20 @@ export async function acquireIncidentLease(
|
|
|
337
447
|
incident.id,
|
|
338
448
|
incident.environment,
|
|
339
449
|
);
|
|
450
|
+
if (res.meta.changes !== 1) {
|
|
451
|
+
return {
|
|
452
|
+
acquired: false,
|
|
453
|
+
reason: `Incident ${incidentId} is already leased by runner ${updated?.leaseOwnerId ?? "unknown"} until ${updated?.leaseExpiresAt ?? "unknown"}`,
|
|
454
|
+
conflictOwnerId: updated?.leaseOwnerId ?? null,
|
|
455
|
+
leaseExpiresAt: updated?.leaseExpiresAt ?? null,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
340
458
|
return {
|
|
341
459
|
acquired: true,
|
|
342
460
|
incident: updated ?? {
|
|
343
461
|
...incident,
|
|
344
462
|
leaseOwnerId: input.runnerId,
|
|
345
463
|
leaseExpiresAt,
|
|
346
|
-
status: nextStatus,
|
|
347
464
|
updatedAt: nowIso,
|
|
348
465
|
},
|
|
349
466
|
};
|
|
@@ -368,9 +485,45 @@ async function computeHmacSha256Hex(
|
|
|
368
485
|
return hex;
|
|
369
486
|
}
|
|
370
487
|
|
|
488
|
+
/**
|
|
489
|
+
* Resolves the DNS-classification hook for hosts that can resolve names
|
|
490
|
+
* (Node: `dns.lookup`). Workers cannot reach private networks from `fetch`, so
|
|
491
|
+
* there the hook is left unset. The specifier is built at runtime so bundlers
|
|
492
|
+
* for Workers do not try to resolve `node:dns` statically.
|
|
493
|
+
*/
|
|
494
|
+
async function defaultHostLookup(): Promise<HostLookup | undefined> {
|
|
495
|
+
if (typeof process === "undefined" || !process.versions?.node) {
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
const specifier = "node:dns/promises";
|
|
500
|
+
const dns = (await import(/* @vite-ignore */ specifier)) as {
|
|
501
|
+
lookup: (
|
|
502
|
+
host: string,
|
|
503
|
+
opts: { all: true; verbatim: true },
|
|
504
|
+
) => Promise<Array<{ address: string }>>;
|
|
505
|
+
};
|
|
506
|
+
return async (hostname) =>
|
|
507
|
+
(await dns.lookup(hostname, { all: true, verbatim: true })).map(
|
|
508
|
+
(a) => a.address,
|
|
509
|
+
);
|
|
510
|
+
} catch {
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
371
515
|
/**
|
|
372
516
|
* SSRF-safe, HMAC-SHA256 signed AutoSRE incident webhook outbox delivery
|
|
373
|
-
* (RFC 0013 § 6.A).
|
|
517
|
+
* (RFC 0013 § 6.A). Mirrors the alert webhook delivery: literal host check,
|
|
518
|
+
* DNS classification (fail closed on private/unresolvable), no redirects and
|
|
519
|
+
* a 5s timeout.
|
|
520
|
+
*
|
|
521
|
+
* Grant handoff (RFC 0013 § 6.B): when `webhook.grant` is supplied the payload
|
|
522
|
+
* is an `autosre.remediation.grant_issued` event carrying `proposalId`,
|
|
523
|
+
* `status` and the plaintext `grantToken`. The plaintext token exists only at
|
|
524
|
+
* approve/propose time (the DB keeps its hash), so the caller that mints it
|
|
525
|
+
* must hand it to this function in the same request. Grant deliveries do not
|
|
526
|
+
* count against the incident's 5-attempt dispatch budget.
|
|
374
527
|
*/
|
|
375
528
|
export async function dispatchIncidentWebhook(
|
|
376
529
|
db: SqlDb,
|
|
@@ -379,25 +532,63 @@ export async function dispatchIncidentWebhook(
|
|
|
379
532
|
webhook: {
|
|
380
533
|
url: string;
|
|
381
534
|
secret?: string;
|
|
535
|
+
environment?: string;
|
|
382
536
|
allowPrivateNetworks?: boolean;
|
|
537
|
+
hostLookup?: HostLookup;
|
|
383
538
|
fetchImpl?: typeof fetch;
|
|
539
|
+
logger?: Logger;
|
|
540
|
+
grant?: { proposalId: string; status: string; grantToken: string };
|
|
384
541
|
},
|
|
385
542
|
): Promise<{ delivered: boolean; status?: number; error?: string }> {
|
|
386
|
-
const
|
|
543
|
+
const logger = webhook.logger ?? consoleLogger;
|
|
544
|
+
const incident = await getIncidentInvestigation(
|
|
545
|
+
db,
|
|
546
|
+
projectId,
|
|
547
|
+
incidentId,
|
|
548
|
+
webhook.environment,
|
|
549
|
+
);
|
|
387
550
|
if (!incident) {
|
|
388
551
|
return { delivered: false, error: "Incident not found" };
|
|
389
552
|
}
|
|
390
|
-
if (incident.dispatchAttempts >= 5) {
|
|
553
|
+
if (!webhook.grant && incident.dispatchAttempts >= 5) {
|
|
391
554
|
return { delivered: false, error: "MAX_DISPATCH_ATTEMPTS (5) exceeded" };
|
|
392
555
|
}
|
|
393
556
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
557
|
+
let safeUrl: URL;
|
|
558
|
+
try {
|
|
559
|
+
safeUrl = assertSafeWebhookUrl(webhook.url, {
|
|
560
|
+
allowPrivateNetworks: webhook.allowPrivateNetworks,
|
|
561
|
+
});
|
|
562
|
+
if (!webhook.allowPrivateNetworks) {
|
|
563
|
+
const lookup = webhook.hostLookup ?? (await defaultHostLookup());
|
|
564
|
+
if (lookup) {
|
|
565
|
+
const resolution = await resolveWebhookHost(safeUrl.hostname, lookup);
|
|
566
|
+
if (resolution === "private") {
|
|
567
|
+
throw new Error(
|
|
568
|
+
`webhook host "${safeUrl.hostname}" resolves to a private address`,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
if (resolution === "unresolvable") {
|
|
572
|
+
throw new Error(
|
|
573
|
+
`webhook host "${safeUrl.hostname}" could not be resolved; refusing to deliver blind`,
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
} catch (err) {
|
|
579
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
580
|
+
logger.warn("[autosre] incident webhook target refused", {
|
|
581
|
+
incidentId,
|
|
582
|
+
error,
|
|
583
|
+
});
|
|
584
|
+
return { delivered: false, error };
|
|
585
|
+
}
|
|
397
586
|
|
|
398
587
|
const nowIso = new Date().toISOString();
|
|
399
588
|
const body = JSON.stringify({
|
|
400
|
-
event:
|
|
589
|
+
event: webhook.grant
|
|
590
|
+
? "autosre.remediation.grant_issued"
|
|
591
|
+
: "autosre.incident.opened",
|
|
401
592
|
incidentId: incident.id,
|
|
402
593
|
projectId: incident.projectId,
|
|
403
594
|
environment: incident.environment,
|
|
@@ -408,6 +599,13 @@ export async function dispatchIncidentWebhook(
|
|
|
408
599
|
id: incident.symptomAnchorId,
|
|
409
600
|
},
|
|
410
601
|
bundle: incident.latestBundle,
|
|
602
|
+
...(webhook.grant
|
|
603
|
+
? {
|
|
604
|
+
proposalId: webhook.grant.proposalId,
|
|
605
|
+
status: webhook.grant.status,
|
|
606
|
+
grantToken: webhook.grant.grantToken,
|
|
607
|
+
}
|
|
608
|
+
: {}),
|
|
411
609
|
dispatchedAt: nowIso,
|
|
412
610
|
});
|
|
413
611
|
|
|
@@ -431,16 +629,33 @@ export async function dispatchIncidentWebhook(
|
|
|
431
629
|
method: "POST",
|
|
432
630
|
headers,
|
|
433
631
|
body,
|
|
632
|
+
// Never follow redirects: a 3xx could re-aim the POST (and its
|
|
633
|
+
// signed body, which may carry a grant token) at another host.
|
|
634
|
+
redirect: "manual",
|
|
635
|
+
signal: AbortSignal.timeout(5000),
|
|
434
636
|
});
|
|
435
637
|
status = res.status;
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
638
|
+
if (res.status >= 300 && res.status < 400) {
|
|
639
|
+
errorMsg = `Redirect (HTTP ${res.status}) refused`;
|
|
640
|
+
} else {
|
|
641
|
+
delivered = res.ok;
|
|
642
|
+
if (!res.ok) errorMsg = `HTTP ${res.status}`;
|
|
439
643
|
}
|
|
440
644
|
} catch (err) {
|
|
441
645
|
errorMsg = err instanceof Error ? err.message : String(err);
|
|
442
646
|
}
|
|
443
647
|
|
|
648
|
+
if (!delivered) {
|
|
649
|
+
logger.warn("[autosre] incident webhook delivery failed", {
|
|
650
|
+
incidentId,
|
|
651
|
+
error: errorMsg,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (webhook.grant) {
|
|
656
|
+
return { delivered, status, error: errorMsg };
|
|
657
|
+
}
|
|
658
|
+
|
|
444
659
|
await db
|
|
445
660
|
.prepare(
|
|
446
661
|
`UPDATE incident_investigations
|
|
@@ -542,10 +757,12 @@ function evaluateActionPolicyMatrix(
|
|
|
542
757
|
effectiveAutonomy: AgentAutonomyLevel;
|
|
543
758
|
reason: string;
|
|
544
759
|
} {
|
|
760
|
+
// `recentProposalsLastHour` counts prior proposals only, so this proposal
|
|
761
|
+
// would be the 4th within the hour once three already exist (max 3/hr).
|
|
545
762
|
if (recentProposalsLastHour >= 3) {
|
|
546
763
|
return {
|
|
547
764
|
effectiveAutonomy: AgentAutonomyLevel.BlockedByPolicy,
|
|
548
|
-
reason: `Circuit breaker tripped: ${recentProposalsLastHour} remediation proposals already recorded in the last hour for this
|
|
765
|
+
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
766
|
};
|
|
550
767
|
}
|
|
551
768
|
|
|
@@ -630,20 +847,21 @@ export async function proposeRemediationAction(
|
|
|
630
847
|
db: SqlDb,
|
|
631
848
|
projectId: string,
|
|
632
849
|
input: RemediationProposalInput,
|
|
850
|
+
options: { logger?: Logger } = {},
|
|
633
851
|
): Promise<RemediationProposal> {
|
|
852
|
+
const logger = options.logger ?? consoleLogger;
|
|
634
853
|
const nowIso = new Date().toISOString();
|
|
635
854
|
const environment = (input.environment || "default").trim().toLowerCase();
|
|
636
855
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
if (existing) {
|
|
856
|
+
const loadByIdempotencyKey = () =>
|
|
857
|
+
db
|
|
858
|
+
.prepare(
|
|
859
|
+
`SELECT * FROM remediation_proposals
|
|
860
|
+
WHERE project_id = ? AND environment = ? AND idempotency_key = ?`,
|
|
861
|
+
)
|
|
862
|
+
.bind(projectId, environment, input.idempotencyKey)
|
|
863
|
+
.first<ProposalRow>();
|
|
864
|
+
const replayExisting = (existing: ProposalRow): RemediationProposal => {
|
|
647
865
|
if (
|
|
648
866
|
existing.action_kind !== input.actionKind ||
|
|
649
867
|
existing.target_node_id !== input.targetNodeId
|
|
@@ -654,6 +872,23 @@ export async function proposeRemediationAction(
|
|
|
654
872
|
);
|
|
655
873
|
}
|
|
656
874
|
return mapProposalRow(existing);
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
// 1. Check idempotency key first
|
|
878
|
+
const existing = await loadByIdempotencyKey();
|
|
879
|
+
if (existing) return replayExisting(existing);
|
|
880
|
+
|
|
881
|
+
const incident = await getIncidentInvestigation(
|
|
882
|
+
db,
|
|
883
|
+
projectId,
|
|
884
|
+
input.incidentId,
|
|
885
|
+
environment,
|
|
886
|
+
);
|
|
887
|
+
if (!incident) {
|
|
888
|
+
throw new RemediationGateError(
|
|
889
|
+
`Incident ${input.incidentId} not found in environment ${environment}`,
|
|
890
|
+
404,
|
|
891
|
+
);
|
|
657
892
|
}
|
|
658
893
|
|
|
659
894
|
const finding = await getInvestigationFinding(
|
|
@@ -669,17 +904,32 @@ export async function proposeRemediationAction(
|
|
|
669
904
|
);
|
|
670
905
|
}
|
|
671
906
|
|
|
672
|
-
// 2. Circuit breaker
|
|
907
|
+
// 2. Circuit breaker: proposals of the same action kind for the same finding
|
|
908
|
+
// fingerprint in the last 60 minutes. Rejected and expired proposals never
|
|
909
|
+
// executed, so they do not count against the budget.
|
|
673
910
|
const oneHourAgo = new Date(Date.now() - 3600_000).toISOString();
|
|
674
911
|
const recentCountRow = await db
|
|
675
912
|
.prepare(
|
|
676
|
-
`SELECT COUNT(*) AS cnt
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
913
|
+
`SELECT COUNT(*) AS cnt
|
|
914
|
+
FROM remediation_proposals p
|
|
915
|
+
JOIN investigation_findings f
|
|
916
|
+
ON f.project_id = p.project_id
|
|
917
|
+
AND f.environment = p.environment
|
|
918
|
+
AND f.id = p.finding_id
|
|
919
|
+
WHERE p.project_id = ?
|
|
920
|
+
AND p.environment = ?
|
|
921
|
+
AND f.fingerprint = ?
|
|
922
|
+
AND p.action_kind = ?
|
|
923
|
+
AND p.status NOT IN ('rejected', 'expired')
|
|
924
|
+
AND p.created_at >= ?`,
|
|
925
|
+
)
|
|
926
|
+
.bind(
|
|
927
|
+
projectId,
|
|
928
|
+
environment,
|
|
929
|
+
finding.fingerprint,
|
|
930
|
+
input.actionKind,
|
|
931
|
+
oneHourAgo,
|
|
681
932
|
)
|
|
682
|
-
.bind(projectId, environment, input.targetNodeId, oneHourAgo)
|
|
683
933
|
.first<{ cnt: number }>();
|
|
684
934
|
|
|
685
935
|
const recentCount = Number(recentCountRow?.cnt ?? 0);
|
|
@@ -701,6 +951,16 @@ export async function proposeRemediationAction(
|
|
|
701
951
|
) {
|
|
702
952
|
effectiveAutonomy = AgentAutonomyLevel.HumanApprovedWrite;
|
|
703
953
|
policyReason = `Caller explicitly requested human_approved_write (${policy.reason})`;
|
|
954
|
+
} else if (
|
|
955
|
+
(requestedAutonomy === AgentAutonomyLevel.ReadOnly ||
|
|
956
|
+
requestedAutonomy === AgentAutonomyLevel.SuggestedAction) &&
|
|
957
|
+
(effectiveAutonomy === AgentAutonomyLevel.AutonomousWrite ||
|
|
958
|
+
effectiveAutonomy === AgentAutonomyLevel.HumanApprovedWrite)
|
|
959
|
+
) {
|
|
960
|
+
// A caller that only asked to read or suggest never gets a write grant;
|
|
961
|
+
// a human can still approve the parked proposal explicitly.
|
|
962
|
+
effectiveAutonomy = requestedAutonomy;
|
|
963
|
+
policyReason = `Caller requested ${requestedAutonomy}; parked for human approval (${policy.reason})`;
|
|
704
964
|
}
|
|
705
965
|
|
|
706
966
|
let status: RemediationProposalStatus = "pending_human_approval";
|
|
@@ -718,11 +978,18 @@ export async function proposeRemediationAction(
|
|
|
718
978
|
}
|
|
719
979
|
|
|
720
980
|
const id = `rem_${randomHex(10)}`;
|
|
721
|
-
const expiresAt = new Date(
|
|
722
|
-
Date.now() + 30 * 24 * 3600_000,
|
|
723
|
-
).toISOString();
|
|
981
|
+
const expiresAt = new Date(Date.now() + 30 * 24 * 3600_000).toISOString();
|
|
724
982
|
|
|
725
|
-
|
|
983
|
+
if (effectiveAutonomy === AgentAutonomyLevel.BlockedByPolicy) {
|
|
984
|
+
logger.warn("[autosre] remediation proposal blocked by policy", {
|
|
985
|
+
projectId,
|
|
986
|
+
incidentId: input.incidentId,
|
|
987
|
+
actionKind: input.actionKind,
|
|
988
|
+
reason: policyReason,
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const insertRes = await db
|
|
726
993
|
.prepare(
|
|
727
994
|
`INSERT INTO remediation_proposals (
|
|
728
995
|
project_id, environment, id, incident_id, finding_id,
|
|
@@ -731,7 +998,8 @@ export async function proposeRemediationAction(
|
|
|
731
998
|
requested_autonomy, effective_autonomy, policy_decision_reason,
|
|
732
999
|
status, grant_token_hash, grant_expires_at,
|
|
733
1000
|
created_at, updated_at, expires_at
|
|
734
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1001
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1002
|
+
ON CONFLICT DO NOTHING`,
|
|
735
1003
|
)
|
|
736
1004
|
.bind(
|
|
737
1005
|
projectId,
|
|
@@ -756,6 +1024,17 @@ export async function proposeRemediationAction(
|
|
|
756
1024
|
)
|
|
757
1025
|
.run();
|
|
758
1026
|
|
|
1027
|
+
if (insertRes.meta.changes === 0) {
|
|
1028
|
+
// A concurrent request with the same idempotency key won the race
|
|
1029
|
+
// (UNIQUE project_id, environment, idempotency_key): replay its row.
|
|
1030
|
+
const winner = await loadByIdempotencyKey();
|
|
1031
|
+
if (winner) return replayExisting(winner);
|
|
1032
|
+
throw new RemediationGateError(
|
|
1033
|
+
`Remediation proposal ${id} conflicted with an existing row`,
|
|
1034
|
+
409,
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
759
1038
|
const created = await db
|
|
760
1039
|
.prepare(
|
|
761
1040
|
`SELECT * FROM remediation_proposals
|
|
@@ -828,7 +1107,7 @@ export async function listRemediationProposals(
|
|
|
828
1107
|
binds.push(options.findingId);
|
|
829
1108
|
}
|
|
830
1109
|
|
|
831
|
-
const limit = Math.max(1, Math.min(
|
|
1110
|
+
const limit = Math.max(1, Math.min(200, options.limit ?? 50));
|
|
832
1111
|
binds.push(limit);
|
|
833
1112
|
|
|
834
1113
|
const rows = await db
|
|
@@ -847,6 +1126,10 @@ export async function listRemediationProposals(
|
|
|
847
1126
|
/**
|
|
848
1127
|
* Human dashboard session approval for `pending_human_approval` proposals
|
|
849
1128
|
* (RFC 0013 § 6.B & P1 #4). Mints a single-use `grantToken`.
|
|
1129
|
+
*
|
|
1130
|
+
* The status precondition lives in the UPDATE's WHERE clause (D1 and Postgres
|
|
1131
|
+
* have no multi-statement transactions beyond `batch`), so two concurrent
|
|
1132
|
+
* approvals cannot both mint a token: only one sees `meta.changes === 1`.
|
|
850
1133
|
*/
|
|
851
1134
|
export async function approveRemediationProposal(
|
|
852
1135
|
db: SqlDb,
|
|
@@ -857,23 +1140,12 @@ export async function approveRemediationProposal(
|
|
|
857
1140
|
approvedByUserId: string;
|
|
858
1141
|
},
|
|
859
1142
|
): 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
|
-
|
|
1143
|
+
const row = await loadProposalRow(
|
|
1144
|
+
db,
|
|
1145
|
+
projectId,
|
|
1146
|
+
proposalId,
|
|
1147
|
+
options.environment,
|
|
1148
|
+
);
|
|
877
1149
|
if (!row) {
|
|
878
1150
|
throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
|
|
879
1151
|
}
|
|
@@ -889,7 +1161,7 @@ export async function approveRemediationProposal(
|
|
|
889
1161
|
const grantTokenHash = await sha256Hex(grantToken);
|
|
890
1162
|
const grantExpiresAt = new Date(Date.now() + 5 * 60_000).toISOString();
|
|
891
1163
|
|
|
892
|
-
await db
|
|
1164
|
+
const res = await db
|
|
893
1165
|
.prepare(
|
|
894
1166
|
`UPDATE remediation_proposals
|
|
895
1167
|
SET status = 'grant_issued',
|
|
@@ -898,7 +1170,8 @@ export async function approveRemediationProposal(
|
|
|
898
1170
|
approved_by_user_id = ?,
|
|
899
1171
|
approved_at = ?,
|
|
900
1172
|
updated_at = ?
|
|
901
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1173
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1174
|
+
AND status = 'pending_human_approval'`,
|
|
902
1175
|
)
|
|
903
1176
|
.bind(
|
|
904
1177
|
grantTokenHash,
|
|
@@ -911,6 +1184,12 @@ export async function approveRemediationProposal(
|
|
|
911
1184
|
row.id,
|
|
912
1185
|
)
|
|
913
1186
|
.run();
|
|
1187
|
+
if (res.meta.changes !== 1) {
|
|
1188
|
+
throw new RemediationGateError(
|
|
1189
|
+
`Proposal ${proposalId} was modified concurrently and can no longer be approved`,
|
|
1190
|
+
409,
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
914
1193
|
|
|
915
1194
|
const updated = await db
|
|
916
1195
|
.prepare(
|
|
@@ -933,23 +1212,12 @@ export async function rejectRemediationProposal(
|
|
|
933
1212
|
reason?: string;
|
|
934
1213
|
},
|
|
935
1214
|
): 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
|
-
|
|
1215
|
+
const row = await loadProposalRow(
|
|
1216
|
+
db,
|
|
1217
|
+
projectId,
|
|
1218
|
+
proposalId,
|
|
1219
|
+
options.environment,
|
|
1220
|
+
);
|
|
953
1221
|
if (!row) {
|
|
954
1222
|
throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
|
|
955
1223
|
}
|
|
@@ -968,17 +1236,24 @@ export async function rejectRemediationProposal(
|
|
|
968
1236
|
? `Rejected by ${options.rejectedByUserId}: ${options.reason}`
|
|
969
1237
|
: `Rejected by ${options.rejectedByUserId}`;
|
|
970
1238
|
|
|
971
|
-
await db
|
|
1239
|
+
const res = await db
|
|
972
1240
|
.prepare(
|
|
973
1241
|
`UPDATE remediation_proposals
|
|
974
1242
|
SET status = 'rejected',
|
|
975
1243
|
grant_token_hash = NULL,
|
|
976
1244
|
failure_reason = ?,
|
|
977
1245
|
updated_at = ?
|
|
978
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1246
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1247
|
+
AND status IN ('pending_human_approval', 'grant_issued')`,
|
|
979
1248
|
)
|
|
980
1249
|
.bind(reason, nowIso, projectId, row.environment, row.id)
|
|
981
1250
|
.run();
|
|
1251
|
+
if (res.meta.changes !== 1) {
|
|
1252
|
+
throw new RemediationGateError(
|
|
1253
|
+
`Proposal ${proposalId} was modified concurrently and can no longer be rejected`,
|
|
1254
|
+
409,
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
982
1257
|
|
|
983
1258
|
const updated = await db
|
|
984
1259
|
.prepare(
|
|
@@ -994,6 +1269,12 @@ export async function rejectRemediationProposal(
|
|
|
994
1269
|
/**
|
|
995
1270
|
* Single-use grant redemption with optimistic `expectedCurrentState` verification
|
|
996
1271
|
* against `ontology_nodes` (RFC 0013 § 6.B & P1 #4).
|
|
1272
|
+
*
|
|
1273
|
+
* The status, token-hash and expiry preconditions are part of the claiming
|
|
1274
|
+
* UPDATE, and the outcome is read from `meta.changes`: of N concurrent claims
|
|
1275
|
+
* for one token exactly one sees `changes === 1`; the rest get a 409. The
|
|
1276
|
+
* incident transition rides in the same `batch` and is guarded by an EXISTS on
|
|
1277
|
+
* the winning claim, so a losing claim cannot touch the incident.
|
|
997
1278
|
*/
|
|
998
1279
|
export async function claimRemediationGrant(
|
|
999
1280
|
db: SqlDb,
|
|
@@ -1004,25 +1285,17 @@ export async function claimRemediationGrant(
|
|
|
1004
1285
|
grantToken: string;
|
|
1005
1286
|
runnerId?: string;
|
|
1006
1287
|
nowIso?: string;
|
|
1288
|
+
logger?: Logger;
|
|
1007
1289
|
},
|
|
1008
1290
|
): Promise<RemediationProposal> {
|
|
1291
|
+
const logger = options.logger ?? consoleLogger;
|
|
1009
1292
|
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>();
|
|
1293
|
+
const row = await loadProposalRow(
|
|
1294
|
+
db,
|
|
1295
|
+
projectId,
|
|
1296
|
+
proposalId,
|
|
1297
|
+
options.environment,
|
|
1298
|
+
);
|
|
1026
1299
|
|
|
1027
1300
|
if (!row) {
|
|
1028
1301
|
throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
|
|
@@ -1037,8 +1310,9 @@ export async function claimRemediationGrant(
|
|
|
1037
1310
|
await db
|
|
1038
1311
|
.prepare(
|
|
1039
1312
|
`UPDATE remediation_proposals
|
|
1040
|
-
SET status = 'expired', updated_at = ?
|
|
1041
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1313
|
+
SET status = 'expired', grant_token_hash = NULL, updated_at = ?
|
|
1314
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1315
|
+
AND status = 'grant_issued'`,
|
|
1042
1316
|
)
|
|
1043
1317
|
.bind(nowIso, projectId, row.environment, row.id)
|
|
1044
1318
|
.run();
|
|
@@ -1049,7 +1323,10 @@ export async function claimRemediationGrant(
|
|
|
1049
1323
|
}
|
|
1050
1324
|
|
|
1051
1325
|
const incomingHash = await sha256Hex(options.grantToken);
|
|
1052
|
-
if (
|
|
1326
|
+
if (
|
|
1327
|
+
!row.grant_token_hash ||
|
|
1328
|
+
!constantTimeEqual(incomingHash, row.grant_token_hash)
|
|
1329
|
+
) {
|
|
1053
1330
|
throw new RemediationGateError("Invalid grantToken", 403);
|
|
1054
1331
|
}
|
|
1055
1332
|
|
|
@@ -1071,43 +1348,35 @@ export async function claimRemediationGrant(
|
|
|
1071
1348
|
}>();
|
|
1072
1349
|
|
|
1073
1350
|
if (targetNode) {
|
|
1074
|
-
|
|
1351
|
+
const lockFailure =
|
|
1075
1352
|
expected.version &&
|
|
1076
1353
|
targetNode.current_version &&
|
|
1077
1354
|
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
|
-
);
|
|
1355
|
+
? `Optimistic version lock failed on ${row.target_node_id}: expected version "${expected.version}", but current version is "${targetNode.current_version}"`
|
|
1356
|
+
: expected.commitSha &&
|
|
1357
|
+
targetNode.current_commit_sha &&
|
|
1358
|
+
targetNode.current_commit_sha !== expected.commitSha
|
|
1359
|
+
? `Optimistic commit lock failed on ${row.target_node_id}: expected commit "${expected.commitSha}", but current commit is "${targetNode.current_commit_sha}"`
|
|
1360
|
+
: expected.configDigest &&
|
|
1361
|
+
targetNode.current_config_digest &&
|
|
1362
|
+
targetNode.current_config_digest !== expected.configDigest
|
|
1363
|
+
? `Optimistic config lock failed on ${row.target_node_id}: expected digest "${expected.configDigest}", but current digest is "${targetNode.current_config_digest}"`
|
|
1364
|
+
: null;
|
|
1365
|
+
if (lockFailure) {
|
|
1366
|
+
logger.warn("[autosre] remediation claim rejected by state lock", {
|
|
1367
|
+
proposalId,
|
|
1368
|
+
error: lockFailure,
|
|
1369
|
+
});
|
|
1370
|
+
throw new RemediationGateError(lockFailure, 409);
|
|
1103
1371
|
}
|
|
1104
1372
|
}
|
|
1105
1373
|
|
|
1106
1374
|
const claimExpiresAt = new Date(
|
|
1107
1375
|
Date.parse(nowIso) + 5 * 60_000,
|
|
1108
1376
|
).toISOString();
|
|
1377
|
+
const runnerId = options.runnerId ?? "autosre-runner";
|
|
1109
1378
|
|
|
1110
|
-
await db.batch([
|
|
1379
|
+
const results = await db.batch([
|
|
1111
1380
|
db
|
|
1112
1381
|
.prepare(
|
|
1113
1382
|
`UPDATE remediation_proposals
|
|
@@ -1117,25 +1386,53 @@ export async function claimRemediationGrant(
|
|
|
1117
1386
|
claimed_at = ?,
|
|
1118
1387
|
claim_expires_at = ?,
|
|
1119
1388
|
updated_at = ?
|
|
1120
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1389
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1390
|
+
AND status = 'grant_issued'
|
|
1391
|
+
AND grant_token_hash = ?
|
|
1392
|
+
AND (grant_expires_at IS NULL OR grant_expires_at > ?)`,
|
|
1121
1393
|
)
|
|
1122
1394
|
.bind(
|
|
1123
|
-
|
|
1395
|
+
runnerId,
|
|
1124
1396
|
nowIso,
|
|
1125
1397
|
claimExpiresAt,
|
|
1126
1398
|
nowIso,
|
|
1127
1399
|
projectId,
|
|
1128
1400
|
row.environment,
|
|
1129
1401
|
row.id,
|
|
1402
|
+
row.grant_token_hash,
|
|
1403
|
+
nowIso,
|
|
1130
1404
|
),
|
|
1131
1405
|
db
|
|
1132
1406
|
.prepare(
|
|
1133
1407
|
`UPDATE incident_investigations
|
|
1134
1408
|
SET status = 'remediating', updated_at = ?
|
|
1135
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1409
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1410
|
+
AND EXISTS (
|
|
1411
|
+
SELECT 1 FROM remediation_proposals g
|
|
1412
|
+
WHERE g.project_id = ? AND g.environment = ? AND g.id = ?
|
|
1413
|
+
AND g.status = 'claimed_executing'
|
|
1414
|
+
AND g.claimed_by_runner_id = ?
|
|
1415
|
+
AND g.claimed_at = ?
|
|
1416
|
+
)`,
|
|
1136
1417
|
)
|
|
1137
|
-
.bind(
|
|
1418
|
+
.bind(
|
|
1419
|
+
nowIso,
|
|
1420
|
+
projectId,
|
|
1421
|
+
row.environment,
|
|
1422
|
+
row.incident_id,
|
|
1423
|
+
projectId,
|
|
1424
|
+
row.environment,
|
|
1425
|
+
row.id,
|
|
1426
|
+
runnerId,
|
|
1427
|
+
nowIso,
|
|
1428
|
+
),
|
|
1138
1429
|
]);
|
|
1430
|
+
if (results[0]?.meta.changes !== 1) {
|
|
1431
|
+
throw new RemediationGateError(
|
|
1432
|
+
`Grant for proposal ${proposalId} was already claimed or is no longer valid (grants are single-use)`,
|
|
1433
|
+
409,
|
|
1434
|
+
);
|
|
1435
|
+
}
|
|
1139
1436
|
|
|
1140
1437
|
const updated = await db
|
|
1141
1438
|
.prepare(
|
|
@@ -1151,6 +1448,10 @@ export async function claimRemediationGrant(
|
|
|
1151
1448
|
/**
|
|
1152
1449
|
* Records remediation completion, updates `ontology_nodes` to desired state,
|
|
1153
1450
|
* and arms the +2m/+5m/+15m post-remediation watchdog on the finding.
|
|
1451
|
+
*
|
|
1452
|
+
* Only the runner that holds the claim may complete, and only while the claim
|
|
1453
|
+
* window is open (`now <= claim_expires_at`); otherwise 409. All follow-up
|
|
1454
|
+
* writes are guarded on the completion UPDATE having applied.
|
|
1154
1455
|
*/
|
|
1155
1456
|
export async function completeRemediationProposal(
|
|
1156
1457
|
db: SqlDb,
|
|
@@ -1158,6 +1459,7 @@ export async function completeRemediationProposal(
|
|
|
1158
1459
|
proposalId: string,
|
|
1159
1460
|
options: {
|
|
1160
1461
|
environment?: string;
|
|
1462
|
+
runnerId?: string;
|
|
1161
1463
|
resultingChangeId?: string | null;
|
|
1162
1464
|
remediationAgentRunId?: string | null;
|
|
1163
1465
|
baselineRequestRatePerMin?: number | null;
|
|
@@ -1165,22 +1467,12 @@ export async function completeRemediationProposal(
|
|
|
1165
1467
|
},
|
|
1166
1468
|
): Promise<RemediationProposal> {
|
|
1167
1469
|
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>();
|
|
1470
|
+
const row = await loadProposalRow(
|
|
1471
|
+
db,
|
|
1472
|
+
projectId,
|
|
1473
|
+
proposalId,
|
|
1474
|
+
options.environment,
|
|
1475
|
+
);
|
|
1184
1476
|
|
|
1185
1477
|
if (!row) {
|
|
1186
1478
|
throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
|
|
@@ -1191,6 +1483,18 @@ export async function completeRemediationProposal(
|
|
|
1191
1483
|
409,
|
|
1192
1484
|
);
|
|
1193
1485
|
}
|
|
1486
|
+
if (!options.runnerId || options.runnerId !== row.claimed_by_runner_id) {
|
|
1487
|
+
throw new RemediationGateError(
|
|
1488
|
+
`Proposal ${proposalId} is claimed by a different runner; only the claiming runner may complete it`,
|
|
1489
|
+
409,
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1492
|
+
if (row.claim_expires_at && row.claim_expires_at < nowIso) {
|
|
1493
|
+
throw new RemediationGateError(
|
|
1494
|
+
`Claim on proposal ${proposalId} expired at ${row.claim_expires_at}`,
|
|
1495
|
+
409,
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1194
1498
|
|
|
1195
1499
|
const desired = parseJsonRecord(
|
|
1196
1500
|
row.desired_target_state_json,
|
|
@@ -1233,7 +1537,15 @@ export async function completeRemediationProposal(
|
|
|
1233
1537
|
});
|
|
1234
1538
|
}
|
|
1235
1539
|
|
|
1236
|
-
|
|
1540
|
+
// Follow-up writes only apply when the completion UPDATE below won.
|
|
1541
|
+
const completedGuard = `EXISTS (
|
|
1542
|
+
SELECT 1 FROM remediation_proposals g
|
|
1543
|
+
WHERE g.project_id = ? AND g.environment = ? AND g.id = ?
|
|
1544
|
+
AND g.status = 'completed' AND g.updated_at = ?
|
|
1545
|
+
)`;
|
|
1546
|
+
const guardBinds = [projectId, row.environment, row.id, nowIso];
|
|
1547
|
+
|
|
1548
|
+
const results = await db.batch([
|
|
1237
1549
|
db
|
|
1238
1550
|
.prepare(
|
|
1239
1551
|
`UPDATE remediation_proposals
|
|
@@ -1241,7 +1553,10 @@ export async function completeRemediationProposal(
|
|
|
1241
1553
|
resulting_change_id = COALESCE(?, resulting_change_id),
|
|
1242
1554
|
remediation_agent_run_id = COALESCE(?, remediation_agent_run_id),
|
|
1243
1555
|
updated_at = ?
|
|
1244
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1556
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1557
|
+
AND status = 'claimed_executing'
|
|
1558
|
+
AND claimed_by_runner_id = ?
|
|
1559
|
+
AND (claim_expires_at IS NULL OR claim_expires_at >= ?)`,
|
|
1245
1560
|
)
|
|
1246
1561
|
.bind(
|
|
1247
1562
|
options.resultingChangeId ?? null,
|
|
@@ -1250,6 +1565,8 @@ export async function completeRemediationProposal(
|
|
|
1250
1565
|
projectId,
|
|
1251
1566
|
row.environment,
|
|
1252
1567
|
row.id,
|
|
1568
|
+
options.runnerId,
|
|
1569
|
+
nowIso,
|
|
1253
1570
|
),
|
|
1254
1571
|
db
|
|
1255
1572
|
.prepare(
|
|
@@ -1258,7 +1575,8 @@ export async function completeRemediationProposal(
|
|
|
1258
1575
|
current_commit_sha = COALESCE(?, current_commit_sha),
|
|
1259
1576
|
current_config_digest = COALESCE(?, current_config_digest),
|
|
1260
1577
|
last_seen_at = ?
|
|
1261
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1578
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1579
|
+
AND ${completedGuard}`,
|
|
1262
1580
|
)
|
|
1263
1581
|
.bind(
|
|
1264
1582
|
desired.version ?? null,
|
|
@@ -1268,17 +1586,20 @@ export async function completeRemediationProposal(
|
|
|
1268
1586
|
projectId,
|
|
1269
1587
|
row.environment,
|
|
1270
1588
|
row.target_node_id,
|
|
1589
|
+
...guardBinds,
|
|
1271
1590
|
),
|
|
1272
1591
|
db
|
|
1273
1592
|
.prepare(
|
|
1274
1593
|
`UPDATE investigation_findings
|
|
1275
1594
|
SET recovery_status = 'monitoring',
|
|
1276
1595
|
watchdog_step = 1,
|
|
1596
|
+
consecutive_ok_checks = 0,
|
|
1277
1597
|
baseline_request_rate_per_min = COALESCE(?, baseline_request_rate_per_min),
|
|
1278
1598
|
next_watchdog_check_at = ?,
|
|
1279
1599
|
causal_chain_json = ?,
|
|
1280
1600
|
updated_at = ?
|
|
1281
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1601
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1602
|
+
AND ${completedGuard}`,
|
|
1282
1603
|
)
|
|
1283
1604
|
.bind(
|
|
1284
1605
|
options.baselineRequestRatePerMin ?? null,
|
|
@@ -1288,15 +1609,23 @@ export async function completeRemediationProposal(
|
|
|
1288
1609
|
projectId,
|
|
1289
1610
|
row.environment,
|
|
1290
1611
|
row.finding_id,
|
|
1612
|
+
...guardBinds,
|
|
1291
1613
|
),
|
|
1292
1614
|
db
|
|
1293
1615
|
.prepare(
|
|
1294
1616
|
`UPDATE incident_investigations
|
|
1295
1617
|
SET status = 'verifying', updated_at = ?
|
|
1296
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1618
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1619
|
+
AND ${completedGuard}`,
|
|
1297
1620
|
)
|
|
1298
|
-
.bind(nowIso, projectId, row.environment, row.incident_id),
|
|
1621
|
+
.bind(nowIso, projectId, row.environment, row.incident_id, ...guardBinds),
|
|
1299
1622
|
]);
|
|
1623
|
+
if (results[0]?.meta.changes !== 1) {
|
|
1624
|
+
throw new RemediationGateError(
|
|
1625
|
+
`Proposal ${proposalId} changed state concurrently and can no longer be completed`,
|
|
1626
|
+
409,
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1300
1629
|
|
|
1301
1630
|
const updated = await db
|
|
1302
1631
|
.prepare(
|
|
@@ -1315,50 +1644,82 @@ export async function failRemediationProposal(
|
|
|
1315
1644
|
proposalId: string,
|
|
1316
1645
|
options: {
|
|
1317
1646
|
environment?: string;
|
|
1647
|
+
runnerId?: string;
|
|
1318
1648
|
failureReason: string;
|
|
1319
1649
|
nowIso?: string;
|
|
1320
1650
|
},
|
|
1321
1651
|
): Promise<RemediationProposal> {
|
|
1322
1652
|
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>();
|
|
1653
|
+
const row = await loadProposalRow(
|
|
1654
|
+
db,
|
|
1655
|
+
projectId,
|
|
1656
|
+
proposalId,
|
|
1657
|
+
options.environment,
|
|
1658
|
+
);
|
|
1339
1659
|
|
|
1340
1660
|
if (!row) {
|
|
1341
1661
|
throw new RemediationGateError(`Proposal ${proposalId} not found`, 404);
|
|
1342
1662
|
}
|
|
1663
|
+
if (row.status !== "claimed_executing") {
|
|
1664
|
+
throw new RemediationGateError(
|
|
1665
|
+
`Proposal ${proposalId} cannot be failed from status=${row.status} (must be claimed_executing)`,
|
|
1666
|
+
409,
|
|
1667
|
+
);
|
|
1668
|
+
}
|
|
1669
|
+
if (!options.runnerId || options.runnerId !== row.claimed_by_runner_id) {
|
|
1670
|
+
throw new RemediationGateError(
|
|
1671
|
+
`Proposal ${proposalId} is claimed by a different runner; only the claiming runner may fail it`,
|
|
1672
|
+
409,
|
|
1673
|
+
);
|
|
1674
|
+
}
|
|
1343
1675
|
|
|
1344
|
-
await db.batch([
|
|
1676
|
+
const results = await db.batch([
|
|
1345
1677
|
db
|
|
1346
1678
|
.prepare(
|
|
1347
1679
|
`UPDATE remediation_proposals
|
|
1348
1680
|
SET status = 'failed_execution',
|
|
1349
1681
|
failure_reason = ?,
|
|
1350
1682
|
updated_at = ?
|
|
1351
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1683
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1684
|
+
AND status = 'claimed_executing'
|
|
1685
|
+
AND claimed_by_runner_id = ?`,
|
|
1352
1686
|
)
|
|
1353
|
-
.bind(
|
|
1687
|
+
.bind(
|
|
1688
|
+
options.failureReason,
|
|
1689
|
+
nowIso,
|
|
1690
|
+
projectId,
|
|
1691
|
+
row.environment,
|
|
1692
|
+
row.id,
|
|
1693
|
+
options.runnerId,
|
|
1694
|
+
),
|
|
1354
1695
|
db
|
|
1355
1696
|
.prepare(
|
|
1356
1697
|
`UPDATE incident_investigations
|
|
1357
1698
|
SET status = 'escalated', updated_at = ?
|
|
1358
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1699
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1700
|
+
AND EXISTS (
|
|
1701
|
+
SELECT 1 FROM remediation_proposals g
|
|
1702
|
+
WHERE g.project_id = ? AND g.environment = ? AND g.id = ?
|
|
1703
|
+
AND g.status = 'failed_execution' AND g.updated_at = ?
|
|
1704
|
+
)`,
|
|
1359
1705
|
)
|
|
1360
|
-
.bind(
|
|
1706
|
+
.bind(
|
|
1707
|
+
nowIso,
|
|
1708
|
+
projectId,
|
|
1709
|
+
row.environment,
|
|
1710
|
+
row.incident_id,
|
|
1711
|
+
projectId,
|
|
1712
|
+
row.environment,
|
|
1713
|
+
row.id,
|
|
1714
|
+
nowIso,
|
|
1715
|
+
),
|
|
1361
1716
|
]);
|
|
1717
|
+
if (results[0]?.meta.changes !== 1) {
|
|
1718
|
+
throw new RemediationGateError(
|
|
1719
|
+
`Proposal ${proposalId} changed state concurrently and can no longer be failed`,
|
|
1720
|
+
409,
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
1362
1723
|
|
|
1363
1724
|
const updated = await db
|
|
1364
1725
|
.prepare(
|
|
@@ -1378,8 +1739,19 @@ export interface WatchdogEvaluationSummary {
|
|
|
1378
1739
|
stillDegraded: number;
|
|
1379
1740
|
timedOutClaims: number;
|
|
1380
1741
|
expiredGrants: number;
|
|
1742
|
+
/** Findings whose evaluation threw; logged and retried on the next tick. */
|
|
1743
|
+
failed: number;
|
|
1381
1744
|
}
|
|
1382
1745
|
|
|
1746
|
+
/** Watchdog checkpoints, in minutes after the remediation completed (RFC 0013 § 5.B). */
|
|
1747
|
+
const WATCHDOG_STEP_OFFSET_MINUTES: Record<number, number> = {
|
|
1748
|
+
1: 2,
|
|
1749
|
+
2: 5,
|
|
1750
|
+
3: 15,
|
|
1751
|
+
};
|
|
1752
|
+
/** Consecutive sufficient-traffic healthy checkpoints required for `recovered`. */
|
|
1753
|
+
const REQUIRED_CONSECUTIVE_OK_CHECKS = 2;
|
|
1754
|
+
|
|
1383
1755
|
/**
|
|
1384
1756
|
* Post-Remediation Watchdog & Lease/Claim Timeout Evaluator (RFC 0013 § 5.B, § 6.B & P1 #3).
|
|
1385
1757
|
*
|
|
@@ -1387,16 +1759,28 @@ export interface WatchdogEvaluationSummary {
|
|
|
1387
1759
|
* - `recovery_status`: whether the service recovered under comparable traffic (`N >= minComparableRequests`)
|
|
1388
1760
|
* - `causal_verification_status`: `"operationally_corroborated"` (never promoted to `"counterfactual_verified"`
|
|
1389
1761
|
* by telemetry recovery alone!).
|
|
1762
|
+
*
|
|
1763
|
+
* Checkpoints are scheduled at +2m/+5m/+15m measured from the remediation's
|
|
1764
|
+
* completion time (not from whenever a tick happened to run), and a finding
|
|
1765
|
+
* only becomes `recovered` after two consecutive healthy checkpoints that each
|
|
1766
|
+
* had sufficient traffic. Pass `projectId` to scope the sweep to one tenant
|
|
1767
|
+
* (HTTP path); the cron path omits it and sweeps every project.
|
|
1390
1768
|
*/
|
|
1391
1769
|
export async function evaluateRemediationWatchdogs(
|
|
1392
1770
|
db: SqlDb,
|
|
1393
1771
|
options: {
|
|
1394
1772
|
nowIso?: string;
|
|
1395
1773
|
minComparableRequests?: number;
|
|
1774
|
+
projectId?: string;
|
|
1775
|
+
logger?: Logger;
|
|
1396
1776
|
} = {},
|
|
1397
1777
|
): Promise<WatchdogEvaluationSummary> {
|
|
1778
|
+
const logger = options.logger ?? consoleLogger;
|
|
1398
1779
|
const nowIso = options.nowIso ?? new Date().toISOString();
|
|
1399
1780
|
const minComparableRequests = options.minComparableRequests ?? 50;
|
|
1781
|
+
const projectId = options.projectId;
|
|
1782
|
+
const projectClause = projectId ? " AND project_id = ?" : "";
|
|
1783
|
+
const projectBinds = projectId ? [projectId] : [];
|
|
1400
1784
|
|
|
1401
1785
|
// 1. Expire unclaimed grants past grant_expires_at
|
|
1402
1786
|
const expiredGrantsRes = await db
|
|
@@ -1405,9 +1789,9 @@ export async function evaluateRemediationWatchdogs(
|
|
|
1405
1789
|
SET status = 'expired', grant_token_hash = NULL, updated_at = ?
|
|
1406
1790
|
WHERE status = 'grant_issued'
|
|
1407
1791
|
AND grant_expires_at IS NOT NULL
|
|
1408
|
-
AND grant_expires_at <=
|
|
1792
|
+
AND grant_expires_at <= ?${projectClause}`,
|
|
1409
1793
|
)
|
|
1410
|
-
.bind(nowIso, nowIso)
|
|
1794
|
+
.bind(nowIso, nowIso, ...projectBinds)
|
|
1411
1795
|
.run();
|
|
1412
1796
|
|
|
1413
1797
|
// 2. Time out claimed_executing proposals past claim_expires_at and escalate their incidents
|
|
@@ -1417,9 +1801,9 @@ export async function evaluateRemediationWatchdogs(
|
|
|
1417
1801
|
FROM remediation_proposals
|
|
1418
1802
|
WHERE status = 'claimed_executing'
|
|
1419
1803
|
AND claim_expires_at IS NOT NULL
|
|
1420
|
-
AND claim_expires_at <=
|
|
1804
|
+
AND claim_expires_at <= ?${projectClause}`,
|
|
1421
1805
|
)
|
|
1422
|
-
.bind(nowIso)
|
|
1806
|
+
.bind(nowIso, ...projectBinds)
|
|
1423
1807
|
.all<{
|
|
1424
1808
|
project_id: string;
|
|
1425
1809
|
environment: string;
|
|
@@ -1427,25 +1811,42 @@ export async function evaluateRemediationWatchdogs(
|
|
|
1427
1811
|
incident_id: string;
|
|
1428
1812
|
}>();
|
|
1429
1813
|
|
|
1814
|
+
let timedOutClaims = 0;
|
|
1430
1815
|
for (const t of timedOutRows.results) {
|
|
1431
|
-
await db.batch([
|
|
1816
|
+
const res = await db.batch([
|
|
1432
1817
|
db
|
|
1433
1818
|
.prepare(
|
|
1434
1819
|
`UPDATE remediation_proposals
|
|
1435
1820
|
SET status = 'execution_timed_out',
|
|
1436
1821
|
failure_reason = 'Runner did not complete or fail within 5m claim window',
|
|
1437
1822
|
updated_at = ?
|
|
1438
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1823
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1824
|
+
AND status = 'claimed_executing'`,
|
|
1439
1825
|
)
|
|
1440
1826
|
.bind(nowIso, t.project_id, t.environment, t.id),
|
|
1441
1827
|
db
|
|
1442
1828
|
.prepare(
|
|
1443
1829
|
`UPDATE incident_investigations
|
|
1444
1830
|
SET status = 'escalated', updated_at = ?
|
|
1445
|
-
WHERE project_id = ? AND environment = ? AND id =
|
|
1831
|
+
WHERE project_id = ? AND environment = ? AND id = ?
|
|
1832
|
+
AND EXISTS (
|
|
1833
|
+
SELECT 1 FROM remediation_proposals g
|
|
1834
|
+
WHERE g.project_id = ? AND g.environment = ? AND g.id = ?
|
|
1835
|
+
AND g.status = 'execution_timed_out' AND g.updated_at = ?
|
|
1836
|
+
)`,
|
|
1446
1837
|
)
|
|
1447
|
-
.bind(
|
|
1838
|
+
.bind(
|
|
1839
|
+
nowIso,
|
|
1840
|
+
t.project_id,
|
|
1841
|
+
t.environment,
|
|
1842
|
+
t.incident_id,
|
|
1843
|
+
t.project_id,
|
|
1844
|
+
t.environment,
|
|
1845
|
+
t.id,
|
|
1846
|
+
nowIso,
|
|
1847
|
+
),
|
|
1448
1848
|
]);
|
|
1849
|
+
if (res[0]?.meta.changes === 1) timedOutClaims += 1;
|
|
1449
1850
|
}
|
|
1450
1851
|
|
|
1451
1852
|
// 3. Evaluate findings in `monitoring` where `next_watchdog_check_at <= nowIso`
|
|
@@ -1454,15 +1855,15 @@ export async function evaluateRemediationWatchdogs(
|
|
|
1454
1855
|
`SELECT
|
|
1455
1856
|
project_id, environment, id, incident_id,
|
|
1456
1857
|
fault_locus_entity_id, root_cause_entity_id,
|
|
1457
|
-
watchdog_step, baseline_request_rate_per_min,
|
|
1858
|
+
watchdog_step, consecutive_ok_checks, baseline_request_rate_per_min,
|
|
1458
1859
|
causal_verification_status, updated_at
|
|
1459
1860
|
FROM investigation_findings
|
|
1460
1861
|
WHERE recovery_status = 'monitoring'
|
|
1461
1862
|
AND next_watchdog_check_at IS NOT NULL
|
|
1462
|
-
AND next_watchdog_check_at <=
|
|
1863
|
+
AND next_watchdog_check_at <= ?${projectClause}
|
|
1463
1864
|
LIMIT 50`,
|
|
1464
1865
|
)
|
|
1465
|
-
.bind(nowIso)
|
|
1866
|
+
.bind(nowIso, ...projectBinds)
|
|
1466
1867
|
.all<{
|
|
1467
1868
|
project_id: string;
|
|
1468
1869
|
environment: string;
|
|
@@ -1471,6 +1872,7 @@ export async function evaluateRemediationWatchdogs(
|
|
|
1471
1872
|
fault_locus_entity_id: string;
|
|
1472
1873
|
root_cause_entity_id: string | null;
|
|
1473
1874
|
watchdog_step: number;
|
|
1875
|
+
consecutive_ok_checks: number | null;
|
|
1474
1876
|
baseline_request_rate_per_min: number | null;
|
|
1475
1877
|
causal_verification_status: string;
|
|
1476
1878
|
updated_at: string;
|
|
@@ -1479,141 +1881,193 @@ export async function evaluateRemediationWatchdogs(
|
|
|
1479
1881
|
let recovered = 0;
|
|
1480
1882
|
let inconclusiveLowTraffic = 0;
|
|
1481
1883
|
let stillDegraded = 0;
|
|
1884
|
+
let failed = 0;
|
|
1482
1885
|
|
|
1483
1886
|
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,
|
|
1887
|
+
try {
|
|
1888
|
+
// Find the completed remediation timestamp for this finding
|
|
1889
|
+
const completedProposal = await db
|
|
1890
|
+
.prepare(
|
|
1891
|
+
`SELECT
|
|
1892
|
+
p.updated_at,
|
|
1893
|
+
p.target_node_id,
|
|
1894
|
+
p.resulting_change_id,
|
|
1895
|
+
c.occurred_at AS change_occurred_at
|
|
1896
|
+
FROM remediation_proposals p
|
|
1897
|
+
LEFT JOIN change_events c
|
|
1898
|
+
ON c.project_id = p.project_id
|
|
1899
|
+
AND c.environment = p.environment
|
|
1900
|
+
AND c.id = p.resulting_change_id
|
|
1901
|
+
WHERE p.project_id = ? AND p.environment = ? AND p.finding_id = ? AND p.status = 'completed'
|
|
1902
|
+
ORDER BY p.updated_at DESC
|
|
1903
|
+
LIMIT 1`,
|
|
1904
|
+
)
|
|
1905
|
+
.bind(f.project_id, f.environment, f.id)
|
|
1906
|
+
.first<{
|
|
1907
|
+
updated_at: string;
|
|
1908
|
+
target_node_id: string;
|
|
1909
|
+
resulting_change_id: string | null;
|
|
1910
|
+
change_occurred_at: string | null;
|
|
1911
|
+
}>();
|
|
1912
|
+
|
|
1913
|
+
// Traffic is measured since the remediation change took effect; the
|
|
1914
|
+
// checkpoint schedule is anchored on when the remediation completed.
|
|
1915
|
+
const sinceTs =
|
|
1916
|
+
completedProposal?.change_occurred_at ??
|
|
1917
|
+
completedProposal?.updated_at ??
|
|
1918
|
+
f.updated_at;
|
|
1919
|
+
const completedAtMs = Date.parse(
|
|
1920
|
+
completedProposal?.updated_at ?? f.updated_at,
|
|
1921
|
+
);
|
|
1922
|
+
const nextCheckFor = (step: number): string =>
|
|
1923
|
+
new Date(
|
|
1924
|
+
completedAtMs + (WATCHDOG_STEP_OFFSET_MINUTES[step] ?? 15) * 60_000,
|
|
1566
1925
|
).toISOString();
|
|
1926
|
+
const targetNodeId =
|
|
1927
|
+
completedProposal?.target_node_id ?? f.fault_locus_entity_id;
|
|
1928
|
+
|
|
1929
|
+
// Extract service name from canonical service:{env}:{ns}:{serviceName} if applicable
|
|
1930
|
+
const parts = targetNodeId.split(":");
|
|
1931
|
+
const serviceName =
|
|
1932
|
+
parts[0] === "service" && parts.length >= 4
|
|
1933
|
+
? parts.slice(3).join(":")
|
|
1934
|
+
: null;
|
|
1935
|
+
|
|
1936
|
+
const stats = serviceName
|
|
1937
|
+
? await db
|
|
1938
|
+
.prepare(
|
|
1939
|
+
`SELECT
|
|
1940
|
+
COUNT(*) AS total_requests,
|
|
1941
|
+
SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS error_requests
|
|
1942
|
+
FROM telemetry_spans
|
|
1943
|
+
WHERE project_id = ?
|
|
1944
|
+
AND environment = ?
|
|
1945
|
+
AND service_name = ?
|
|
1946
|
+
AND (parent_span_id IS NULL OR span_kind = 1)
|
|
1947
|
+
AND (start_time >= ? OR received_at >= ?)`,
|
|
1948
|
+
)
|
|
1949
|
+
.bind(f.project_id, f.environment, serviceName, sinceTs, sinceTs)
|
|
1950
|
+
.first<{ total_requests: number; error_requests: number }>()
|
|
1951
|
+
: await db
|
|
1952
|
+
.prepare(
|
|
1953
|
+
`SELECT
|
|
1954
|
+
COUNT(*) AS total_requests,
|
|
1955
|
+
SUM(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) AS error_requests
|
|
1956
|
+
FROM telemetry_spans
|
|
1957
|
+
WHERE project_id = ?
|
|
1958
|
+
AND environment = ?
|
|
1959
|
+
AND (parent_span_id IS NULL OR span_kind = 1)
|
|
1960
|
+
AND (start_time >= ? OR received_at >= ?)`,
|
|
1961
|
+
)
|
|
1962
|
+
.bind(f.project_id, f.environment, sinceTs, sinceTs)
|
|
1963
|
+
.first<{ total_requests: number; error_requests: number }>();
|
|
1964
|
+
|
|
1965
|
+
const totalReq = Number(stats?.total_requests ?? 0);
|
|
1966
|
+
const errorReq = Number(stats?.error_requests ?? 0);
|
|
1967
|
+
const errorRate = totalReq > 0 ? errorReq / totalReq : 0;
|
|
1968
|
+
|
|
1969
|
+
const requiredTraffic = Math.max(
|
|
1970
|
+
minComparableRequests,
|
|
1971
|
+
f.baseline_request_rate_per_min
|
|
1972
|
+
? Math.floor(0.5 * f.baseline_request_rate_per_min * 5)
|
|
1973
|
+
: minComparableRequests,
|
|
1974
|
+
);
|
|
1975
|
+
|
|
1976
|
+
if (totalReq < requiredTraffic) {
|
|
1977
|
+
// Insufficient traffic breaks the consecutive-ok streak.
|
|
1978
|
+
if (f.watchdog_step < 3) {
|
|
1979
|
+
const nextStep = f.watchdog_step + 1;
|
|
1980
|
+
await db
|
|
1981
|
+
.prepare(
|
|
1982
|
+
`UPDATE investigation_findings
|
|
1983
|
+
SET watchdog_step = ?, consecutive_ok_checks = 0,
|
|
1984
|
+
next_watchdog_check_at = ?, updated_at = ?
|
|
1985
|
+
WHERE project_id = ? AND environment = ? AND id = ?`,
|
|
1986
|
+
)
|
|
1987
|
+
.bind(
|
|
1988
|
+
nextStep,
|
|
1989
|
+
nextCheckFor(nextStep),
|
|
1990
|
+
nowIso,
|
|
1991
|
+
f.project_id,
|
|
1992
|
+
f.environment,
|
|
1993
|
+
f.id,
|
|
1994
|
+
)
|
|
1995
|
+
.run();
|
|
1996
|
+
} else {
|
|
1997
|
+
// Step 3 (+15m) with insufficient comparable traffic -> inconclusive_low_traffic
|
|
1998
|
+
inconclusiveLowTraffic += 1;
|
|
1999
|
+
await db
|
|
2000
|
+
.prepare(
|
|
2001
|
+
`UPDATE investigation_findings
|
|
2002
|
+
SET recovery_status = 'inconclusive_low_traffic',
|
|
2003
|
+
consecutive_ok_checks = 0,
|
|
2004
|
+
next_watchdog_check_at = NULL,
|
|
2005
|
+
updated_at = ?
|
|
2006
|
+
WHERE project_id = ? AND environment = ? AND id = ?`,
|
|
2007
|
+
)
|
|
2008
|
+
.bind(nowIso, f.project_id, f.environment, f.id)
|
|
2009
|
+
.run();
|
|
2010
|
+
}
|
|
2011
|
+
continue;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
if (errorRate >= 0.05) {
|
|
2015
|
+
stillDegraded += 1;
|
|
1567
2016
|
await db
|
|
1568
2017
|
.prepare(
|
|
1569
2018
|
`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',
|
|
2019
|
+
SET recovery_status = 'still_degraded',
|
|
2020
|
+
causal_verification_status = 'refuted',
|
|
2021
|
+
consecutive_ok_checks = 0,
|
|
1582
2022
|
next_watchdog_check_at = NULL,
|
|
1583
2023
|
updated_at = ?
|
|
1584
2024
|
WHERE project_id = ? AND environment = ? AND id = ?`,
|
|
1585
2025
|
)
|
|
1586
2026
|
.bind(nowIso, f.project_id, f.environment, f.id)
|
|
1587
2027
|
.run();
|
|
2028
|
+
if (f.incident_id) {
|
|
2029
|
+
await db
|
|
2030
|
+
.prepare(
|
|
2031
|
+
`UPDATE incident_investigations
|
|
2032
|
+
SET status = 'escalated', updated_at = ?
|
|
2033
|
+
WHERE project_id = ? AND environment = ? AND id = ?`,
|
|
2034
|
+
)
|
|
2035
|
+
.bind(nowIso, f.project_id, f.environment, f.incident_id)
|
|
2036
|
+
.run();
|
|
2037
|
+
}
|
|
2038
|
+
continue;
|
|
1588
2039
|
}
|
|
1589
|
-
continue;
|
|
1590
|
-
}
|
|
1591
2040
|
|
|
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) {
|
|
2041
|
+
// Traffic is sufficient AND error rate is healthy (<5%) at this checkpoint.
|
|
2042
|
+
const okChecks = Number(f.consecutive_ok_checks ?? 0) + 1;
|
|
2043
|
+
if (okChecks < REQUIRED_CONSECUTIVE_OK_CHECKS) {
|
|
2044
|
+
// Need one more healthy checkpoint. Advance along the schedule; past
|
|
2045
|
+
// the last step, re-check shortly after now.
|
|
2046
|
+
const nextStep = Math.min(3, f.watchdog_step + 1);
|
|
2047
|
+
const nextCheck =
|
|
2048
|
+
f.watchdog_step < 3
|
|
2049
|
+
? nextCheckFor(nextStep)
|
|
2050
|
+
: new Date(Date.parse(nowIso) + 5 * 60_000).toISOString();
|
|
1606
2051
|
await db
|
|
1607
2052
|
.prepare(
|
|
1608
|
-
`UPDATE
|
|
1609
|
-
SET
|
|
2053
|
+
`UPDATE investigation_findings
|
|
2054
|
+
SET watchdog_step = ?, consecutive_ok_checks = ?,
|
|
2055
|
+
next_watchdog_check_at = ?, updated_at = ?
|
|
1610
2056
|
WHERE project_id = ? AND environment = ? AND id = ?`,
|
|
1611
2057
|
)
|
|
1612
|
-
.bind(
|
|
2058
|
+
.bind(
|
|
2059
|
+
nextStep,
|
|
2060
|
+
okChecks,
|
|
2061
|
+
nextCheck,
|
|
2062
|
+
nowIso,
|
|
2063
|
+
f.project_id,
|
|
2064
|
+
f.environment,
|
|
2065
|
+
f.id,
|
|
2066
|
+
)
|
|
1613
2067
|
.run();
|
|
2068
|
+
continue;
|
|
1614
2069
|
}
|
|
1615
|
-
|
|
1616
|
-
// Traffic is sufficient AND error rate recovered (<5%)
|
|
2070
|
+
|
|
1617
2071
|
recovered += 1;
|
|
1618
2072
|
const nextCausalVerification =
|
|
1619
2073
|
f.causal_verification_status === "counterfactual_verified"
|
|
@@ -1626,12 +2080,14 @@ export async function evaluateRemediationWatchdogs(
|
|
|
1626
2080
|
SET recovery_status = 'recovered',
|
|
1627
2081
|
causal_verification_status = ?,
|
|
1628
2082
|
watchdog_step = 3,
|
|
2083
|
+
consecutive_ok_checks = ?,
|
|
1629
2084
|
next_watchdog_check_at = NULL,
|
|
1630
2085
|
updated_at = ?
|
|
1631
2086
|
WHERE project_id = ? AND environment = ? AND id = ?`,
|
|
1632
2087
|
)
|
|
1633
2088
|
.bind(
|
|
1634
2089
|
nextCausalVerification,
|
|
2090
|
+
okChecks,
|
|
1635
2091
|
nowIso,
|
|
1636
2092
|
f.project_id,
|
|
1637
2093
|
f.environment,
|
|
@@ -1649,6 +2105,13 @@ export async function evaluateRemediationWatchdogs(
|
|
|
1649
2105
|
.bind(nowIso, nowIso, f.project_id, f.environment, f.incident_id)
|
|
1650
2106
|
.run();
|
|
1651
2107
|
}
|
|
2108
|
+
} catch (err) {
|
|
2109
|
+
failed += 1;
|
|
2110
|
+
logger.error("[autosre] watchdog evaluation failed for finding", {
|
|
2111
|
+
findingId: f.id,
|
|
2112
|
+
projectId: f.project_id,
|
|
2113
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2114
|
+
});
|
|
1652
2115
|
}
|
|
1653
2116
|
}
|
|
1654
2117
|
|
|
@@ -1657,7 +2120,8 @@ export async function evaluateRemediationWatchdogs(
|
|
|
1657
2120
|
recovered,
|
|
1658
2121
|
inconclusiveLowTraffic,
|
|
1659
2122
|
stillDegraded,
|
|
1660
|
-
timedOutClaims
|
|
2123
|
+
timedOutClaims,
|
|
1661
2124
|
expiredGrants: expiredGrantsRes.meta.changes,
|
|
2125
|
+
failed,
|
|
1662
2126
|
};
|
|
1663
2127
|
}
|