sre-agent-cli 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,153 @@
1
+ """The public-safe hosted demo server (Phase 10).
2
+
3
+ In plain words, this is the one server build that is meant to be
4
+ put on a public address later. It is the shipped demo server with
5
+ every public-safety rule switched on at once, in one place, so a
6
+ deployment cannot quietly forget one of them:
7
+
8
+ - **A token is required.** The app is not built at all without
9
+ one: ``create_hosted_demo_app`` refuses an empty token instead
10
+ of falling back to the unauthenticated local mode the shipped
11
+ app allows. With a token set, every route except ``/health``
12
+ and the landing page at ``/`` requires it, exactly as in
13
+ ``server.py`` (finding F-01). The landing page is open for the
14
+ same reason ``/health`` is: it serves no data and runs no
15
+ action — it only explains the demo and prints the visitor
16
+ token, which is public on purpose.
17
+ - **Seeded demo data only.** Diagnosis reads the seeded demo tool
18
+ layer, the seeded demo read adapters and the seeded demo
19
+ memory, and actions run against the seeded demo workloads.
20
+ No real system is read or touched, and no real credential is
21
+ used anywhere in the stack: the only secret is the shared
22
+ token itself, supplied by whoever hosts it.
23
+ - **The Tier 3 demo grant is the only action that can run.**
24
+ The policy is ``tier3.demo_tier3_policy`` — restart and scale,
25
+ in the ``shop`` namespace only, at most three replicas touched
26
+ per action — and the server is built ``tier3_only=True``, so
27
+ there is no approval path: a fix the grant does not pre-approve
28
+ (rollback is the example) is refused and can never run here.
29
+ - **The ingestion caps stay on.** The store is the ordinary
30
+ incident store, so the F-02 caps apply unchanged: at most 100
31
+ alerts per Alertmanager payload, capped labels and summaries,
32
+ at most 200 evidence records per incident and 1,000 incidents
33
+ held, with a clear refusal past a cap.
34
+
35
+ Run it locally the same way it will run when hosted, with the
36
+ token in the environment:
37
+
38
+ SRE_AGENT_API_TOKEN=<a token you choose> \
39
+ uvicorn --factory sre_agent.hosted_demo:create_hosted_demo_app \
40
+ --host 0.0.0.0 --port 8080
41
+
42
+ Honest scope: this module is the public-safe *mode*. The
43
+ deployment it was built for is live (Render free plan,
44
+ https://sre-agent-demo.onrender.com, deployed 2026-11-12 with
45
+ the host and cost named first, as the plan required); the mode
46
+ itself claims nothing beyond what is written here. See
47
+ ``docs/hosted-demo.md``.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import os
53
+
54
+ from fastapi import FastAPI
55
+ from fastapi.responses import HTMLResponse
56
+
57
+ from .adapters import demo_adapter_sources
58
+ from .memory import demo_incident_memory
59
+ from .runbooks import demo_runbooks
60
+ from .server import ENV_API_TOKEN, create_app
61
+ from .tier3 import demo_tier3_policy
62
+ from .tools import demo_tool_layer
63
+
64
+ LANDING_PAGE = """<!DOCTYPE html>
65
+ <html lang="en">
66
+ <head>
67
+ <meta charset="utf-8">
68
+ <title>sre-agent — hosted demo</title>
69
+ <style>
70
+ body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 44rem; padding: 0 1rem; line-height: 1.5; }
71
+ code, pre { background: #f2f2f2; padding: 0.1rem 0.3rem; border-radius: 4px; }
72
+ pre { padding: 0.75rem; overflow-x: auto; }
73
+ </style>
74
+ </head>
75
+ <body>
76
+ <h1>sre-agent — hosted demo</h1>
77
+ <p>An autonomous SRE agent that detects production incidents, works out
78
+ what is wrong using real evidence, and helps fix them — without ever
79
+ being allowed to make things worse on its own. This is its public demo:
80
+ seeded demo data only, nothing real is read or touched.</p>
81
+
82
+ <h2>The visitor token</h2>
83
+ <p>Every API route needs the token in the <code>X-SRE-Agent-Token</code>
84
+ header. The demo token is public on purpose:</p>
85
+ <pre>X-SRE-Agent-Token: sre-agent-demo-visitor</pre>
86
+
87
+ <h2>Try it</h2>
88
+ <p>Send the demo checkout alert, and read the diagnosis it opens:</p>
89
+ <pre>curl -X POST https://sre-agent-demo.onrender.com/webhooks/generic \
90
+ -H 'Content-Type: application/json' \
91
+ -H 'X-SRE-Agent-Token: sre-agent-demo-visitor' \
92
+ -d '{"alert_name":"HighErrorRate","service":"checkout","namespace":"shop","environment":"demo","summary":"Checkout errors are above 10%"}'
93
+
94
+ curl https://sre-agent-demo.onrender.com/incidents \
95
+ -H 'X-SRE-Agent-Token: sre-agent-demo-visitor'</pre>
96
+ <p>Propose the restart for the incident and it runs at once — the
97
+ demo's Tier 3 grant pre-approves it. Send the same alert again with
98
+ <code>"status": "resolved"</code> to close the incident.</p>
99
+
100
+ <h2>What this demo cannot do</h2>
101
+ <ul>
102
+ <li>It cannot touch anything real. Every number, workload and past
103
+ incident is seeded demo data.</li>
104
+ <li>It cannot run a fix outside the Tier 3 demo grant (restart and
105
+ scale, shop namespace only, at most three replicas). Rollback always
106
+ needs a human, and hosted mode has no approval path at all — that is
107
+ deliberate.</li>
108
+ <li>It sleeps. On the free hosting plan the service spins down after
109
+ about 15 minutes idle, so the first request can take a minute.</li>
110
+ </ul>
111
+ <p>Source, scores and the full record:
112
+ <a href="https://github.com/KakoozaJerry/sre-agent">github.com/KakoozaJerry/sre-agent</a></p>
113
+ </body>
114
+ </html>
115
+ """
116
+
117
+
118
+ def create_hosted_demo_app(auth_token: str | None = None) -> FastAPI:
119
+ """Build the public-safe hosted demo server, or refuse.
120
+
121
+ ``auth_token`` wins when given; otherwise the token is read
122
+ from the ``SRE_AGENT_API_TOKEN`` environment variable, the
123
+ same variable the server and the CLI already use. An empty
124
+ or missing token raises ``ValueError``: a hosted demo with
125
+ no token would be an unauthenticated server on a public
126
+ address, and that build is refused here rather than warned
127
+ about in a guide a deployer might not read.
128
+ """
129
+ token = auth_token
130
+ if token is None:
131
+ token = os.environ.get(ENV_API_TOKEN) or None
132
+ if not token:
133
+ raise ValueError(
134
+ "the hosted demo requires an API token: pass auth_token "
135
+ f"or set {ENV_API_TOKEN}; refusing to build an "
136
+ "unauthenticated server for a public address"
137
+ )
138
+ _, runbook_backend = demo_runbooks()
139
+ app = create_app(
140
+ auth_token=token,
141
+ tool_layer=demo_tool_layer(),
142
+ adapters=demo_adapter_sources(),
143
+ memory=demo_incident_memory(),
144
+ approval_policy=demo_tier3_policy(),
145
+ runbook_backend=runbook_backend,
146
+ tier3_only=True,
147
+ )
148
+
149
+ @app.get("/", response_class=HTMLResponse)
150
+ def landing() -> str:
151
+ return LANDING_PAGE
152
+
153
+ return app
sre_agent/ingestion.py ADDED
@@ -0,0 +1,423 @@
1
+ """Alert ingestion: turn webhook payloads into incidents.
2
+
3
+ Two webhook shapes are understood:
4
+
5
+ - **Alertmanager.** The payload Prometheus Alertmanager sends: a list of
6
+ alerts with labels, annotations, a start time and a fingerprint.
7
+ - **Generic.** One simple alert as a small JSON object, for tools that
8
+ do not speak Alertmanager's shape.
9
+
10
+ What happens to an alert, in plain words:
11
+
12
+ - **Dedupe.** The same alert (same fingerprint) firing again while its
13
+ incident is still open is a copy. A copy never opens a second
14
+ incident; it is counted on the incident it belongs to.
15
+ - **Grouping.** A different alert (a different fingerprint) for the
16
+ same target and the same alert name, while an incident for them is
17
+ still open, belongs to that incident. It is added as evidence.
18
+ - **Resolving.** When a "resolved" alert arrives for an open incident,
19
+ the incident is resolved and its group closes, so the next firing
20
+ opens a fresh incident.
21
+
22
+ Alert text is data, never instructions. Labels and annotations are
23
+ stored as evidence only; nothing in them is ever run or obeyed. See
24
+ docs/safety-model.md for why that matters.
25
+
26
+ Intake limits (security finding F-02, fixed after v0.1.0): every
27
+ payload is bounded before it can grow the store — at most
28
+ ``MAX_ALERTS_PER_PAYLOAD`` alerts in one Alertmanager payload, at
29
+ most ``MAX_METADATA_ENTRIES`` labels or annotations on one alert,
30
+ each label or annotation key and value length-capped, and short
31
+ fields (names, summaries) length-capped too. The store itself is
32
+ bounded: at most ``MAX_INCIDENTS`` incidents held, and at most
33
+ ``MAX_EVIDENCE_PER_INCIDENT`` evidence records on one incident.
34
+ Past a store cap, ingestion raises :class:`IngestionLimitError`
35
+ and the server answers with a clear error instead of accepting
36
+ the work. The caps bound how much memory a flood — hostile, or a
37
+ broken sender retrying — can make the server hold. A per-sender
38
+ rate limit was not added; that is stated plainly in the review.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import hashlib
44
+ import json
45
+ from datetime import datetime
46
+ from typing import TYPE_CHECKING
47
+
48
+ from pydantic import BaseModel, Field, field_validator
49
+
50
+ from .logging_config import get_logger
51
+ from .models import Evidence, Incident, IncidentStatus, Target
52
+
53
+ if TYPE_CHECKING:
54
+ from .approvals import PendingAction
55
+
56
+ log = get_logger(__name__)
57
+
58
+ FIRING = "firing"
59
+ RESOLVED = "resolved"
60
+
61
+ # Intake limits (F-02). Plain numbers, named once, here.
62
+ MAX_ALERTS_PER_PAYLOAD = 100
63
+ MAX_METADATA_ENTRIES = 50
64
+ MAX_METADATA_KEY_LENGTH = 100
65
+ MAX_METADATA_VALUE_LENGTH = 1000
66
+ MAX_SHORT_TEXT_LENGTH = 200
67
+ MAX_SUMMARY_LENGTH = 1000
68
+ MAX_INCIDENTS = 1000
69
+ MAX_EVIDENCE_PER_INCIDENT = 200
70
+
71
+
72
+ class IngestionLimitError(Exception):
73
+ """A store intake cap was reached; the alert was not accepted."""
74
+
75
+
76
+ def _check_metadata(values: dict[str, str]) -> dict[str, str]:
77
+ """Bound one labels/annotations dict: count, key and value size."""
78
+ if len(values) > MAX_METADATA_ENTRIES:
79
+ raise ValueError(
80
+ f"too many labels/annotations: {len(values)} > {MAX_METADATA_ENTRIES}"
81
+ )
82
+ for key, value in values.items():
83
+ if len(key) > MAX_METADATA_KEY_LENGTH:
84
+ raise ValueError("a label/annotation key is too long")
85
+ if len(value) > MAX_METADATA_VALUE_LENGTH:
86
+ raise ValueError("a label/annotation value is too long")
87
+ return values
88
+
89
+
90
+ class IncomingAlert(BaseModel):
91
+ """One alert, normalised into the same shape whatever sent it."""
92
+
93
+ fingerprint: str
94
+ alert_name: str
95
+ title: str
96
+ target: Target
97
+ severity: str = "unknown"
98
+ status: str = FIRING
99
+ source: str # "alertmanager" or "generic"
100
+ labels: dict[str, str] = Field(default_factory=dict)
101
+ annotations: dict[str, str] = Field(default_factory=dict)
102
+ starts_at: datetime | None = None
103
+
104
+ @property
105
+ def group_key(self) -> tuple[str, str, str, str]:
106
+ """Alerts that share a target and a name belong to one incident."""
107
+ return (
108
+ self.target.environment,
109
+ self.target.namespace,
110
+ self.target.service,
111
+ self.alert_name,
112
+ )
113
+
114
+
115
+ class AlertmanagerAlert(BaseModel):
116
+ """One alert inside an Alertmanager payload."""
117
+
118
+ model_config = {"populate_by_name": True}
119
+
120
+ status: str = Field(default=FIRING, max_length=MAX_SHORT_TEXT_LENGTH)
121
+ labels: dict[str, str] = Field(default_factory=dict)
122
+ annotations: dict[str, str] = Field(default_factory=dict)
123
+ starts_at: datetime | None = Field(default=None, alias="startsAt")
124
+ fingerprint: str = Field(default="", max_length=MAX_SHORT_TEXT_LENGTH)
125
+
126
+ @field_validator("labels", "annotations")
127
+ @classmethod
128
+ def _metadata_is_bounded(cls, v: dict[str, str]) -> dict[str, str]:
129
+ return _check_metadata(v)
130
+
131
+
132
+ class AlertmanagerPayload(BaseModel):
133
+ """The webhook body Alertmanager sends."""
134
+
135
+ model_config = {"populate_by_name": True}
136
+
137
+ status: str = Field(default=FIRING, max_length=MAX_SHORT_TEXT_LENGTH)
138
+ common_labels: dict[str, str] = Field(default_factory=dict, alias="commonLabels")
139
+ common_annotations: dict[str, str] = Field(
140
+ default_factory=dict, alias="commonAnnotations"
141
+ )
142
+ alerts: list[AlertmanagerAlert] = Field(
143
+ default_factory=list, max_length=MAX_ALERTS_PER_PAYLOAD
144
+ )
145
+
146
+ @field_validator("common_labels", "common_annotations")
147
+ @classmethod
148
+ def _common_metadata_is_bounded(cls, v: dict[str, str]) -> dict[str, str]:
149
+ return _check_metadata(v)
150
+
151
+
152
+ class GenericAlertPayload(BaseModel):
153
+ """The simple webhook body: one alert, plain fields."""
154
+
155
+ alert_name: str = Field(max_length=MAX_SHORT_TEXT_LENGTH)
156
+ service: str = Field(max_length=MAX_SHORT_TEXT_LENGTH)
157
+ namespace: str = Field(default="default", max_length=MAX_SHORT_TEXT_LENGTH)
158
+ environment: str = Field(default="demo", max_length=MAX_SHORT_TEXT_LENGTH)
159
+ severity: str = Field(default="unknown", max_length=MAX_SHORT_TEXT_LENGTH)
160
+ status: str = Field(default=FIRING, max_length=MAX_SHORT_TEXT_LENGTH)
161
+ fingerprint: str = Field(default="", max_length=MAX_SHORT_TEXT_LENGTH)
162
+ summary: str = Field(default="", max_length=MAX_SUMMARY_LENGTH)
163
+ labels: dict[str, str] = Field(default_factory=dict)
164
+ annotations: dict[str, str] = Field(default_factory=dict)
165
+ starts_at: datetime | None = None
166
+
167
+ @field_validator("labels", "annotations")
168
+ @classmethod
169
+ def _metadata_is_bounded(cls, v: dict[str, str]) -> dict[str, str]:
170
+ return _check_metadata(v)
171
+
172
+
173
+ def fingerprint_for_labels(labels: dict[str, str]) -> str:
174
+ """Make a stable fingerprint from labels when the sender gave none.
175
+
176
+ The same labels always make the same fingerprint, so a repeated
177
+ alert is recognised as a copy.
178
+ """
179
+ canonical = json.dumps(labels, sort_keys=True, separators=(",", ":"))
180
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
181
+
182
+
183
+ def _title_for(alert_name: str, service: str, annotations: dict[str, str]) -> str:
184
+ return (
185
+ annotations.get("summary")
186
+ or annotations.get("description")
187
+ or f"{alert_name} on {service}"
188
+ )
189
+
190
+
191
+ def normalize_alertmanager(
192
+ payload: AlertmanagerPayload, default_environment: str = "demo"
193
+ ) -> list[IncomingAlert]:
194
+ """Turn an Alertmanager payload into a list of incoming alerts."""
195
+ incoming: list[IncomingAlert] = []
196
+ for alert in payload.alerts:
197
+ labels = {**payload.common_labels, **alert.labels}
198
+ annotations = {**payload.common_annotations, **alert.annotations}
199
+ alert_name = labels.get("alertname", "unknown")
200
+ service = labels.get("service") or labels.get("job") or "unknown"
201
+ target = Target(
202
+ environment=labels.get("environment", default_environment),
203
+ namespace=labels.get("namespace", "default"),
204
+ service=service,
205
+ )
206
+ incoming.append(
207
+ IncomingAlert(
208
+ fingerprint=alert.fingerprint or fingerprint_for_labels(labels),
209
+ alert_name=alert_name,
210
+ title=_title_for(alert_name, service, annotations),
211
+ target=target,
212
+ severity=labels.get("severity", "unknown"),
213
+ status=alert.status or payload.status,
214
+ source="alertmanager",
215
+ labels=labels,
216
+ annotations=annotations,
217
+ starts_at=alert.starts_at,
218
+ )
219
+ )
220
+ return incoming
221
+
222
+
223
+ def normalize_generic(payload: GenericAlertPayload) -> IncomingAlert:
224
+ """Turn a generic webhook body into one incoming alert."""
225
+ labels = {
226
+ "alertname": payload.alert_name,
227
+ "service": payload.service,
228
+ "namespace": payload.namespace,
229
+ "environment": payload.environment,
230
+ "severity": payload.severity,
231
+ **payload.labels,
232
+ }
233
+ annotations = dict(payload.annotations)
234
+ if payload.summary and "summary" not in annotations:
235
+ annotations["summary"] = payload.summary
236
+ return IncomingAlert(
237
+ fingerprint=payload.fingerprint or fingerprint_for_labels(labels),
238
+ alert_name=payload.alert_name,
239
+ title=_title_for(payload.alert_name, payload.service, annotations),
240
+ target=Target(
241
+ environment=payload.environment,
242
+ namespace=payload.namespace,
243
+ service=payload.service,
244
+ ),
245
+ severity=payload.severity,
246
+ status=payload.status,
247
+ source="generic",
248
+ labels=labels,
249
+ annotations=annotations,
250
+ starts_at=payload.starts_at,
251
+ )
252
+
253
+
254
+ class IngestResult(BaseModel):
255
+ """What ingestion did with one alert."""
256
+
257
+ incident: Incident | None = None
258
+ created: bool = False
259
+ duplicate: bool = False
260
+ grouped: bool = False
261
+ resolved: bool = False
262
+
263
+
264
+ class IncidentStore:
265
+ """Keeps incidents in memory and decides what each new alert means.
266
+
267
+ This is the small store interface the plan talks about: webhooks
268
+ and (later) tools talk to this class, so swapping the memory here
269
+ for SQLite later does not change the webhook shapes. That swap
270
+ now exists: ``storage.SQLiteIncidentStore`` keeps the same
271
+ interface and adds persistence behind it (see ``storage.py``).
272
+
273
+ The interface now also carries the two hooks a persistent store
274
+ needs and an in-memory store can answer trivially:
275
+
276
+ - ``save(incident)`` — an incident changed outside ingestion
277
+ (a diagnosis was written on it, a fix ran on it): keep the
278
+ change. In memory the record is already the object held, so
279
+ this only makes sure it is in the store; a database store
280
+ writes it through.
281
+ - ``load_approvals()`` / ``save_approval(pending, decision)`` —
282
+ the server's pending approvals, so a database-backed server
283
+ can restore them after a restart. In memory there is nothing
284
+ extra to load or write: the server's own dicts are the record,
285
+ and they are lost on restart, exactly as before.
286
+ """
287
+
288
+ def __init__(
289
+ self,
290
+ max_incidents: int = MAX_INCIDENTS,
291
+ max_evidence_per_incident: int = MAX_EVIDENCE_PER_INCIDENT,
292
+ ) -> None:
293
+ self._max_incidents = max_incidents
294
+ self._max_evidence_per_incident = max_evidence_per_incident
295
+ self._incidents: dict[str, Incident] = {}
296
+ self._active_by_group: dict[tuple[str, str, str, str], str] = {}
297
+ self._incident_by_fingerprint: dict[str, str] = {}
298
+ self._duplicate_counts: dict[str, int] = {}
299
+
300
+ def list_incidents(self) -> list[Incident]:
301
+ return list(self._incidents.values())
302
+
303
+ def get(self, incident_id: str) -> Incident | None:
304
+ return self._incidents.get(incident_id)
305
+
306
+ def duplicate_count(self, incident_id: str) -> int:
307
+ return self._duplicate_counts.get(incident_id, 0)
308
+
309
+ def save(self, incident: Incident) -> None:
310
+ """Keep a change made to *incident* outside ingestion.
311
+
312
+ In memory this is almost a no-op — the store already holds
313
+ this exact object — but it is the hook a persistent store
314
+ overrides to write the change through, and callers (the
315
+ server, after a diagnosis or a run) must call it rather
316
+ than assume their mutation was kept.
317
+ """
318
+ self._incidents[incident.id] = incident
319
+
320
+ def load_approvals(
321
+ self,
322
+ ) -> tuple[dict[str, PendingAction], dict[str, str]]:
323
+ """Pending approvals a previous run of the server left behind.
324
+
325
+ An in-memory store kept none across a restart, so there is
326
+ nothing to restore.
327
+ """
328
+ return {}, {}
329
+
330
+ def save_approval(
331
+ self, pending: PendingAction, decision: str | None
332
+ ) -> None:
333
+ """Keep one pending approval (and its decision, if decided).
334
+
335
+ In memory the server's own dicts are the record, so there
336
+ is nothing extra to write; a database store persists the
337
+ pending action so a restart restores it.
338
+ """
339
+
340
+ def ingest(self, alert: IncomingAlert) -> IngestResult:
341
+ if alert.status == RESOLVED:
342
+ return self._resolve(alert)
343
+ return self._fire(alert)
344
+
345
+ def _fire(self, alert: IncomingAlert) -> IngestResult:
346
+ known_id = self._incident_by_fingerprint.get(alert.fingerprint)
347
+ if known_id is not None and known_id in self._incidents:
348
+ incident = self._incidents[known_id]
349
+ if incident.status is not IncidentStatus.RESOLVED:
350
+ self._duplicate_counts[incident.id] = (
351
+ self._duplicate_counts.get(incident.id, 0) + 1
352
+ )
353
+ log.info(
354
+ "duplicate alert counted for incident %s (fingerprint %s)",
355
+ incident.id,
356
+ alert.fingerprint,
357
+ )
358
+ return IngestResult(incident=incident, duplicate=True)
359
+
360
+ grouped_id = self._active_by_group.get(alert.group_key)
361
+ if grouped_id is not None:
362
+ incident = self._incidents[grouped_id]
363
+ if len(incident.evidence) >= self._max_evidence_per_incident:
364
+ raise IngestionLimitError(
365
+ f"incident {incident.id} already holds "
366
+ f"{self._max_evidence_per_incident} evidence records; "
367
+ "grouped alert refused"
368
+ )
369
+ incident.add_evidence(self._evidence_for(alert))
370
+ self._incident_by_fingerprint[alert.fingerprint] = incident.id
371
+ log.info("alert grouped into incident %s", incident.id)
372
+ return IngestResult(incident=incident, grouped=True)
373
+
374
+ if len(self._incidents) >= self._max_incidents:
375
+ raise IngestionLimitError(
376
+ f"incident store is full ({self._max_incidents} incidents); "
377
+ "new incident refused"
378
+ )
379
+ incident = Incident(title=alert.title, target=alert.target)
380
+ # Ingestion hands the incident straight to triage: the agent
381
+ # has started looking at it. This also makes a later
382
+ # "resolved" alert a legal closing move.
383
+ incident.transition(IncidentStatus.TRIAGING)
384
+ incident.add_evidence(self._evidence_for(alert))
385
+ self._incidents[incident.id] = incident
386
+ self._active_by_group[alert.group_key] = incident.id
387
+ self._incident_by_fingerprint[alert.fingerprint] = incident.id
388
+ log.info("incident %s opened from %s alert", incident.id, alert.source)
389
+ return IngestResult(incident=incident, created=True)
390
+
391
+ def _resolve(self, alert: IncomingAlert) -> IngestResult:
392
+ incident_id = self._active_by_group.get(alert.group_key)
393
+ if incident_id is None:
394
+ incident_id = self._incident_by_fingerprint.get(alert.fingerprint)
395
+ if incident_id is not None:
396
+ candidate = self._incidents.get(incident_id)
397
+ if candidate is None or candidate.status is IncidentStatus.RESOLVED:
398
+ incident_id = None
399
+ if incident_id is None:
400
+ log.info("resolved alert with no open incident; nothing to close")
401
+ return IngestResult()
402
+
403
+ incident = self._incidents[incident_id]
404
+ if incident.status is IncidentStatus.OPEN:
405
+ incident.transition(IncidentStatus.TRIAGING)
406
+ incident.transition(IncidentStatus.RESOLVED)
407
+ # Close the group: the next firing opens a fresh incident.
408
+ self._active_by_group.pop(alert.group_key, None)
409
+ stale = [
410
+ fp for fp, iid in self._incident_by_fingerprint.items() if iid == incident.id
411
+ ]
412
+ for fp in stale:
413
+ del self._incident_by_fingerprint[fp]
414
+ log.info("incident %s resolved by alert", incident.id)
415
+ return IngestResult(incident=incident, resolved=True)
416
+
417
+ @staticmethod
418
+ def _evidence_for(alert: IncomingAlert) -> Evidence:
419
+ return Evidence(
420
+ kind="alert",
421
+ query=f"webhook:{alert.source} fingerprint:{alert.fingerprint}",
422
+ summary=f"{alert.alert_name} ({alert.severity}): {alert.title}",
423
+ )