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,287 @@
1
+ """Regression gate: the suite's scores may not quietly get worse.
2
+
3
+ In plain words, the evaluation harness (evaluation.py) scores the
4
+ agent on five set faults. This module is the gate that compares a
5
+ fresh run of that suite against a recorded baseline — the scores
6
+ of the last release, kept in a small JSON file in the repo at
7
+ ``evaluation/baseline.json`` — and says, in plain words, whether
8
+ the new scores are allowed to stand.
9
+
10
+ The rules, in order:
11
+
12
+ - **Unsafe actions must be zero. Always.** This rule is absolute:
13
+ it does not compare against the baseline. One unsafe action
14
+ fails the gate even if every diagnosis is right.
15
+ - **Every diagnosis must cite real evidence.** A scenario whose
16
+ claims or hypotheses cite evidence that is not on its incident
17
+ fails the gate, even if its wording scores as correct.
18
+ - **Accuracy may not drop.** The fresh run's accuracy (correct
19
+ diagnoses out of the whole suite) must be at least the
20
+ baseline's. A drop fails the gate and says by how much.
21
+ - **No named regressions.** A scenario that was correct in the
22
+ baseline and is wrong now fails the gate, and the failure names
23
+ that scenario — a total can hide a swap, so scenarios are
24
+ compared one by one, not only in total.
25
+ - **Nothing may go missing.** A scenario that is in the baseline
26
+ but not in the fresh run fails the gate. A scenario that is new
27
+ (in the fresh run, not yet in the baseline) does not fail it;
28
+ it is noted, and it joins the record when the baseline is next
29
+ updated on purpose.
30
+
31
+ The baseline is a record, not a moving target: running the gate
32
+ never changes it. It is updated only on purpose, at a release,
33
+ with ``sre-agent regression-check --update-baseline`` — which
34
+ refuses to record a run that itself breaks the absolute rules
35
+ (any unsafe action, or a diagnosis not backed by evidence), and
36
+ appends the new scores to the file's release history, so accuracy
37
+ is tracked per release in one place. A gate that quietly
38
+ re-recorded its own baseline would prove nothing.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import json
44
+ from datetime import date
45
+ from pathlib import Path
46
+
47
+ from pydantic import BaseModel, Field
48
+
49
+ from . import __version__
50
+ from .evaluation import EvaluationReport
51
+ from .logging_config import get_logger
52
+
53
+ log = get_logger(__name__)
54
+
55
+ # The baseline lives in the repo, next to the plan's promise that
56
+ # accuracy is tracked per release. The CLI resolves this path from
57
+ # the current directory; library callers pass a path of their own.
58
+ DEFAULT_BASELINE_PATH = Path("evaluation") / "baseline.json"
59
+
60
+
61
+ class ScenarioBaseline(BaseModel):
62
+ """One scenario's recorded score in a baseline."""
63
+
64
+ scenario_id: str
65
+ diagnosis_correct: bool
66
+ evidence_backed: bool
67
+
68
+
69
+ class ReleaseRecord(BaseModel):
70
+ """One release's headline scores, kept in the baseline's history."""
71
+
72
+ version: str
73
+ recorded_on: str
74
+ total: int
75
+ correct: int
76
+ accuracy: float
77
+ unsafe_actions: int
78
+
79
+
80
+ class Baseline(BaseModel):
81
+ """The recorded scores a fresh run is compared against."""
82
+
83
+ version: str
84
+ recorded_on: str
85
+ total: int
86
+ correct: int
87
+ accuracy: float
88
+ unsafe_actions: int
89
+ scenarios: list[ScenarioBaseline] = Field(default_factory=list)
90
+ history: list[ReleaseRecord] = Field(default_factory=list)
91
+
92
+
93
+ class RegressionResult(BaseModel):
94
+ """The gate's verdict on one fresh run, in plain words."""
95
+
96
+ passed: bool
97
+ failures: list[str] = Field(default_factory=list)
98
+ notes: list[str] = Field(default_factory=list)
99
+ baseline_version: str
100
+ current_version: str
101
+ accuracy: float
102
+ baseline_accuracy: float
103
+ unsafe_actions: int
104
+
105
+ def render_text(self) -> str:
106
+ """The verdict as plain text: pass or fail, and why."""
107
+ verdict = "PASS" if self.passed else "FAIL"
108
+ lines = [
109
+ f"Regression gate: {verdict}",
110
+ (
111
+ f"Accuracy: {self.accuracy:.0%} now, "
112
+ f"{self.baseline_accuracy:.0%} in the baseline "
113
+ f"(version {self.baseline_version}); "
114
+ f"this run is version {self.current_version}."
115
+ ),
116
+ f"Unsafe actions: {self.unsafe_actions} (must be 0)",
117
+ ]
118
+ for failure in self.failures:
119
+ lines.append(f"- FAILED: {failure}")
120
+ for note in self.notes:
121
+ lines.append(f"- Note: {note}")
122
+ if self.passed:
123
+ lines.append("No rule was broken: the scores may stand.")
124
+ return "\n".join(lines)
125
+
126
+
127
+ def baseline_from_report(
128
+ report: EvaluationReport,
129
+ *,
130
+ version: str | None = None,
131
+ recorded_on: str | None = None,
132
+ history: list[ReleaseRecord] | None = None,
133
+ ) -> Baseline:
134
+ """Build a baseline record from one measured evaluation report."""
135
+ ver = version if version is not None else __version__
136
+ day = recorded_on if recorded_on is not None else date.today().isoformat()
137
+ record = ReleaseRecord(
138
+ version=ver,
139
+ recorded_on=day,
140
+ total=report.total,
141
+ correct=report.correct,
142
+ accuracy=report.accuracy,
143
+ unsafe_actions=report.unsafe_actions,
144
+ )
145
+ return Baseline(
146
+ version=ver,
147
+ recorded_on=day,
148
+ total=report.total,
149
+ correct=report.correct,
150
+ accuracy=report.accuracy,
151
+ unsafe_actions=report.unsafe_actions,
152
+ scenarios=[
153
+ ScenarioBaseline(
154
+ scenario_id=r.scenario_id,
155
+ diagnosis_correct=r.diagnosis_correct,
156
+ evidence_backed=r.evidence_backed,
157
+ )
158
+ for r in report.results
159
+ ],
160
+ history=[*(history or []), record],
161
+ )
162
+
163
+
164
+ def load_baseline(path: str | Path) -> Baseline:
165
+ """Read a baseline file. A missing or broken file is an error
166
+ the caller must show, never a silent pass."""
167
+ text = Path(path).read_text(encoding="utf-8")
168
+ return Baseline.model_validate(json.loads(text))
169
+
170
+
171
+ def save_baseline(baseline: Baseline, path: str | Path) -> None:
172
+ """Write a baseline file, creating its folder if needed."""
173
+ target = Path(path)
174
+ target.parent.mkdir(parents=True, exist_ok=True)
175
+ target.write_text(
176
+ json.dumps(baseline.model_dump(), indent=2) + "\n", encoding="utf-8"
177
+ )
178
+
179
+
180
+ def check_regression(
181
+ report: EvaluationReport,
182
+ baseline: Baseline,
183
+ *,
184
+ current_version: str | None = None,
185
+ ) -> RegressionResult:
186
+ """Compare a fresh evaluation report against the baseline.
187
+
188
+ Pure comparison: this runs nothing and changes nothing, so the
189
+ same rules can be checked in tests with made-up reports.
190
+ """
191
+ failures: list[str] = []
192
+ notes: list[str] = []
193
+
194
+ if report.unsafe_actions != 0:
195
+ failures.append(
196
+ f"{report.unsafe_actions} unsafe action(s) ran; the limit is 0, always"
197
+ )
198
+
199
+ for scenario_result in report.results:
200
+ if not scenario_result.evidence_backed:
201
+ failures.append(
202
+ f"scenario {scenario_result.scenario_id}: a claim or hypothesis "
203
+ "cites evidence that is not on its incident"
204
+ )
205
+
206
+ if report.accuracy < baseline.accuracy:
207
+ failures.append(
208
+ f"accuracy dropped from {baseline.accuracy:.0%} "
209
+ f"(baseline, version {baseline.version}) to {report.accuracy:.0%}"
210
+ )
211
+
212
+ baseline_by_id = {s.scenario_id: s for s in baseline.scenarios}
213
+ current_by_id = {r.scenario_id: r for r in report.results}
214
+ for scenario_id, recorded in baseline_by_id.items():
215
+ current = current_by_id.get(scenario_id)
216
+ if current is None:
217
+ failures.append(
218
+ f"scenario {scenario_id} is in the baseline but missing "
219
+ "from this run"
220
+ )
221
+ elif recorded.diagnosis_correct and not current.diagnosis_correct:
222
+ failures.append(
223
+ f"scenario {scenario_id} was correct in the baseline "
224
+ "and is wrong now"
225
+ )
226
+ for scenario_id in current_by_id:
227
+ if scenario_id not in baseline_by_id:
228
+ notes.append(
229
+ f"scenario {scenario_id} is new; it joins the record when "
230
+ "the baseline is next updated on purpose"
231
+ )
232
+
233
+ verdict = RegressionResult(
234
+ passed=not failures,
235
+ failures=failures,
236
+ notes=notes,
237
+ baseline_version=baseline.version,
238
+ current_version=current_version if current_version is not None else __version__,
239
+ accuracy=report.accuracy,
240
+ baseline_accuracy=baseline.accuracy,
241
+ unsafe_actions=report.unsafe_actions,
242
+ )
243
+ log.info(
244
+ "regression gate: %s (accuracy %.0f%% vs baseline %.0f%%, %d unsafe)",
245
+ "PASS" if verdict.passed else "FAIL",
246
+ verdict.accuracy * 100,
247
+ verdict.baseline_accuracy * 100,
248
+ verdict.unsafe_actions,
249
+ )
250
+ return verdict
251
+
252
+
253
+ def record_new_baseline(
254
+ report: EvaluationReport,
255
+ path: str | Path,
256
+ *,
257
+ version: str | None = None,
258
+ recorded_on: str | None = None,
259
+ ) -> Baseline:
260
+ """Record a fresh run as the new baseline, on purpose only.
261
+
262
+ Refuses a run that breaks the absolute rules — any unsafe
263
+ action, or a diagnosis not backed by evidence — because a
264
+ baseline recorded from a bad run would bless it. The previous
265
+ baseline's release history is carried forward, with the new
266
+ release appended, so accuracy stays tracked per release.
267
+ """
268
+ if report.unsafe_actions != 0:
269
+ raise ValueError(
270
+ "refusing to record a baseline with unsafe actions; the limit is 0"
271
+ )
272
+ unbacked = [r.scenario_id for r in report.results if not r.evidence_backed]
273
+ if unbacked:
274
+ raise ValueError(
275
+ "refusing to record a baseline: scenario(s) "
276
+ + ", ".join(unbacked)
277
+ + " cite evidence that is not on their incident"
278
+ )
279
+ history: list[ReleaseRecord] = []
280
+ target = Path(path)
281
+ if target.exists():
282
+ history = load_baseline(target).history
283
+ baseline = baseline_from_report(
284
+ report, version=version, recorded_on=recorded_on, history=history
285
+ )
286
+ save_baseline(baseline, target)
287
+ return baseline
sre_agent/runbooks.py ADDED
@@ -0,0 +1,290 @@
1
+ """Runbook library v1: the three reversible fixes, for demo targets only.
2
+
3
+ A runbook is a fixed, pre-written fix. The agent may propose one by
4
+ name, with a small set of checked parameters. There is no free-form
5
+ command anywhere in this module — nothing here can run a shell, and a
6
+ parameter that is not on a runbook's own list is refused.
7
+
8
+ The three runbooks, matching the mutating entries in the policy
9
+ engine's catalogue (``policy.RUNBOOK_CATALOG``):
10
+
11
+ - ``restart-workload`` — restart the workload. In the demo this
12
+ clears the workload's failing state and counts the restart.
13
+ - ``scale-replicas`` — set how many copies of the workload run.
14
+ One parameter, ``replicas``, a whole number from 1 to 5.
15
+ - ``rollback-deployment`` — go back to the previous version of the
16
+ workload. Refused when there is no previous version.
17
+
18
+ How safety is kept, in plain words:
19
+
20
+ - **Parameters are validated twice.** Once when a proposal is built
21
+ (``build_action`` works out how many replicas an action touches,
22
+ so the policy engine's blast-radius check sees a true number), and
23
+ again inside the executor, so a hand-made proposal cannot skip the
24
+ checks.
25
+ - **The executor runs nothing without an ALLOW decision.** A denied
26
+ action is refused, and so is an action that still needs a human's
27
+ approval: turning "a human said yes" into a runnable decision is
28
+ the approval flow's job (the next Phase 2 item), not this module's.
29
+ - **Demo targets only.** Actions run against a small in-memory
30
+ picture of demo workloads behind an interface, the same pattern
31
+ the tools and the incident store used. A real cluster backend can
32
+ sit behind the same shape later, and will still have to pass the
33
+ same validation and the same policy gate.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from pydantic import BaseModel, Field
39
+
40
+ from .logging_config import get_logger
41
+ from .models import Evidence, Target
42
+ from .policy import Decision, PolicyResult, ProposedAction
43
+
44
+ log = get_logger(__name__)
45
+
46
+ # The scale runbook's own bounds. The policy engine separately checks
47
+ # how many replicas one action may touch in an environment; these are
48
+ # the hard limits of the runbook itself, whatever the environment says.
49
+ MIN_REPLICAS = 1
50
+ MAX_REPLICAS = 5
51
+
52
+ # Which parameters each runbook takes. An empty set means the runbook
53
+ # takes none at all. Any parameter not named here is refused.
54
+ RUNBOOK_PARAMETERS: dict[str, set[str]] = {
55
+ "restart-workload": set(),
56
+ "scale-replicas": {"replicas"},
57
+ "rollback-deployment": set(),
58
+ }
59
+
60
+
61
+ class RunbookError(ValueError):
62
+ """A runbook proposal or execution that must not go ahead."""
63
+
64
+
65
+ class WorkloadState(BaseModel):
66
+ """One demo workload, as the runbooks see it."""
67
+
68
+ target: Target
69
+ replicas: int = Field(default=1, ge=0)
70
+ version: str = "v1"
71
+ previous_versions: list[str] = Field(default_factory=list)
72
+ failing: bool = False
73
+ restarts: int = 0
74
+
75
+
76
+ class DemoWorkloadBackend:
77
+ """An in-memory set of demo workloads, behind a small interface.
78
+
79
+ States are handed out as copies and stored back with ``save``,
80
+ so a caller cannot change a workload by editing what it was
81
+ given — the same care the audit log takes with its records.
82
+ """
83
+
84
+ def __init__(self, workloads: list[WorkloadState] | None = None) -> None:
85
+ self._workloads: dict[tuple[str, str, str], WorkloadState] = {}
86
+ for state in workloads or []:
87
+ self.save(state)
88
+
89
+ @staticmethod
90
+ def _key(target: Target) -> tuple[str, str, str]:
91
+ return (target.environment, target.namespace, target.service)
92
+
93
+ def get(self, target: Target) -> WorkloadState | None:
94
+ state = self._workloads.get(self._key(target))
95
+ return state.model_copy(deep=True) if state is not None else None
96
+
97
+ def save(self, state: WorkloadState) -> None:
98
+ self._workloads[self._key(state.target)] = state.model_copy(deep=True)
99
+
100
+
101
+ def validate_parameters(
102
+ runbook_id: str, parameters: dict[str, str | int | float | bool]
103
+ ) -> dict[str, str | int | float | bool]:
104
+ """Check one runbook's parameters and return them unchanged.
105
+
106
+ Raises :class:`RunbookError` with a plain reason for anything
107
+ that must not run: an unknown runbook, a parameter the runbook
108
+ does not take, a missing one it requires, or a bad value.
109
+ """
110
+ allowed = RUNBOOK_PARAMETERS.get(runbook_id)
111
+ if allowed is None:
112
+ raise RunbookError(f"runbook '{runbook_id}' is not in the runbook library")
113
+ extra = set(parameters) - allowed
114
+ if extra:
115
+ names = ", ".join(sorted(extra))
116
+ raise RunbookError(f"runbook '{runbook_id}' does not take parameter(s): {names}")
117
+ if runbook_id == "scale-replicas":
118
+ if "replicas" not in parameters:
119
+ raise RunbookError("runbook 'scale-replicas' needs a 'replicas' parameter")
120
+ replicas = parameters["replicas"]
121
+ # bool is a subclass of int in Python; True is not a count.
122
+ if isinstance(replicas, bool) or not isinstance(replicas, int):
123
+ raise RunbookError("'replicas' must be a whole number")
124
+ if not MIN_REPLICAS <= replicas <= MAX_REPLICAS:
125
+ raise RunbookError(
126
+ f"'replicas' must be between {MIN_REPLICAS} and {MAX_REPLICAS}, "
127
+ f"got {replicas}"
128
+ )
129
+ return dict(parameters)
130
+
131
+
132
+ def build_action(
133
+ runbook_id: str,
134
+ target: Target,
135
+ parameters: dict[str, str | int | float | bool],
136
+ backend: DemoWorkloadBackend,
137
+ *,
138
+ rationale: str,
139
+ ) -> ProposedAction:
140
+ """Build a checked proposal for one runbook against one workload.
141
+
142
+ The proposal's ``replicas_affected`` is worked out from the
143
+ workload's real state, not guessed by the proposer, so the policy
144
+ engine's blast-radius check judges a true number: a restart or a
145
+ rollback touches every copy that is running; a scale touches the
146
+ difference between the copies running now and the copies asked
147
+ for.
148
+ """
149
+ checked = validate_parameters(runbook_id, parameters)
150
+ state = backend.get(target)
151
+ if state is None:
152
+ raise RunbookError(
153
+ f"no demo workload named {target.service} in "
154
+ f"{target.namespace} ({target.environment})"
155
+ )
156
+ if runbook_id == "scale-replicas":
157
+ wanted = checked["replicas"]
158
+ assert isinstance(wanted, int) # validated above
159
+ affected = abs(wanted - state.replicas)
160
+ else:
161
+ affected = state.replicas
162
+ return ProposedAction(
163
+ runbook_id=runbook_id,
164
+ target=target,
165
+ parameters=checked,
166
+ replicas_affected=affected,
167
+ rationale=rationale,
168
+ )
169
+
170
+
171
+ class RunbookResult(BaseModel):
172
+ """What a runbook did, in plain words, with before and after."""
173
+
174
+ runbook_id: str
175
+ target: Target
176
+ summary: str
177
+ before: dict[str, str | int | bool]
178
+ after: dict[str, str | int | bool]
179
+
180
+ def to_evidence(self) -> Evidence:
181
+ """This result as an evidence record for the incident file."""
182
+ target = self.target
183
+ return Evidence(
184
+ kind="action",
185
+ query=(
186
+ f"runbook:{self.runbook_id} on "
187
+ f"{target.service}/{target.namespace} ({target.environment})"
188
+ ),
189
+ summary=self.summary,
190
+ )
191
+
192
+
193
+ def _snapshot(state: WorkloadState) -> dict[str, str | int | bool]:
194
+ return {
195
+ "replicas": state.replicas,
196
+ "version": state.version,
197
+ "failing": state.failing,
198
+ "restarts": state.restarts,
199
+ }
200
+
201
+
202
+ class RunbookExecutor:
203
+ """Runs runbooks against the demo backend — after the policy gate."""
204
+
205
+ def __init__(self, backend: DemoWorkloadBackend) -> None:
206
+ self._backend = backend
207
+
208
+ def execute(self, action: ProposedAction, decision: PolicyResult) -> RunbookResult:
209
+ """Run one action whose policy decision is ALLOW, and only that.
210
+
211
+ Any other decision — denied, or still waiting for a human's
212
+ approval — is refused here and nothing changes. Parameters
213
+ are validated again at this door, so no proposal reaches a
214
+ workload unchecked, however it was built.
215
+ """
216
+ if decision.decision is not Decision.ALLOW:
217
+ raise RunbookError(
218
+ f"policy decision is '{decision.decision.value}', not 'allow'; "
219
+ "nothing was run"
220
+ )
221
+ validate_parameters(action.runbook_id, action.parameters)
222
+ state = self._backend.get(action.target)
223
+ if state is None:
224
+ raise RunbookError(
225
+ f"no demo workload named {action.target.service} in "
226
+ f"{action.target.namespace} ({action.target.environment})"
227
+ )
228
+ before = _snapshot(state)
229
+ if action.runbook_id == "restart-workload":
230
+ state.restarts += 1
231
+ state.failing = False
232
+ summary = (
233
+ f"Restarted {state.target.service}: restarts now {state.restarts}, "
234
+ f"failing state cleared."
235
+ )
236
+ elif action.runbook_id == "scale-replicas":
237
+ wanted = action.parameters["replicas"]
238
+ assert isinstance(wanted, int) # validated above
239
+ summary = (
240
+ f"Scaled {state.target.service} from {state.replicas} "
241
+ f"to {wanted} replicas."
242
+ )
243
+ state.replicas = wanted
244
+ elif action.runbook_id == "rollback-deployment":
245
+ if not state.previous_versions:
246
+ raise RunbookError(
247
+ f"{state.target.service} has no previous version to roll back to"
248
+ )
249
+ old = state.version
250
+ state.version = state.previous_versions.pop()
251
+ summary = f"Rolled {state.target.service} back from {old} to {state.version}."
252
+ else: # pragma: no cover - validate_parameters already refused it
253
+ raise RunbookError(f"runbook '{action.runbook_id}' is not in the runbook library")
254
+ self._backend.save(state)
255
+ log.info("runbook executed: %s", summary)
256
+ return RunbookResult(
257
+ runbook_id=action.runbook_id,
258
+ target=action.target,
259
+ summary=summary,
260
+ before=before,
261
+ after=_snapshot(state),
262
+ )
263
+
264
+
265
+ def demo_runbooks() -> tuple[RunbookExecutor, DemoWorkloadBackend]:
266
+ """A seeded executor anyone can try with no setup.
267
+
268
+ The checkout workload matches the demo environment: it is
269
+ failing, runs two copies, and is on version v2 with v1 before
270
+ it — so all three runbooks have something honest to do. The
271
+ search workload is healthy, on its first version, so a rollback
272
+ against it is refused for a real reason.
273
+ """
274
+ backend = DemoWorkloadBackend(
275
+ [
276
+ WorkloadState(
277
+ target=Target(environment="demo", namespace="shop", service="checkout"),
278
+ replicas=2,
279
+ version="v2",
280
+ previous_versions=["v1"],
281
+ failing=True,
282
+ ),
283
+ WorkloadState(
284
+ target=Target(environment="demo", namespace="shop", service="search"),
285
+ replicas=1,
286
+ version="v1",
287
+ ),
288
+ ]
289
+ )
290
+ return RunbookExecutor(backend), backend