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,226 @@
1
+ """Policy condition evaluator.
2
+
3
+ Parses and evaluates condition strings against a context dictionary.
4
+ Supports a simple DSL for time, SLO, and service-based conditions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime
12
+ from typing import Any, Callable
13
+
14
+ from nthlayer_workers.observe.gate.conditions import (
15
+ is_business_hours,
16
+ is_freeze_period,
17
+ is_peak_traffic,
18
+ is_weekday,
19
+ )
20
+
21
+
22
+ @dataclass
23
+ class PolicyContext:
24
+ """Context for policy evaluation with service and SLO data."""
25
+
26
+ budget_remaining: float = 100.0
27
+ budget_consumed: float = 0.0
28
+ burn_rate: float = 1.0
29
+ tier: str = "standard"
30
+ environment: str = "prod"
31
+ service: str = ""
32
+ team: str = ""
33
+ downstream_count: int = 0
34
+ high_criticality_downstream: int = 0
35
+ now: datetime | None = None
36
+
37
+ def to_dict(self) -> dict[str, Any]:
38
+ """Convert to dictionary for evaluation. Delegates to get_current_context."""
39
+ from nthlayer_workers.observe.gate.conditions import get_current_context
40
+
41
+ ctx = get_current_context(
42
+ budget_remaining=self.budget_remaining,
43
+ budget_consumed=self.budget_consumed,
44
+ burn_rate=self.burn_rate,
45
+ tier=self.tier,
46
+ environment=self.environment,
47
+ downstream_count=self.downstream_count,
48
+ high_criticality_downstream=self.high_criticality_downstream,
49
+ now=self.now,
50
+ )
51
+ ctx["service"] = self.service
52
+ ctx["team"] = self.team
53
+ return ctx
54
+
55
+
56
+ @dataclass
57
+ class EvaluationResult:
58
+ """Result of condition evaluation."""
59
+
60
+ condition: str
61
+ result: bool
62
+ matched_rule: str | None = None
63
+ context_snapshot: dict[str, Any] = field(default_factory=dict)
64
+
65
+
66
+ class ConditionEvaluator:
67
+ """Evaluates policy conditions against a context.
68
+
69
+ Supports comparisons, boolean operators (AND/OR/NOT), parentheses,
70
+ and built-in functions (business_hours, freeze_period, etc.).
71
+ """
72
+
73
+ OPERATORS: dict[str, Any] = {
74
+ "==": lambda a, b: a == b,
75
+ "!=": lambda a, b: a != b,
76
+ ">=": lambda a, b: a >= b,
77
+ "<=": lambda a, b: a <= b,
78
+ ">": lambda a, b: a > b,
79
+ "<": lambda a, b: a < b,
80
+ }
81
+
82
+ FUNCTIONS: dict[str, Callable[..., bool]] = {
83
+ "business_hours": is_business_hours,
84
+ "weekday": is_weekday,
85
+ "freeze_period": is_freeze_period,
86
+ "peak_traffic": is_peak_traffic,
87
+ }
88
+
89
+ def __init__(self, context: PolicyContext | dict[str, Any] | None = None):
90
+ self._policy_context: PolicyContext | None = None
91
+ if context is None:
92
+ self.context: dict[str, Any] = {}
93
+ elif isinstance(context, PolicyContext):
94
+ self.context = context.to_dict()
95
+ self._policy_context = context
96
+ else:
97
+ self.context = context
98
+
99
+ def evaluate(self, condition: str) -> bool:
100
+ """Evaluate a condition string. Empty condition = always true."""
101
+ if not condition or not condition.strip():
102
+ return True
103
+
104
+ condition = " ".join(condition.split())
105
+
106
+ try:
107
+ return self._evaluate_expression(condition)
108
+ except Exception:
109
+ return False # fail safe
110
+
111
+ def _evaluate_expression(self, expr: str) -> bool:
112
+ """Evaluate a boolean expression with AND/OR/NOT."""
113
+ expr = expr.strip()
114
+
115
+ while "(" in expr:
116
+ match = re.search(r"(?<!\w)\(([^()]+)\)", expr)
117
+ if match:
118
+ inner = match.group(1)
119
+ result = self._evaluate_expression(inner)
120
+ expr = expr[: match.start()] + str(result) + expr[match.end() :]
121
+ else:
122
+ break
123
+
124
+ if expr.upper().startswith("NOT "):
125
+ return not self._evaluate_expression(expr[4:])
126
+
127
+ if " OR " in expr.upper():
128
+ parts = re.split(r"\s+OR\s+", expr, flags=re.IGNORECASE)
129
+ return any(self._evaluate_expression(p) for p in parts)
130
+
131
+ if " AND " in expr.upper():
132
+ parts = re.split(r"\s+AND\s+", expr, flags=re.IGNORECASE)
133
+ return all(self._evaluate_expression(p) for p in parts)
134
+
135
+ if expr.lower() == "true":
136
+ return True
137
+ if expr.lower() == "false":
138
+ return False
139
+
140
+ func_match = re.match(r"(\w+)\((.*)\)", expr)
141
+ if func_match:
142
+ return self._evaluate_function(func_match.group(1), func_match.group(2))
143
+
144
+ return self._evaluate_comparison(expr)
145
+
146
+ def _evaluate_comparison(self, expr: str) -> bool:
147
+ """Evaluate a comparison like 'hour >= 9'."""
148
+ for op, func in self.OPERATORS.items():
149
+ if op in expr:
150
+ parts = expr.split(op, 1)
151
+ if len(parts) == 2:
152
+ left = self._resolve_value(parts[0].strip())
153
+ right = self._resolve_value(parts[1].strip())
154
+ return func(left, right)
155
+
156
+ value = self._resolve_value(expr)
157
+ return bool(value)
158
+
159
+ def _resolve_value(self, token: str) -> Any:
160
+ """Resolve a token to its value."""
161
+ token = token.strip()
162
+
163
+ if (token.startswith("'") and token.endswith("'")) or (
164
+ token.startswith('"') and token.endswith('"')
165
+ ):
166
+ return token[1:-1]
167
+
168
+ try:
169
+ if "." in token:
170
+ return float(token)
171
+ return int(token)
172
+ except ValueError:
173
+ pass
174
+
175
+ if token.lower() == "true":
176
+ return True
177
+ if token.lower() == "false":
178
+ return False
179
+
180
+ return self.context.get(token, False)
181
+
182
+ def _evaluate_function(self, name: str, args_str: str) -> bool:
183
+ """Evaluate a function call."""
184
+ name = name.lower()
185
+
186
+ if name not in self.FUNCTIONS:
187
+ return False
188
+
189
+ func = self.FUNCTIONS[name]
190
+
191
+ args = []
192
+ if args_str.strip():
193
+ raw_args = re.split(r",\s*(?=(?:[^']*'[^']*')*[^']*$)", args_str)
194
+ for arg in raw_args:
195
+ args.append(self._resolve_value(arg.strip()))
196
+
197
+ now = None
198
+ if self._policy_context:
199
+ now = self._policy_context.now
200
+
201
+ if name == "freeze_period" and len(args) >= 2:
202
+ return func(args[0], args[1], now=now)
203
+ elif name in ("business_hours", "weekday", "peak_traffic"):
204
+ return func(now=now)
205
+
206
+ return False
207
+
208
+ def evaluate_all(
209
+ self,
210
+ conditions: list[dict[str, Any]],
211
+ ) -> tuple[bool, dict[str, Any] | None]:
212
+ """Evaluate multiple conditions and return the most restrictive match."""
213
+ matched = None
214
+
215
+ for cond in conditions:
216
+ when_clause = cond.get("when", "")
217
+ if self.evaluate(when_clause):
218
+ if matched is None:
219
+ matched = cond
220
+ else:
221
+ curr_block = cond.get("blocking", 0)
222
+ prev_block = matched.get("blocking", 0)
223
+ if curr_block > prev_block:
224
+ matched = cond
225
+
226
+ return (matched is not None, matched)
@@ -0,0 +1,40 @@
1
+ """Read-only assessment store backed by core API.
2
+
3
+ Used by the deploy gate CLI to read historical assessments from core.
4
+ Synchronous wrapper — safe for CLI use only (no running event loop).
5
+ Gate is CLI-only in v1.5; if gate moves to async (v2), use the
6
+ CoreAPIClient directly instead of this adapter.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+
13
+ from nthlayer_common.api_client import CoreAPIClient
14
+
15
+ from nthlayer_workers.observe.assessment import Assessment, from_dict
16
+ from nthlayer_workers.observe.store import AssessmentFilter, AssessmentStore
17
+
18
+
19
+ class CoreAPIAssessmentStore(AssessmentStore):
20
+ """Read-only assessment store backed by core API."""
21
+
22
+ def __init__(self, client: CoreAPIClient):
23
+ self._client = client
24
+
25
+ def put(self, assessment: Assessment) -> None:
26
+ raise NotImplementedError("CoreAPIAssessmentStore is read-only")
27
+
28
+ def get(self, assessment_id: str) -> Assessment | None:
29
+ # Not needed by gate; single-assessment lookup not exposed in v1.5.
30
+ raise NotImplementedError("Single assessment lookup not needed by gate")
31
+
32
+ def query(self, criteria: AssessmentFilter) -> list[Assessment]:
33
+ result = asyncio.run(self._client.get_assessments(
34
+ service=criteria.service,
35
+ kind=criteria.kind,
36
+ limit=criteria.limit if criteria.limit > 0 else 100,
37
+ ))
38
+ if not result.ok:
39
+ return []
40
+ return [from_dict(d) for d in result.data]
@@ -0,0 +1,36 @@
1
+ """Incident envelope creation for the decision records system.
2
+
3
+ Observe creates incident envelopes when a breach assessment triggers a respond cycle.
4
+ The incident_id is assigned at trigger time and propagated to all downstream records.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+ from datetime import datetime, timezone
11
+
12
+ from nthlayer_common.records.models import Incident
13
+ from nthlayer_common.records.sqlite_store import SQLiteDecisionRecordStore
14
+
15
+ __all__ = ["create_incident_from_breach"]
16
+
17
+
18
+ def create_incident_from_breach(
19
+ store: SQLiteDecisionRecordStore,
20
+ trigger_hash: str,
21
+ stream: str,
22
+ ) -> Incident:
23
+ """Create an incident envelope from a breach assessment.
24
+
25
+ The incident is written to the store and returned. The caller should use
26
+ the ``incident.id`` to stamp downstream records (verdicts, evaluations).
27
+ """
28
+ incident_id = f"inc-{uuid.uuid4().hex[:12]}"
29
+ incident = Incident(
30
+ id=incident_id,
31
+ created_at=datetime.now(timezone.utc),
32
+ trigger_hash=trigger_hash,
33
+ stream=stream,
34
+ )
35
+ store.create_incident(incident)
36
+ return incident
@@ -0,0 +1,17 @@
1
+ """Portfolio health aggregation."""
2
+
3
+ from nthlayer_workers.observe.portfolio.aggregator import (
4
+ PortfolioSummary,
5
+ SLOHealth,
6
+ ServiceHealth,
7
+ build_portfolio,
8
+ )
9
+ from nthlayer_workers.observe.portfolio.scorer import score_service
10
+
11
+ __all__ = [
12
+ "PortfolioSummary",
13
+ "SLOHealth",
14
+ "ServiceHealth",
15
+ "build_portfolio",
16
+ "score_service",
17
+ ]
@@ -0,0 +1,168 @@
1
+ """Portfolio aggregation from assessment store or in-memory SLO results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from dataclasses import dataclass, field
7
+
8
+ from nthlayer_workers.observe.slo.collector import SLOResult
9
+ from nthlayer_workers.observe.store import AssessmentFilter, AssessmentStore
10
+
11
+ # Status severity order for worst-status calculation
12
+ _STATUS_SEVERITY = {
13
+ "EXHAUSTED": 4,
14
+ "CRITICAL": 3,
15
+ "WARNING": 2,
16
+ "ERROR": 1,
17
+ "NO_DATA": 0,
18
+ "HEALTHY": -1,
19
+ "UNKNOWN": -2,
20
+ }
21
+
22
+
23
+ @dataclass
24
+ class SLOHealth:
25
+ """Health of a single SLO from assessment data."""
26
+
27
+ name: str
28
+ status: str
29
+ current_sli: float | None = None
30
+ objective: float | None = None
31
+ percent_consumed: float | None = None
32
+
33
+
34
+ @dataclass
35
+ class ServiceHealth:
36
+ """Aggregated health for a service across all its SLOs."""
37
+
38
+ service: str
39
+ slos: list[SLOHealth] = field(default_factory=list)
40
+ overall_status: str = "UNKNOWN"
41
+
42
+ def __post_init__(self) -> None:
43
+ if self.slos and self.overall_status == "UNKNOWN":
44
+ self.overall_status = _worst_status([s.status for s in self.slos])
45
+
46
+
47
+ @dataclass
48
+ class PortfolioSummary:
49
+ """Org-level portfolio health aggregated from assessments."""
50
+
51
+ services: list[ServiceHealth] = field(default_factory=list)
52
+ total_services: int = 0
53
+ healthy_count: int = 0
54
+ warning_count: int = 0
55
+ critical_count: int = 0
56
+ exhausted_count: int = 0
57
+
58
+
59
+ def build_portfolio(store: AssessmentStore) -> PortfolioSummary:
60
+ """Build portfolio summary from recent slo_status assessments.
61
+
62
+ Reads the most recent slo_status assessment per service+SLO combination,
63
+ groups by service, and aggregates health status.
64
+ """
65
+ assessments = store.query(
66
+ AssessmentFilter(kind="slo_status", limit=0)
67
+ )
68
+
69
+ # Group by service, keeping only the latest assessment per service+slo_name
70
+ latest: dict[str, dict[str, dict]] = defaultdict(dict)
71
+ for a in assessments:
72
+ slo_name = a.data.get("slo_name", "unknown")
73
+ key = slo_name
74
+ # Assessments are ordered by timestamp desc, so first seen is latest
75
+ if key not in latest[a.service]:
76
+ latest[a.service][key] = a.data
77
+
78
+ services = []
79
+ for service_name in sorted(latest):
80
+ slos = []
81
+ for slo_name, data in sorted(latest[service_name].items()):
82
+ slos.append(
83
+ SLOHealth(
84
+ name=slo_name,
85
+ status=data.get("status", "UNKNOWN"),
86
+ current_sli=data.get("current_sli"),
87
+ objective=data.get("objective"),
88
+ percent_consumed=data.get("percent_consumed"),
89
+ )
90
+ )
91
+ services.append(ServiceHealth(service=service_name, slos=slos))
92
+
93
+ return _summarise_services(services)
94
+
95
+
96
+ def build_portfolio_from_results(
97
+ results_by_service: dict[str, list[SLOResult]],
98
+ ) -> PortfolioSummary:
99
+ """Build portfolio summary from current-cycle SLO results.
100
+
101
+ Used by ObserveCollectModule.process_cycle() — receives results
102
+ in-memory from the collector, no store round-trip. Parallel to
103
+ build_portfolio(store) which reads from an AssessmentStore.
104
+ """
105
+ services = []
106
+ for service_name in sorted(results_by_service):
107
+ slos = []
108
+ for r in results_by_service[service_name]:
109
+ slos.append(
110
+ SLOHealth(
111
+ name=r.name,
112
+ status=r.status,
113
+ current_sli=r.current_sli,
114
+ objective=r.objective,
115
+ percent_consumed=r.percent_consumed,
116
+ )
117
+ )
118
+ services.append(ServiceHealth(service=service_name, slos=slos))
119
+
120
+ return _summarise_services(services)
121
+
122
+
123
+ def _summarise_services(services: list[ServiceHealth]) -> PortfolioSummary:
124
+ """Build PortfolioSummary from a list of ServiceHealth objects.
125
+
126
+ Shared by build_portfolio (store path) and build_portfolio_from_results
127
+ (in-memory path). Must stay in sync — single implementation.
128
+ """
129
+ status_counts: dict[str, int] = defaultdict(int)
130
+ for svc in services:
131
+ bucket = _status_bucket(svc.overall_status)
132
+ status_counts[bucket] += 1
133
+
134
+ return PortfolioSummary(
135
+ services=services,
136
+ total_services=len(services),
137
+ healthy_count=status_counts["healthy"],
138
+ warning_count=status_counts["warning"],
139
+ critical_count=status_counts["critical"],
140
+ exhausted_count=status_counts["exhausted"],
141
+ )
142
+
143
+
144
+ def _worst_status(statuses: list[str]) -> str:
145
+ """Return the worst status from a list."""
146
+ if not statuses:
147
+ return "UNKNOWN"
148
+ return max(statuses, key=lambda s: _STATUS_SEVERITY.get(s, -2))
149
+
150
+
151
+ def _status_bucket(status: str) -> str:
152
+ """Map a status to a summary bucket.
153
+
154
+ UNKNOWN/NO_DATA/ERROR map to "unknown" which is NOT tracked in
155
+ PortfolioSummary (no unknown_count field). These represent missing
156
+ information, not active failures — they are silently excluded from
157
+ the healthy/warning/critical/exhausted counts.
158
+ """
159
+ buckets = {
160
+ "HEALTHY": "healthy",
161
+ "WARNING": "warning",
162
+ "CRITICAL": "critical",
163
+ "EXHAUSTED": "exhausted",
164
+ "UNKNOWN": "unknown",
165
+ "NO_DATA": "unknown",
166
+ "ERROR": "unknown",
167
+ }
168
+ return buckets.get(status, "unknown")
@@ -0,0 +1,13 @@
1
+ """Simple reliability scoring from portfolio data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from nthlayer_workers.observe.portfolio.aggregator import ServiceHealth
6
+
7
+
8
+ def score_service(health: ServiceHealth) -> float:
9
+ """Score a service based on its SLO compliance (0-100)."""
10
+ if not health.slos:
11
+ return 0.0
12
+ healthy = sum(1 for s in health.slos if s.status == "HEALTHY")
13
+ return (healthy / len(health.slos)) * 100
@@ -0,0 +1,19 @@
1
+ """SLO state collection and storage."""
2
+
3
+ from nthlayer_workers.observe.slo.collector import (
4
+ BudgetSummary,
5
+ SLOMetricCollector,
6
+ SLOResult,
7
+ ServiceSLO,
8
+ results_to_assessments,
9
+ )
10
+ from nthlayer_workers.observe.slo.spec_loader import load_specs
11
+
12
+ __all__ = [
13
+ "BudgetSummary",
14
+ "SLOMetricCollector",
15
+ "SLOResult",
16
+ "ServiceSLO",
17
+ "load_specs",
18
+ "results_to_assessments",
19
+ ]