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.
sre_agent/server.py ADDED
@@ -0,0 +1,684 @@
1
+ """Webhook server: the agent's front door for alerts.
2
+
3
+ Run it with:
4
+
5
+ uvicorn sre_agent.server:app --port 8080
6
+
7
+ Endpoints, in plain words:
8
+
9
+ - ``GET /health`` — is the agent up? Always answers ``{"status": "ok"}``.
10
+ This one stays open even when a token is set, so a health check
11
+ never needs the secret.
12
+ - ``POST /webhooks/alertmanager`` — send an Alertmanager payload;
13
+ each alert in it is ingested (see ingestion.py for dedupe/grouping).
14
+ - ``POST /webhooks/generic`` — send one simple alert as JSON.
15
+ - ``GET /incidents`` — list the incidents the agent knows about.
16
+ - ``GET /incidents/{id}`` — one incident, in full.
17
+ - ``POST /incidents/{id}/actions`` — propose a runbook fix for a
18
+ diagnosed incident; the policy engine decides and, when it asks
19
+ for a human, the proposal is stored as a pending approval.
20
+ - ``GET /incidents/{id}/approvals`` — the pending approvals held
21
+ for one incident, with their state.
22
+ - ``POST /incidents/{id}/approvals/{pending_id}/approve`` — one
23
+ named human approves one pending action; it then runs, once.
24
+ - ``POST /incidents/{id}/approvals/{pending_id}/reject`` — one
25
+ named human turns one pending action down; nothing runs.
26
+
27
+ Authentication (security finding F-01, fixed after v0.1.0): set the
28
+ ``SRE_AGENT_API_TOKEN`` environment variable (or pass ``auth_token``
29
+ to :func:`create_app`) and every endpoint except ``/health`` then
30
+ requires that exact token in the ``X-SRE-Agent-Token`` header —
31
+ webhooks and incident reads alike. A missing or wrong token gets a
32
+ 401 and nothing is ingested or shown. With no token set, the server
33
+ is unauthenticated, exactly as v0.1.0 was: that stays fine for one
34
+ operator on their own machine, and only for that. Before the server
35
+ is shared with anyone else, set a token. Alertmanager can send the
36
+ header too (its webhook receiver's ``http_config``), and the CLI
37
+ sends it automatically when the same environment variable is set.
38
+
39
+ Diagnosis (Phase 7): when the app is built with a tool layer
40
+ (``create_app(tool_layer=...)``), the reasoning loop runs inside
41
+ the server. A firing alert that opens a new incident is diagnosed
42
+ at once: the loop's evidence, its possible causes and its
43
+ diagnosis are stored on the incident itself, so ``GET
44
+ /incidents/{id}`` — and the CLI's ``show`` command, which reads
45
+ that endpoint — show the diagnosis with the incident. An update
46
+ that groups onto an incident a human was handed (status
47
+ "escalated") is diagnosed again; a duplicate changes nothing and
48
+ is never re-diagnosed, and an incident already diagnosed keeps
49
+ its diagnosis when a grouped alert adds evidence. The shipped
50
+ ``app`` is built with the demo tool layer, so its diagnoses come
51
+ from the seeded demo data and claim nothing about a real system;
52
+ an app built with no tool layer records incidents only, exactly
53
+ as before. If the loop itself fails, the incident stays as
54
+ ingestion left it and the failure is logged — a broken diagnosis
55
+ never loses an alert.
56
+
57
+ Adapter evidence (Phase 7): when the app is also built with a
58
+ bundle of read adapters (``create_app(adapters=...)``, see
59
+ ``adapters.AdapterSources``), the loop asks each selected
60
+ adapter one fixed question during a diagnosis and stores the
61
+ answers on the incident as evidence, under the same rules as
62
+ in ``reasoning.py``: adapter questions are built only from
63
+ validated target names, a failing adapter is skipped, and
64
+ adapter answers can support a claim or a cause only as counts
65
+ and measured values. The shipped ``app`` is built with the demo
66
+ adapter bundle, so its diagnoses carry Kubernetes, Datadog and
67
+ Azure Monitor evidence from the same seeded demo picture; a
68
+ real deployment passes its own bundle, whose live adapters
69
+ already passed the backend address rules when they were built.
70
+ An app built with adapters but no tool layer still records
71
+ only, because adapters supplement the loop, they do not
72
+ replace it.
73
+
74
+ Memory advice (Phase 7): when the app is also built with a
75
+ memory of past incidents (``create_app(memory=...)``, see
76
+ ``memory.IncidentMemory``), the loop attaches that memory's
77
+ report to each incident it diagnoses — similar past incidents,
78
+ each runbook's record, and suggested runbooks — stored on the
79
+ incident as ``memory_report``, so ``GET /incidents/{id}`` and
80
+ ``sre-agent show`` carry it with the diagnosis. The report is
81
+ computed after the diagnosis is decided and is advice only:
82
+ it is never an input to a claim, a cause, a proposal or an
83
+ action, and the server creates no proposal from a suggestion —
84
+ a suggested fix still has to be proposed and approved through
85
+ the live approval flow like any other. The shipped ``app`` is
86
+ built with the seeded demo memory (four past incidents), so
87
+ its diagnosed demo incidents carry advice from that seeded
88
+ past and claim nothing about a real system; an app built with
89
+ no memory attaches none, and a memory handed in without a
90
+ tool layer still records only, because there is no diagnosis
91
+ to attach advice to.
92
+
93
+ Database store (Phase 7): the store is passed in, so a server
94
+ built with ``storage.SQLiteIncidentStore`` (a file path) keeps
95
+ its incidents across a restart — diagnoses included, because a
96
+ diagnosis is a field on the incident — and its pending approvals
97
+ too: the server restores the approvals the store holds when it
98
+ starts, keeps each new one through the store, and saves the
99
+ incident again after a diagnosis and after a run, which is what
100
+ writes those later changes through. A restored approval keeps
101
+ its original expiry, and a decided one stays decided. A server
102
+ built with the in-memory store (the shipped ``app`` included)
103
+ behaves exactly as before: stop it and its incidents and
104
+ approvals are gone.
105
+
106
+ Live approvals (Phase 7): when the app is built with an
107
+ environment policy and a runbook backend
108
+ (``create_app(approval_policy=..., runbook_backend=...)``), the
109
+ server also serves the Tier 2 approval flow. A fix is proposed
110
+ for one diagnosed incident: the proposal's target is the target
111
+ pinned on that incident — the request body cannot name a target,
112
+ so a proposal cannot be aimed anywhere else — and how many
113
+ replicas it touches is counted from the backend's real state by
114
+ ``runbooks.build_action``, never claimed by the proposer. The
115
+ policy engine decides, and the decision is written to the
116
+ server's audit log before anything else happens. A denied
117
+ proposal is refused. A proposal the policy sends for approval
118
+ waits as a pending action for at most 15 minutes; approving it
119
+ re-runs the policy check at approval time (a halt or tightening
120
+ since the proposal stands), spends the approval before the run
121
+ starts, and runs the fix exactly once. Rejecting a pending
122
+ action runs nothing. A run that succeeds adds its result to the
123
+ incident as evidence and moves the incident to "mitigating" —
124
+ never straight to "resolved", because the server has not checked
125
+ that the fix worked; resolution still arrives as a resolved
126
+ alert, as it always has. The shipped ``app`` is built with the
127
+ demo policy (Tier 2, the demo environment) and the demo runbook
128
+ backend, so its approvals act on the seeded demo workloads only
129
+ and claim nothing about a real system; an app built without a
130
+ policy or a backend holds no approvals, and its approval routes
131
+ answer 409 saying so.
132
+
133
+ Hosted demo mode (Phase 10): a server built with
134
+ ``tier3_only=True`` can run only what its policy allows outright.
135
+ A proposal the policy sends for a human's approval is refused
136
+ with 403 and no pending action is created, and the approve and
137
+ reject routes answer 409 — in that mode there is no approval
138
+ path at all, so the only actions that can ever run are the ones
139
+ a Tier 3 grant pre-approved. ``hosted_demo.py`` builds the
140
+ public-safe server this way: a token is required, the data is
141
+ the seeded demo data only, and the policy is the demo Tier 3
142
+ grant. See ``docs/hosted-demo.md``.
143
+ """
144
+
145
+ from __future__ import annotations
146
+
147
+ import os
148
+ import secrets
149
+ from datetime import UTC, datetime
150
+
151
+ from fastapi import Depends, FastAPI, Header, HTTPException
152
+ from pydantic import BaseModel, Field
153
+
154
+ from .adapters import AdapterSources, demo_adapter_sources
155
+ from .approvals import (
156
+ ApprovalError,
157
+ PendingAction,
158
+ approve_pending,
159
+ create_pending,
160
+ execute_approved,
161
+ )
162
+ from .audit import AuditLog, evaluate_and_audit
163
+ from .ingestion import (
164
+ AlertmanagerPayload,
165
+ GenericAlertPayload,
166
+ IncidentStore,
167
+ IngestionLimitError,
168
+ IngestResult,
169
+ normalize_alertmanager,
170
+ normalize_generic,
171
+ )
172
+ from .logging_config import get_logger
173
+ from .memory import IncidentMemory, demo_incident_memory
174
+ from .model_backend import DiagnosisBackend
175
+ from .models import Incident, IncidentStatus
176
+ from .policy import Decision, EnvironmentPolicy, Tier
177
+ from .reasoning import ReasoningLoop
178
+ from .runbooks import (
179
+ DemoWorkloadBackend,
180
+ RunbookError,
181
+ RunbookExecutor,
182
+ build_action,
183
+ demo_runbooks,
184
+ )
185
+ from .tools import ToolLayer, demo_tool_layer
186
+
187
+ log = get_logger(__name__)
188
+
189
+ ENV_API_TOKEN = "SRE_AGENT_API_TOKEN"
190
+ TOKEN_HEADER = "X-SRE-Agent-Token"
191
+
192
+
193
+ def _result_view(result: IngestResult) -> dict[str, object]:
194
+ incident = result.incident
195
+ return {
196
+ "incident_id": incident.id if incident else None,
197
+ "created": result.created,
198
+ "duplicate": result.duplicate,
199
+ "grouped": result.grouped,
200
+ "resolved": result.resolved,
201
+ "status": incident.status.value if incident else None,
202
+ "diagnosis": incident.diagnosis if incident else None,
203
+ }
204
+
205
+
206
+ def _incident_summary(store: IncidentStore, incident: Incident) -> dict[str, object]:
207
+ return {
208
+ "id": incident.id,
209
+ "title": incident.title,
210
+ "status": incident.status.value,
211
+ "target": incident.target.model_dump(),
212
+ "evidence_count": len(incident.evidence),
213
+ "duplicate_count": store.duplicate_count(incident.id),
214
+ "diagnosis": incident.diagnosis,
215
+ "memory_matches": (
216
+ len(incident.memory_report.matches) if incident.memory_report else 0
217
+ ),
218
+ "memory_suggestions": (
219
+ len(incident.memory_report.suggestions)
220
+ if incident.memory_report
221
+ else 0
222
+ ),
223
+ }
224
+
225
+
226
+ class ProposeActionRequest(BaseModel):
227
+ """One fix proposed through the server, for one incident.
228
+
229
+ There is deliberately no target field: the action's target is
230
+ the target pinned on the incident named in the URL, so a
231
+ request cannot aim a fix anywhere else.
232
+ """
233
+
234
+ runbook_id: str = Field(max_length=200)
235
+ parameters: dict[str, str | int | float | bool] = Field(default_factory=dict)
236
+ rationale: str = Field(
237
+ default="proposed through the server for this incident",
238
+ max_length=1000,
239
+ )
240
+
241
+
242
+ class ApprovalDecisionRequest(BaseModel):
243
+ """One named human's decision on one pending action."""
244
+
245
+ by: str = Field(max_length=200)
246
+
247
+
248
+ def _pending_view(
249
+ pending: PendingAction, decisions: dict[str, str]
250
+ ) -> dict[str, object]:
251
+ """One pending action as the API shows it, with its state."""
252
+ if pending.id in decisions:
253
+ status = decisions[pending.id]
254
+ elif pending.is_expired(datetime.now(UTC)):
255
+ status = "expired"
256
+ else:
257
+ status = "pending"
258
+ return {
259
+ "id": pending.id,
260
+ "incident_id": pending.incident_id,
261
+ "runbook_id": pending.action.runbook_id,
262
+ "target": pending.action.target.model_dump(),
263
+ "parameters": pending.action.parameters,
264
+ "replicas_affected": pending.action.replicas_affected,
265
+ "parameters_hash": pending.parameters_hash,
266
+ "reason": pending.reason,
267
+ "status": status,
268
+ "created_at": pending.created_at.isoformat(),
269
+ "expires_at": pending.expires_at.isoformat(),
270
+ }
271
+
272
+
273
+ def create_app(
274
+ store: IncidentStore | None = None,
275
+ auth_token: str | None = None,
276
+ tool_layer: ToolLayer | None = None,
277
+ approval_policy: EnvironmentPolicy | None = None,
278
+ runbook_backend: DemoWorkloadBackend | None = None,
279
+ audit_log: AuditLog | None = None,
280
+ adapters: AdapterSources | None = None,
281
+ memory: IncidentMemory | None = None,
282
+ diagnosis_backend: DiagnosisBackend | None = None,
283
+ tier3_only: bool = False,
284
+ ) -> FastAPI:
285
+ """Build the webhook app. Pass a store to share or inspect state.
286
+
287
+ ``auth_token`` wins when given; otherwise the token is read from
288
+ the ``SRE_AGENT_API_TOKEN`` environment variable. An empty value
289
+ counts as no token.
290
+
291
+ ``tool_layer`` turns on server-side diagnosis: with one given,
292
+ the reasoning loop diagnoses each new incident as its alert is
293
+ ingested, and the diagnosis is stored on the incident. With no
294
+ tool layer, the server records incidents only.
295
+
296
+ ``approval_policy`` and ``runbook_backend`` together turn on
297
+ live approvals: proposals for diagnosed incidents are decided
298
+ by that policy (every decision written to ``audit_log``, a
299
+ fresh in-memory one when none is given) and approved actions
300
+ run against that backend. With either missing, the server
301
+ holds no approvals and the approval routes answer 409.
302
+
303
+ ``adapters`` selects read adapters as extra evidence sources
304
+ for the loop's diagnoses. It only takes effect together with
305
+ a ``tool_layer``: with no tool layer there is no loop, and
306
+ the server records incidents only.
307
+
308
+ ``memory`` hands the loop a memory of past incidents; the
309
+ loop attaches its advice (a ``MemoryReport``) to each
310
+ incident it diagnoses, after the diagnosis is decided. It
311
+ likewise only takes effect together with a ``tool_layer``.
312
+
313
+ ``diagnosis_backend`` selects who reads the gathered
314
+ evidence for the loop's diagnoses (see
315
+ ``model_backend.py``). With none given, the deterministic
316
+ backend runs — the default. A language-model backend takes
317
+ effect only when an operator builds one and passes it here
318
+ on purpose; the shipped ``app`` below passes none, so the
319
+ model backend is off in the shipped server. It likewise
320
+ only takes effect together with a ``tool_layer``.
321
+
322
+ ``store`` may be a ``storage.SQLiteIncidentStore``: then
323
+ incidents, diagnoses and pending approvals persist in its
324
+ file and are restored when a server is built on it again.
325
+ With the in-memory store (the default) nothing persists.
326
+
327
+ ``tier3_only`` turns off the approval path: a proposal the
328
+ policy does not allow outright is refused and never becomes
329
+ a pending action, and the approve/reject routes answer 409.
330
+ Only a policy ALLOW — in the hosted demo, the demo Tier 3
331
+ grant — can run an action on such a server.
332
+ """
333
+ incident_store = store if store is not None else IncidentStore()
334
+ loop = (
335
+ ReasoningLoop(
336
+ tool_layer, adapters=adapters, memory=memory, backend=diagnosis_backend
337
+ )
338
+ if tool_layer is not None
339
+ else None
340
+ )
341
+ executor = (
342
+ RunbookExecutor(runbook_backend) if runbook_backend is not None else None
343
+ )
344
+ audit = audit_log if audit_log is not None else AuditLog()
345
+ # Restored approvals (a database store hands back what a
346
+ # previous server left; the in-memory store hands back none).
347
+ pendings, decisions = incident_store.load_approvals()
348
+
349
+ def diagnose_if_needed(result: IngestResult) -> None:
350
+ """Run the reasoning loop for one ingested alert, if it fits.
351
+
352
+ Only two moments are diagnosed: a newly opened incident
353
+ (still in triage), and a grouped update to an incident that
354
+ was escalated to a human (sent back to triage for a fresh
355
+ look). Duplicates, resolved alerts, and incidents already
356
+ diagnosed or further along keep the record they have.
357
+ """
358
+ if loop is None or result.incident is None:
359
+ return
360
+ if result.duplicate or result.resolved:
361
+ return
362
+ incident = result.incident
363
+ if incident.status is IncidentStatus.ESCALATED:
364
+ incident.transition(IncidentStatus.TRIAGING)
365
+ if incident.status is not IncidentStatus.TRIAGING:
366
+ return
367
+ try:
368
+ loop.diagnose(incident)
369
+ except Exception:
370
+ log.exception(
371
+ "diagnosis failed for incident %s; the incident is "
372
+ "kept as ingested",
373
+ incident.id,
374
+ )
375
+ # The diagnosis (or the triage move that preceded a failed
376
+ # one) changed the record outside ingestion: keep the
377
+ # change through the store, so a database store writes it.
378
+ incident_store.save(incident)
379
+ token = auth_token
380
+ if token is None:
381
+ token = os.environ.get(ENV_API_TOKEN) or None
382
+ if token == "":
383
+ token = None
384
+ app = FastAPI(title="sre-agent")
385
+
386
+ def require_token(
387
+ x_sre_agent_token: str | None = Header(default=None, alias=TOKEN_HEADER),
388
+ ) -> None:
389
+ if token is None:
390
+ return
391
+ if x_sre_agent_token is None or not secrets.compare_digest(
392
+ x_sre_agent_token, token
393
+ ):
394
+ raise HTTPException(
395
+ status_code=401, detail="missing or wrong API token"
396
+ )
397
+
398
+ guarded = [Depends(require_token)]
399
+
400
+ @app.get("/health")
401
+ def health() -> dict[str, str]:
402
+ return {"status": "ok"}
403
+
404
+ @app.post("/webhooks/alertmanager", dependencies=guarded)
405
+ def alertmanager_webhook(payload: AlertmanagerPayload) -> dict[str, object]:
406
+ try:
407
+ results = [
408
+ incident_store.ingest(alert)
409
+ for alert in normalize_alertmanager(payload)
410
+ ]
411
+ except IngestionLimitError as exc:
412
+ raise HTTPException(status_code=429, detail=str(exc)) from exc
413
+ for result in results:
414
+ diagnose_if_needed(result)
415
+ return {
416
+ "received": len(results),
417
+ "results": [_result_view(r) for r in results],
418
+ "active_incidents": sum(
419
+ 1
420
+ for inc in incident_store.list_incidents()
421
+ if inc.status.value != "resolved"
422
+ ),
423
+ }
424
+
425
+ @app.post("/webhooks/generic", dependencies=guarded)
426
+ def generic_webhook(payload: GenericAlertPayload) -> dict[str, object]:
427
+ try:
428
+ result = incident_store.ingest(normalize_generic(payload))
429
+ except IngestionLimitError as exc:
430
+ raise HTTPException(status_code=429, detail=str(exc)) from exc
431
+ diagnose_if_needed(result)
432
+ return _result_view(result)
433
+
434
+ @app.get("/incidents", dependencies=guarded)
435
+ def list_incidents() -> list[dict[str, object]]:
436
+ return [
437
+ _incident_summary(incident_store, inc)
438
+ for inc in incident_store.list_incidents()
439
+ ]
440
+
441
+ @app.get("/incidents/{incident_id}", dependencies=guarded)
442
+ def get_incident(incident_id: str) -> dict[str, object]:
443
+ incident = incident_store.get(incident_id)
444
+ if incident is None:
445
+ raise HTTPException(status_code=404, detail="incident not found")
446
+ return {
447
+ **incident.model_dump(mode="json"),
448
+ "duplicate_count": incident_store.duplicate_count(incident.id),
449
+ }
450
+
451
+ def _incident_or_404(incident_id: str) -> Incident:
452
+ incident = incident_store.get(incident_id)
453
+ if incident is None:
454
+ raise HTTPException(status_code=404, detail="incident not found")
455
+ return incident
456
+
457
+ def _approvals_or_409() -> tuple[EnvironmentPolicy, RunbookExecutor]:
458
+ if approval_policy is None or executor is None or runbook_backend is None:
459
+ raise HTTPException(
460
+ status_code=409,
461
+ detail="live approvals are not configured on this server",
462
+ )
463
+ return approval_policy, executor
464
+
465
+ def _pending_or_404(incident_id: str, pending_id: str) -> PendingAction:
466
+ pending = pendings.get(pending_id)
467
+ if pending is None or pending.incident_id != incident_id:
468
+ raise HTTPException(status_code=404, detail="approval not found")
469
+ return pending
470
+
471
+ @app.post(
472
+ "/incidents/{incident_id}/actions",
473
+ dependencies=guarded,
474
+ status_code=201,
475
+ )
476
+ def propose_action(
477
+ incident_id: str, body: ProposeActionRequest
478
+ ) -> dict[str, object]:
479
+ incident = _incident_or_404(incident_id)
480
+ policy, _ = _approvals_or_409()
481
+ assert runbook_backend is not None # checked by _approvals_or_409
482
+ if incident.status is not IncidentStatus.DIAGNOSED:
483
+ raise HTTPException(
484
+ status_code=409,
485
+ detail=(
486
+ f"incident is '{incident.status.value}'; only a diagnosed "
487
+ "incident can have a fix proposed for it"
488
+ ),
489
+ )
490
+ try:
491
+ action = build_action(
492
+ body.runbook_id,
493
+ incident.target,
494
+ body.parameters,
495
+ runbook_backend,
496
+ rationale=body.rationale,
497
+ )
498
+ except RunbookError as exc:
499
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
500
+ result = evaluate_and_audit(
501
+ action,
502
+ policy,
503
+ audit,
504
+ incident_id=incident.id,
505
+ incident_target=incident.target,
506
+ )
507
+ if result.decision is Decision.DENY:
508
+ raise HTTPException(status_code=403, detail=result.reason)
509
+ if result.decision is Decision.ALLOW:
510
+ # A policy that allows outright (a Tier 3 grant) runs
511
+ # through the same executor; the decision is already
512
+ # in the audit log.
513
+ assert executor is not None
514
+ try:
515
+ run_result = executor.execute(action, result)
516
+ except RunbookError as exc:
517
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
518
+ incident.add_evidence(run_result.to_evidence())
519
+ incident.transition(IncidentStatus.MITIGATING)
520
+ incident_store.save(incident)
521
+ return {
522
+ "decision": result.decision.value,
523
+ "reason": result.reason,
524
+ "summary": run_result.summary,
525
+ "incident_status": incident.status.value,
526
+ "pending": None,
527
+ }
528
+ if tier3_only:
529
+ # Hosted demo mode: there is no approval path, so a
530
+ # proposal that is not allowed outright cannot run.
531
+ # The decision is already in the audit log; nothing
532
+ # becomes pending and nothing runs.
533
+ raise HTTPException(
534
+ status_code=403,
535
+ detail=(
536
+ "this server runs only actions its policy allows "
537
+ "outright (the Tier 3 demo grant); "
538
+ f"{body.runbook_id} needs a human approval, and "
539
+ "approvals are disabled in hosted demo mode"
540
+ ),
541
+ )
542
+ pending = create_pending(action, result, incident_id=incident.id)
543
+ pendings[pending.id] = pending
544
+ incident_store.save_approval(pending, None)
545
+ return {
546
+ "decision": result.decision.value,
547
+ "reason": result.reason,
548
+ "summary": None,
549
+ "incident_status": incident.status.value,
550
+ "pending": _pending_view(pending, decisions),
551
+ }
552
+
553
+ @app.get("/incidents/{incident_id}/approvals", dependencies=guarded)
554
+ def list_approvals(incident_id: str) -> list[dict[str, object]]:
555
+ _incident_or_404(incident_id)
556
+ _approvals_or_409()
557
+ return [
558
+ _pending_view(pending, decisions)
559
+ for pending in pendings.values()
560
+ if pending.incident_id == incident_id
561
+ ]
562
+
563
+ @app.post(
564
+ "/incidents/{incident_id}/approvals/{pending_id}/approve",
565
+ dependencies=guarded,
566
+ )
567
+ def approve_action(
568
+ incident_id: str, pending_id: str, body: ApprovalDecisionRequest
569
+ ) -> dict[str, object]:
570
+ incident = _incident_or_404(incident_id)
571
+ policy, executor = _approvals_or_409()
572
+ if tier3_only:
573
+ raise HTTPException(
574
+ status_code=409,
575
+ detail=(
576
+ "approvals are disabled in hosted demo mode; "
577
+ "only the Tier 3 demo grant can run an action"
578
+ ),
579
+ )
580
+ pending = _pending_or_404(incident_id, pending_id)
581
+ if pending.id in decisions:
582
+ raise HTTPException(
583
+ status_code=409,
584
+ detail="this approval has already been decided; "
585
+ "propose the action again for a new approval",
586
+ )
587
+ try:
588
+ grant, allowed = approve_pending(
589
+ pending,
590
+ approved_by=body.by,
591
+ policy=policy,
592
+ audit_log=audit,
593
+ incident_target=incident.target,
594
+ )
595
+ except ApprovalError as exc:
596
+ status_code = 403 if "policy now denies" in str(exc) else 409
597
+ raise HTTPException(status_code=status_code, detail=str(exc)) from exc
598
+ # The grant is spent by the run attempt, success or failure
599
+ # (see approvals.execute_approved), so the pending action
600
+ # is decided from here on either way.
601
+ decisions[pending.id] = "approved"
602
+ incident_store.save_approval(pending, "approved")
603
+ try:
604
+ run_result = execute_approved(executor, pending, grant, allowed)
605
+ except RunbookError as exc:
606
+ raise HTTPException(
607
+ status_code=422,
608
+ detail=f"the approved action could not run: {exc}",
609
+ ) from exc
610
+ incident.add_evidence(run_result.to_evidence())
611
+ if incident.status is IncidentStatus.DIAGNOSED:
612
+ incident.transition(IncidentStatus.MITIGATING)
613
+ incident_store.save(incident)
614
+ return {
615
+ "pending_id": pending.id,
616
+ "decision": "approved",
617
+ "approved_by": grant.approved_by,
618
+ "runbook_id": pending.action.runbook_id,
619
+ "summary": run_result.summary,
620
+ "incident_status": incident.status.value,
621
+ }
622
+
623
+ @app.post(
624
+ "/incidents/{incident_id}/approvals/{pending_id}/reject",
625
+ dependencies=guarded,
626
+ )
627
+ def reject_action(
628
+ incident_id: str, pending_id: str, body: ApprovalDecisionRequest
629
+ ) -> dict[str, object]:
630
+ incident = _incident_or_404(incident_id)
631
+ _approvals_or_409()
632
+ if tier3_only:
633
+ raise HTTPException(
634
+ status_code=409,
635
+ detail=(
636
+ "approvals are disabled in hosted demo mode; "
637
+ "only the Tier 3 demo grant can run an action"
638
+ ),
639
+ )
640
+ pending = _pending_or_404(incident_id, pending_id)
641
+ if not body.by.strip():
642
+ raise HTTPException(
643
+ status_code=400,
644
+ detail="a rejection must name who is rejecting",
645
+ )
646
+ if pending.id in decisions:
647
+ raise HTTPException(
648
+ status_code=409,
649
+ detail="this approval has already been decided",
650
+ )
651
+ decisions[pending.id] = "rejected"
652
+ incident_store.save_approval(pending, "rejected")
653
+ return {
654
+ "pending_id": pending.id,
655
+ "decision": "rejected",
656
+ "rejected_by": body.by.strip(),
657
+ "runbook_id": pending.action.runbook_id,
658
+ "summary": None,
659
+ "incident_status": incident.status.value,
660
+ }
661
+
662
+ return app
663
+
664
+
665
+ # The shipped app is the demo server: it diagnoses new incidents
666
+ # from the seeded demo tool layer's data (see tools.demo_tool_layer),
667
+ # with the seeded demo read adapters selected as extra evidence
668
+ # sources and the seeded demo memory attached as advice, and serves
669
+ # live approvals against the seeded demo workloads, under a Tier 2
670
+ # policy for the demo environment. It passes no diagnosis backend,
671
+ # so its diagnoses come from the deterministic backend — the
672
+ # language-model backend is off in the shipped server.
673
+ _demo_backend = demo_runbooks()[1]
674
+ app = create_app(
675
+ tool_layer=demo_tool_layer(),
676
+ adapters=demo_adapter_sources(),
677
+ memory=demo_incident_memory(),
678
+ approval_policy=EnvironmentPolicy(
679
+ environment="demo",
680
+ granted_tier=Tier.ACT_WITH_APPROVAL,
681
+ max_replicas_affected=5,
682
+ ),
683
+ runbook_backend=_demo_backend,
684
+ )