nthlayer-workers 1.0.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.
Files changed (175) hide show
  1. nthlayer_workers/__init__.py +5 -0
  2. nthlayer_workers/cli.py +234 -0
  3. nthlayer_workers/correlate/__init__.py +1 -0
  4. nthlayer_workers/correlate/cli.py +847 -0
  5. nthlayer_workers/correlate/config.py +111 -0
  6. nthlayer_workers/correlate/correlation/__init__.py +1 -0
  7. nthlayer_workers/correlate/correlation/changes.py +87 -0
  8. nthlayer_workers/correlate/correlation/dedup.py +62 -0
  9. nthlayer_workers/correlate/correlation/engine.py +244 -0
  10. nthlayer_workers/correlate/correlation/temporal.py +79 -0
  11. nthlayer_workers/correlate/correlation/topology.py +104 -0
  12. nthlayer_workers/correlate/ingestion/__init__.py +1 -0
  13. nthlayer_workers/correlate/ingestion/protocol.py +10 -0
  14. nthlayer_workers/correlate/ingestion/severity.py +18 -0
  15. nthlayer_workers/correlate/ingestion/webhook.py +197 -0
  16. nthlayer_workers/correlate/notifications.py +85 -0
  17. nthlayer_workers/correlate/prometheus.py +234 -0
  18. nthlayer_workers/correlate/reasoning.py +375 -0
  19. nthlayer_workers/correlate/session.py +189 -0
  20. nthlayer_workers/correlate/snapshot/__init__.py +1 -0
  21. nthlayer_workers/correlate/snapshot/generator.py +170 -0
  22. nthlayer_workers/correlate/snapshot/model.py +177 -0
  23. nthlayer_workers/correlate/snapshot/token.py +14 -0
  24. nthlayer_workers/correlate/state.py +88 -0
  25. nthlayer_workers/correlate/store/__init__.py +5 -0
  26. nthlayer_workers/correlate/store/protocol.py +48 -0
  27. nthlayer_workers/correlate/store/sqlite.py +443 -0
  28. nthlayer_workers/correlate/summary.py +180 -0
  29. nthlayer_workers/correlate/traces/__init__.py +1 -0
  30. nthlayer_workers/correlate/traces/protocol.py +120 -0
  31. nthlayer_workers/correlate/traces/tempo.py +667 -0
  32. nthlayer_workers/correlate/traces/topology.py +39 -0
  33. nthlayer_workers/correlate/types.py +77 -0
  34. nthlayer_workers/correlate/worker.py +630 -0
  35. nthlayer_workers/learn/__init__.py +5 -0
  36. nthlayer_workers/learn/__main__.py +5 -0
  37. nthlayer_workers/learn/cli.py +164 -0
  38. nthlayer_workers/learn/retrospective.py +381 -0
  39. nthlayer_workers/learn/trends.py +102 -0
  40. nthlayer_workers/learn/worker.py +366 -0
  41. nthlayer_workers/measure/__init__.py +3 -0
  42. nthlayer_workers/measure/__main__.py +5 -0
  43. nthlayer_workers/measure/_parsing.py +15 -0
  44. nthlayer_workers/measure/adapters/__init__.py +0 -0
  45. nthlayer_workers/measure/adapters/_util.py +24 -0
  46. nthlayer_workers/measure/adapters/devin.py +119 -0
  47. nthlayer_workers/measure/adapters/gastown.py +88 -0
  48. nthlayer_workers/measure/adapters/prometheus.py +277 -0
  49. nthlayer_workers/measure/adapters/protocol.py +20 -0
  50. nthlayer_workers/measure/adapters/webhook.py +161 -0
  51. nthlayer_workers/measure/api/__init__.py +0 -0
  52. nthlayer_workers/measure/api/normalise.py +50 -0
  53. nthlayer_workers/measure/api/queue.py +243 -0
  54. nthlayer_workers/measure/api/response.py +51 -0
  55. nthlayer_workers/measure/api/server.py +504 -0
  56. nthlayer_workers/measure/calibration/__init__.py +0 -0
  57. nthlayer_workers/measure/calibration/loop.py +62 -0
  58. nthlayer_workers/measure/calibration/slos.py +212 -0
  59. nthlayer_workers/measure/calibration/verdict_calibration.py +31 -0
  60. nthlayer_workers/measure/cli.py +753 -0
  61. nthlayer_workers/measure/config.py +191 -0
  62. nthlayer_workers/measure/detection/__init__.py +6 -0
  63. nthlayer_workers/measure/detection/detector.py +82 -0
  64. nthlayer_workers/measure/detection/protocol.py +29 -0
  65. nthlayer_workers/measure/governance/__init__.py +0 -0
  66. nthlayer_workers/measure/governance/engine.py +163 -0
  67. nthlayer_workers/measure/manifest.py +77 -0
  68. nthlayer_workers/measure/notifications.py +53 -0
  69. nthlayer_workers/measure/pipeline/__init__.py +0 -0
  70. nthlayer_workers/measure/pipeline/evaluator.py +155 -0
  71. nthlayer_workers/measure/pipeline/router.py +160 -0
  72. nthlayer_workers/measure/store/__init__.py +0 -0
  73. nthlayer_workers/measure/store/protocol.py +38 -0
  74. nthlayer_workers/measure/store/sqlite.py +276 -0
  75. nthlayer_workers/measure/telemetry.py +116 -0
  76. nthlayer_workers/measure/tiering/__init__.py +0 -0
  77. nthlayer_workers/measure/tiering/classifier.py +58 -0
  78. nthlayer_workers/measure/tiering/promotion.py +118 -0
  79. nthlayer_workers/measure/trends/__init__.py +0 -0
  80. nthlayer_workers/measure/trends/tracker.py +72 -0
  81. nthlayer_workers/measure/types.py +75 -0
  82. nthlayer_workers/measure/worker.py +439 -0
  83. nthlayer_workers/observe/__init__.py +25 -0
  84. nthlayer_workers/observe/__main__.py +5 -0
  85. nthlayer_workers/observe/api/__init__.py +1 -0
  86. nthlayer_workers/observe/assessment.py +95 -0
  87. nthlayer_workers/observe/cli.py +737 -0
  88. nthlayer_workers/observe/config.py +11 -0
  89. nthlayer_workers/observe/db/__init__.py +1 -0
  90. nthlayer_workers/observe/decision_records.py +220 -0
  91. nthlayer_workers/observe/dependencies/__init__.py +18 -0
  92. nthlayer_workers/observe/dependencies/discovery.py +294 -0
  93. nthlayer_workers/observe/dependencies/providers/__init__.py +48 -0
  94. nthlayer_workers/observe/dependencies/providers/backstage.py +467 -0
  95. nthlayer_workers/observe/dependencies/providers/base.py +76 -0
  96. nthlayer_workers/observe/dependencies/providers/consul.py +518 -0
  97. nthlayer_workers/observe/dependencies/providers/etcd.py +360 -0
  98. nthlayer_workers/observe/dependencies/providers/kubernetes.py +682 -0
  99. nthlayer_workers/observe/dependencies/providers/prometheus.py +368 -0
  100. nthlayer_workers/observe/dependencies/providers/zookeeper.py +399 -0
  101. nthlayer_workers/observe/deployments/__init__.py +1 -0
  102. nthlayer_workers/observe/discovery/__init__.py +14 -0
  103. nthlayer_workers/observe/discovery/classifier.py +66 -0
  104. nthlayer_workers/observe/discovery/client.py +189 -0
  105. nthlayer_workers/observe/discovery/models.py +53 -0
  106. nthlayer_workers/observe/drift/__init__.py +26 -0
  107. nthlayer_workers/observe/drift/analyzer.py +383 -0
  108. nthlayer_workers/observe/drift/models.py +174 -0
  109. nthlayer_workers/observe/drift/patterns.py +88 -0
  110. nthlayer_workers/observe/explanation.py +118 -0
  111. nthlayer_workers/observe/gate/__init__.py +39 -0
  112. nthlayer_workers/observe/gate/conditions.py +92 -0
  113. nthlayer_workers/observe/gate/correlator.py +154 -0
  114. nthlayer_workers/observe/gate/evaluator.py +192 -0
  115. nthlayer_workers/observe/gate/policies.py +226 -0
  116. nthlayer_workers/observe/gate_adapter.py +40 -0
  117. nthlayer_workers/observe/incident.py +36 -0
  118. nthlayer_workers/observe/portfolio/__init__.py +17 -0
  119. nthlayer_workers/observe/portfolio/aggregator.py +168 -0
  120. nthlayer_workers/observe/portfolio/scorer.py +13 -0
  121. nthlayer_workers/observe/slo/__init__.py +19 -0
  122. nthlayer_workers/observe/slo/collector.py +235 -0
  123. nthlayer_workers/observe/slo/spec_loader.py +40 -0
  124. nthlayer_workers/observe/sqlite_store.py +152 -0
  125. nthlayer_workers/observe/store.py +92 -0
  126. nthlayer_workers/observe/verification/__init__.py +22 -0
  127. nthlayer_workers/observe/verification/exporter_guidance.py +146 -0
  128. nthlayer_workers/observe/verification/extractor.py +127 -0
  129. nthlayer_workers/observe/verification/models.py +101 -0
  130. nthlayer_workers/observe/verification/verifier.py +111 -0
  131. nthlayer_workers/observe/worker.py +332 -0
  132. nthlayer_workers/respond/__init__.py +2 -0
  133. nthlayer_workers/respond/__main__.py +4 -0
  134. nthlayer_workers/respond/agents/__init__.py +0 -0
  135. nthlayer_workers/respond/agents/base.py +556 -0
  136. nthlayer_workers/respond/agents/communication.py +115 -0
  137. nthlayer_workers/respond/agents/investigation.py +124 -0
  138. nthlayer_workers/respond/agents/remediation.py +219 -0
  139. nthlayer_workers/respond/agents/triage.py +132 -0
  140. nthlayer_workers/respond/cli.py +772 -0
  141. nthlayer_workers/respond/config.py +135 -0
  142. nthlayer_workers/respond/context_store.py +256 -0
  143. nthlayer_workers/respond/coordinator.py +487 -0
  144. nthlayer_workers/respond/metrics.py +104 -0
  145. nthlayer_workers/respond/notification_backends/__init__.py +1 -0
  146. nthlayer_workers/respond/notification_backends/ntfy_backend.py +158 -0
  147. nthlayer_workers/respond/notification_backends/protocol.py +59 -0
  148. nthlayer_workers/respond/notification_backends/slack_backend.py +203 -0
  149. nthlayer_workers/respond/notification_backends/stdout_backend.py +56 -0
  150. nthlayer_workers/respond/notifications.py +247 -0
  151. nthlayer_workers/respond/oncall/__init__.py +1 -0
  152. nthlayer_workers/respond/oncall/escalation.py +103 -0
  153. nthlayer_workers/respond/oncall/runner.py +193 -0
  154. nthlayer_workers/respond/oncall/schedule.py +243 -0
  155. nthlayer_workers/respond/safe_actions/__init__.py +0 -0
  156. nthlayer_workers/respond/safe_actions/actions.py +139 -0
  157. nthlayer_workers/respond/safe_actions/registry.py +171 -0
  158. nthlayer_workers/respond/safe_actions/webhook.py +194 -0
  159. nthlayer_workers/respond/server.py +357 -0
  160. nthlayer_workers/respond/sre/__init__.py +1 -0
  161. nthlayer_workers/respond/sre/brief.py +175 -0
  162. nthlayer_workers/respond/sre/delegation.py +101 -0
  163. nthlayer_workers/respond/sre/post_incident.py +146 -0
  164. nthlayer_workers/respond/sre/shift_report.py +129 -0
  165. nthlayer_workers/respond/sre/suppression.py +91 -0
  166. nthlayer_workers/respond/types.py +109 -0
  167. nthlayer_workers/respond/verdict_submission.py +56 -0
  168. nthlayer_workers/respond/worker.py +533 -0
  169. nthlayer_workers/respond/worker_helpers.py +140 -0
  170. nthlayer_workers/runner.py +198 -0
  171. nthlayer_workers-1.0.0.dist-info/METADATA +19 -0
  172. nthlayer_workers-1.0.0.dist-info/RECORD +175 -0
  173. nthlayer_workers-1.0.0.dist-info/WHEEL +5 -0
  174. nthlayer_workers-1.0.0.dist-info/entry_points.txt +2 -0
  175. nthlayer_workers-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,194 @@
1
+ """Webhook dispatcher for safe action execution bindings.
2
+
3
+ Renders {{variable}} templates, resolves ${ENV_VAR} secrets,
4
+ makes HTTP calls, and optionally verifies results via PromQL.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import logging
10
+ import os
11
+ import re
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class ExecutionResult:
22
+ """Result of a safe action execution."""
23
+
24
+ success: bool
25
+ status_code: int | None = None
26
+ detail: str = ""
27
+ verified: bool | None = None
28
+ verification_detail: str | None = None
29
+
30
+
31
+ def render_binding_templates(obj: Any, variables: dict[str, str]) -> Any:
32
+ """Recursively render {{variable}} placeholders in strings."""
33
+ if isinstance(obj, str):
34
+ for key, value in variables.items():
35
+ obj = obj.replace("{{" + key + "}}", str(value))
36
+ obj = obj.replace("{{ " + key + " }}", str(value))
37
+ return obj
38
+ if isinstance(obj, dict):
39
+ return {k: render_binding_templates(v, variables) for k, v in obj.items()}
40
+ if isinstance(obj, list):
41
+ return [render_binding_templates(item, variables) for item in obj]
42
+ return obj
43
+
44
+
45
+ def resolve_secrets(obj: Any) -> Any:
46
+ """Recursively resolve ${ENV_VAR} placeholders from os.environ.
47
+
48
+ Raises ValueError if a referenced env var is not set.
49
+ """
50
+ if isinstance(obj, str):
51
+ def _replace(match):
52
+ var_name = match.group(1)
53
+ value = os.environ.get(var_name)
54
+ if value is None:
55
+ raise ValueError(
56
+ f"Secret ${{{var_name}}} not set. "
57
+ f"Set the {var_name} environment variable."
58
+ )
59
+ return value
60
+ return re.sub(r'\$\{(\w+)\}', _replace, obj)
61
+ if isinstance(obj, dict):
62
+ return {k: resolve_secrets(v) for k, v in obj.items()}
63
+ if isinstance(obj, list):
64
+ return [resolve_secrets(item) for item in obj]
65
+ return obj
66
+
67
+
68
+ class WebhookDispatcher:
69
+ """Execute safe action bindings via HTTP webhooks."""
70
+
71
+ async def execute(
72
+ self, binding: dict | str, variables: dict[str, str]
73
+ ) -> ExecutionResult:
74
+ """Render templates, resolve secrets, make HTTP call, verify."""
75
+ if binding == "stub" or not binding:
76
+ target = variables.get("service", variables.get("target", "unknown"))
77
+ return ExecutionResult(
78
+ success=True,
79
+ detail=f"Stub execution for {target} (no binding configured).",
80
+ )
81
+
82
+ rendered = render_binding_templates(binding, variables)
83
+ try:
84
+ rendered = resolve_secrets(rendered)
85
+ except ValueError as exc:
86
+ return ExecutionResult(success=False, detail=str(exc))
87
+
88
+ url = rendered.get("url", "")
89
+ headers = rendered.get("headers", {})
90
+ body = rendered.get("body")
91
+ timeout = int(rendered.get("timeout", 30))
92
+ retry_config = rendered.get("retry", {})
93
+ verify_config = rendered.get("verify_after")
94
+
95
+ result = await self._call_webhook(url, headers, body, timeout, retry_config)
96
+
97
+ if verify_config and result.success:
98
+ verification = await self._verify(verify_config, variables)
99
+ result.verified = verification.verified
100
+ result.verification_detail = verification.verification_detail
101
+
102
+ return result
103
+
104
+ async def _call_webhook(
105
+ self, url, headers, body, timeout, retry_config
106
+ ) -> ExecutionResult:
107
+ """Make HTTP POST with retry logic."""
108
+ attempts = retry_config.get("attempts", 1)
109
+ backoff = retry_config.get("backoff", [1])
110
+ last_error = ""
111
+ last_status = None
112
+
113
+ async with httpx.AsyncClient() as client:
114
+ for attempt in range(attempts):
115
+ try:
116
+ resp = await client.post(
117
+ url, headers=headers, json=body, timeout=timeout
118
+ )
119
+ last_status = resp.status_code
120
+ if resp.is_success:
121
+ return ExecutionResult(
122
+ success=True,
123
+ status_code=resp.status_code,
124
+ detail=resp.text[:500],
125
+ )
126
+ resp.raise_for_status()
127
+ except httpx.HTTPStatusError as exc:
128
+ last_error = f"HTTP {exc.response.status_code}: {exc.response.text[:200]}"
129
+ last_status = exc.response.status_code
130
+ except httpx.TimeoutException:
131
+ last_error = f"Timeout after {timeout}s"
132
+ except Exception as exc:
133
+ last_error = str(exc)
134
+
135
+ if attempt < attempts - 1:
136
+ delay = backoff[min(attempt, len(backoff) - 1)]
137
+ await asyncio.sleep(delay)
138
+
139
+ return ExecutionResult(
140
+ success=False, status_code=last_status, detail=last_error
141
+ )
142
+
143
+ async def _verify(self, verify_config, variables) -> ExecutionResult:
144
+ """Wait, query Prometheus, return verification result."""
145
+ wait = int(verify_config.get("wait", 30))
146
+ query = verify_config.get("query", "")
147
+ description = verify_config.get("description", "")
148
+ prometheus_url = verify_config.get("prometheus_url") or os.environ.get(
149
+ "PROMETHEUS_URL", "http://localhost:9090"
150
+ )
151
+
152
+ query = render_binding_templates(query, variables)
153
+
154
+ logger.info("Waiting %ds before verification: %s", wait, description)
155
+ await asyncio.sleep(wait)
156
+
157
+ try:
158
+ async with httpx.AsyncClient() as client:
159
+ resp = await client.get(
160
+ f"{prometheus_url}/api/v1/query",
161
+ params={"query": query},
162
+ timeout=10.0,
163
+ )
164
+ resp.raise_for_status()
165
+ data = resp.json()
166
+ results = data.get("data", {}).get("result", [])
167
+
168
+ if not results:
169
+ return ExecutionResult(
170
+ success=True,
171
+ verified=None,
172
+ verification_detail=f"No data for query: {description}",
173
+ )
174
+
175
+ value = float(results[0].get("value", [None, "0"])[1])
176
+ verified = value == 1.0
177
+
178
+ return ExecutionResult(
179
+ success=True,
180
+ verified=verified,
181
+ verification_detail=(
182
+ f"Verified: {description}"
183
+ if verified
184
+ else f"Verification failed: {description} (value={value})"
185
+ ),
186
+ )
187
+
188
+ except Exception as exc:
189
+ logger.warning("Verification query failed: %s", exc)
190
+ return ExecutionResult(
191
+ success=True,
192
+ verified=None,
193
+ verification_detail=f"Verification unavailable: {exc}",
194
+ )
@@ -0,0 +1,357 @@
1
+ """HTTP server for incident approval workflows.
2
+
3
+ Starlette ASGI app with routes for approve, reject, status, and
4
+ Slack interaction callbacks. Embedded in `nthlayer-respond serve`.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import contextlib
10
+ import json
11
+ import logging
12
+ import time as _time
13
+ from collections.abc import AsyncGenerator
14
+ from datetime import datetime, timezone
15
+ from typing import Any
16
+
17
+ from starlette.applications import Starlette
18
+ from starlette.requests import Request
19
+ from starlette.responses import JSONResponse, Response
20
+ from starlette.routing import Route
21
+
22
+ from nthlayer_common.slack_web import SlackWebClient
23
+ from nthlayer_workers.respond.config import RespondConfig
24
+ from nthlayer_workers.respond.metrics import VerdictMetricsCollector
25
+ from nthlayer_workers.respond.types import IncidentState
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class ApprovalServer:
31
+ """HTTP server for incident approval workflows."""
32
+
33
+ def __init__(
34
+ self,
35
+ coordinator: Any,
36
+ context_store: Any,
37
+ config: RespondConfig,
38
+ verdict_store: Any = None,
39
+ ) -> None:
40
+ self._coordinator = coordinator
41
+ self._context_store = context_store
42
+ self._config = config
43
+ self._timeouts: dict[str, asyncio.Task] = {}
44
+ self._locks: dict[str, asyncio.Lock] = {}
45
+ self._metrics = VerdictMetricsCollector(verdict_store) if verdict_store else None
46
+
47
+ def _get_lock(self, incident_id: str) -> asyncio.Lock:
48
+ """Get or create a per-incident lock to serialize approve/reject/timeout."""
49
+ if incident_id not in self._locks:
50
+ self._locks[incident_id] = asyncio.Lock()
51
+ return self._locks[incident_id]
52
+
53
+ @contextlib.asynccontextmanager
54
+ async def _lifespan(self, app: Starlette) -> AsyncGenerator[None, None]:
55
+ """Recover pending approval timeouts on startup."""
56
+ await self.recover_pending_approvals()
57
+ yield
58
+
59
+ def build_app(self) -> Starlette:
60
+ """Build the Starlette ASGI application."""
61
+ routes = [
62
+ Route(
63
+ "/api/v1/incidents/{incident_id}/approve",
64
+ self.handle_approve,
65
+ methods=["POST"],
66
+ ),
67
+ Route(
68
+ "/api/v1/incidents/{incident_id}/reject",
69
+ self.handle_reject,
70
+ methods=["POST"],
71
+ ),
72
+ Route(
73
+ "/api/v1/incidents/{incident_id}",
74
+ self.handle_status,
75
+ methods=["GET"],
76
+ ),
77
+ Route(
78
+ "/api/v1/slack/interactions",
79
+ self.handle_slack_interaction,
80
+ methods=["POST"],
81
+ ),
82
+ Route("/metrics", self.handle_metrics, methods=["GET"]),
83
+ ]
84
+ return Starlette(routes=routes, lifespan=self._lifespan)
85
+
86
+ async def handle_approve(self, request: Request) -> JSONResponse:
87
+ """POST /api/v1/incidents/{id}/approve"""
88
+ incident_id = request.path_params["incident_id"]
89
+ try:
90
+ body = await request.json() if await request.body() else {}
91
+ except (json.JSONDecodeError, ValueError):
92
+ return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
93
+ approved_by = body.get("approved_by")
94
+
95
+ async with self._get_lock(incident_id):
96
+ try:
97
+ ctx = await self._coordinator.approve(
98
+ incident_id, approved_by=approved_by
99
+ )
100
+ except ValueError as exc:
101
+ msg = str(exc)
102
+ if "not found" in msg.lower():
103
+ return JSONResponse({"error": msg}, status_code=404)
104
+ return JSONResponse({"error": msg}, status_code=409)
105
+
106
+ self.cancel_timeout(incident_id)
107
+
108
+ return JSONResponse({
109
+ "incident_id": ctx.id,
110
+ "state": ctx.state.value,
111
+ "action": ctx.remediation.proposed_action if ctx.remediation else None,
112
+ "target": ctx.remediation.target if ctx.remediation else None,
113
+ "approved_by": approved_by,
114
+ "execution_result": ctx.remediation.execution_result if ctx.remediation else None,
115
+ "verdict_id": ctx.verdict_chain[-1] if ctx.verdict_chain else None,
116
+ })
117
+
118
+ async def handle_reject(self, request: Request) -> JSONResponse:
119
+ """POST /api/v1/incidents/{id}/reject"""
120
+ incident_id = request.path_params["incident_id"]
121
+ try:
122
+ body = await request.json() if await request.body() else {}
123
+ except (json.JSONDecodeError, ValueError):
124
+ return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
125
+ reason = body.get("reason")
126
+ rejected_by = body.get("rejected_by")
127
+
128
+ if not reason:
129
+ return JSONResponse(
130
+ {"error": "reason is required"}, status_code=400
131
+ )
132
+
133
+ async with self._get_lock(incident_id):
134
+ try:
135
+ ctx = await self._coordinator.reject(
136
+ incident_id, reason, rejected_by=rejected_by
137
+ )
138
+ except ValueError as exc:
139
+ msg = str(exc)
140
+ if "not found" in msg.lower():
141
+ return JSONResponse({"error": msg}, status_code=404)
142
+ return JSONResponse({"error": msg}, status_code=409)
143
+
144
+ self.cancel_timeout(incident_id)
145
+
146
+ return JSONResponse({
147
+ "incident_id": ctx.id,
148
+ "state": ctx.state.value,
149
+ "rejected_by": rejected_by,
150
+ "reason": reason,
151
+ })
152
+
153
+ async def handle_status(self, request: Request) -> JSONResponse:
154
+ """GET /api/v1/incidents/{id}"""
155
+ incident_id = request.path_params["incident_id"]
156
+ ctx = self._context_store.load(incident_id)
157
+
158
+ if ctx is None:
159
+ return JSONResponse(
160
+ {"error": f"Incident {incident_id!r} not found"}, status_code=404
161
+ )
162
+
163
+ result: dict[str, Any] = {
164
+ "incident_id": ctx.id,
165
+ "state": ctx.state.value,
166
+ "created_at": ctx.created_at,
167
+ "updated_at": ctx.updated_at,
168
+ "trigger_source": ctx.trigger_source,
169
+ }
170
+ if ctx.remediation:
171
+ result["proposed_action"] = ctx.remediation.proposed_action
172
+ result["target"] = ctx.remediation.target
173
+ result["requires_human_approval"] = ctx.remediation.requires_human_approval
174
+ result["executed"] = ctx.remediation.executed
175
+ if ctx.triage:
176
+ result["severity"] = ctx.triage.severity
177
+ return JSONResponse(result)
178
+
179
+ async def handle_slack_interaction(self, request: Request) -> Response:
180
+ """POST /api/v1/slack/interactions — Slack callback endpoint."""
181
+ signing_secret = self._config.slack_signing_secret
182
+ if not signing_secret:
183
+ return Response(
184
+ content=json.dumps({"error": "Slack signing secret not configured"}),
185
+ status_code=403,
186
+ media_type="application/json",
187
+ )
188
+
189
+ body = await request.body()
190
+ timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
191
+ signature = request.headers.get("X-Slack-Signature", "")
192
+
193
+ if not SlackWebClient.verify_signature(signing_secret, timestamp, body, signature):
194
+ return Response(status_code=401)
195
+
196
+ # Slack sends payload as form-encoded
197
+ form = await request.form()
198
+ payload_str = form.get("payload", "")
199
+ try:
200
+ payload = json.loads(payload_str)
201
+ except (json.JSONDecodeError, TypeError):
202
+ return Response(status_code=400)
203
+
204
+ actions = payload.get("actions", [])
205
+ if not actions:
206
+ return Response(status_code=200)
207
+
208
+ action = actions[0]
209
+ action_id = action.get("action_id")
210
+ incident_id = action.get("value")
211
+ if not incident_id:
212
+ return Response(status_code=200)
213
+
214
+ user = payload.get("user") or {}
215
+ user_name = user.get("name", "unknown") if isinstance(user, dict) else "unknown"
216
+ channel = payload.get("channel") or {}
217
+ channel_id = channel.get("id") if isinstance(channel, dict) else None
218
+ message = payload.get("message") or {}
219
+ message_ts = message.get("ts") if isinstance(message, dict) else None
220
+
221
+ async with self._get_lock(incident_id):
222
+ try:
223
+ if action_id == "approve":
224
+ ctx = await self._coordinator.approve(
225
+ incident_id, approved_by=user_name
226
+ )
227
+ elif action_id == "reject":
228
+ ctx = await self._coordinator.reject(
229
+ incident_id,
230
+ f"Rejected via Slack by {user_name}",
231
+ rejected_by=user_name,
232
+ )
233
+ else:
234
+ return Response(status_code=200)
235
+ except ValueError as exc:
236
+ logger.warning("Slack interaction failed: %s", exc)
237
+ return Response(status_code=200) # Slack expects 200
238
+
239
+ self.cancel_timeout(incident_id)
240
+
241
+ # Update original message to remove buttons (fire-and-forget)
242
+ if self._config.slack_bot_token and channel_id and message_ts:
243
+ asyncio.create_task(
244
+ self._update_slack_message(
245
+ channel_id, message_ts, action_id, user_name, ctx
246
+ )
247
+ )
248
+
249
+ return Response(status_code=200)
250
+
251
+ async def handle_metrics(self, request: Request) -> Response:
252
+ """GET /metrics — Prometheus text exposition."""
253
+ if self._metrics is None:
254
+ return Response(content="", media_type="text/plain")
255
+ text = self._metrics.collect()
256
+ return Response(
257
+ content=text,
258
+ media_type="text/plain; version=0.0.4; charset=utf-8",
259
+ )
260
+
261
+ async def _update_slack_message(
262
+ self,
263
+ channel_id: str,
264
+ message_ts: str,
265
+ action_id: str,
266
+ user_name: str,
267
+ ctx: Any,
268
+ ) -> None:
269
+ """Replace buttons with confirmation text in the original Slack message."""
270
+ client = SlackWebClient(self._config.slack_bot_token)
271
+
272
+ if action_id == "approve":
273
+ status_text = f"\u2705 Approved by @{user_name}"
274
+ else:
275
+ status_text = f"\u274c Rejected by @{user_name}"
276
+
277
+ blocks = [
278
+ {"type": "section", "text": {"type": "mrkdwn", "text": f"*{status_text}*"}},
279
+ {"type": "context", "elements": [
280
+ {"type": "mrkdwn", "text": f"State: {ctx.state.value} \u00b7 nthlayer-respond"},
281
+ ]},
282
+ ]
283
+
284
+ try:
285
+ await client.update_message(channel_id, message_ts, blocks, status_text)
286
+ except Exception as exc:
287
+ logger.warning("Slack message update failed: %s", exc)
288
+
289
+ def start_timeout(self, incident_id: str) -> None:
290
+ """Start a background timeout task for an incident."""
291
+ self.cancel_timeout(incident_id)
292
+ task = asyncio.create_task(self._timeout_task(incident_id))
293
+ self._timeouts[incident_id] = task
294
+
295
+ def cancel_timeout(self, incident_id: str) -> None:
296
+ """Cancel the timeout task for an incident if active."""
297
+ task = self._timeouts.pop(incident_id, None)
298
+ if task and not task.done():
299
+ task.cancel()
300
+ self._locks.pop(incident_id, None)
301
+
302
+ async def _timeout_task(self, incident_id: str, delay: float | None = None) -> None:
303
+ """Wait for delay seconds, then auto-reject if still awaiting approval.
304
+
305
+ If delay is None, uses approval_timeout_seconds from config.
306
+ """
307
+ wait = delay if delay is not None else self._config.approval_timeout_seconds
308
+ try:
309
+ await asyncio.sleep(wait)
310
+ except asyncio.CancelledError:
311
+ return
312
+
313
+ async with self._get_lock(incident_id):
314
+ ctx = self._context_store.load(incident_id)
315
+ if ctx is None or ctx.state != IncidentState.AWAITING_APPROVAL:
316
+ return
317
+
318
+ try:
319
+ await self._coordinator.reject(
320
+ incident_id,
321
+ f"Approval timed out after {self._config.approval_timeout_seconds}s",
322
+ rejected_by="system/timeout",
323
+ )
324
+ logger.info("Approval timed out", extra={"incident_id": incident_id})
325
+ except Exception as exc:
326
+ logger.warning("Timeout reject failed: %s", exc)
327
+ finally:
328
+ self._timeouts.pop(incident_id, None)
329
+
330
+ async def recover_pending_approvals(self) -> None:
331
+ """On startup, scan for AWAITING_APPROVAL incidents and start timeouts."""
332
+ active = self._context_store.list_active()
333
+ for incident_id in active:
334
+ ctx = self._context_store.load(incident_id)
335
+ if ctx is None or ctx.state != IncidentState.AWAITING_APPROVAL:
336
+ continue
337
+
338
+ try:
339
+ updated = datetime.fromisoformat(ctx.updated_at)
340
+ elapsed = _time.time() - updated.replace(tzinfo=timezone.utc).timestamp()
341
+ remaining = self._config.approval_timeout_seconds - elapsed
342
+ except (ValueError, TypeError):
343
+ remaining = self._config.approval_timeout_seconds
344
+
345
+ if remaining <= 0:
346
+ try:
347
+ await self._coordinator.reject(
348
+ incident_id,
349
+ "Approval timed out (expired during server downtime)",
350
+ rejected_by="system/timeout",
351
+ )
352
+ except Exception as exc:
353
+ logger.warning("Timeout recovery reject failed: %s", exc)
354
+ else:
355
+ self.cancel_timeout(incident_id)
356
+ task = asyncio.create_task(self._timeout_task(incident_id, delay=remaining))
357
+ self._timeouts[incident_id] = task
@@ -0,0 +1 @@
1
+ """SRE experience features — templates over verdicts."""