mayhem-cli 0.5.1__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 (107) hide show
  1. mayhem/agent/__init__.py +1 -0
  2. mayhem/agent/cli.py +36 -0
  3. mayhem/agents/__init__.py +1 -0
  4. mayhem/agents/capabilities.py +106 -0
  5. mayhem/agents/executors.py +430 -0
  6. mayhem/agents/impact.py +729 -0
  7. mayhem/agents/lease_client.py +141 -0
  8. mayhem/agents/probes.py +284 -0
  9. mayhem/agents/protocol.py +134 -0
  10. mayhem/agents/server.py +281 -0
  11. mayhem/agents/sinks.py +60 -0
  12. mayhem/agents/transports.py +134 -0
  13. mayhem/agents/watchdog.py +140 -0
  14. mayhem/cli/__init__.py +11 -0
  15. mayhem/cli/app.py +154 -0
  16. mayhem/cli/campaign.py +496 -0
  17. mayhem/cli/config_cmd.py +47 -0
  18. mayhem/cli/context.py +23 -0
  19. mayhem/cli/dependency.py +429 -0
  20. mayhem/cli/exit_codes.py +24 -0
  21. mayhem/cli/experiment.py +24 -0
  22. mayhem/cli/lifecycle.py +805 -0
  23. mayhem/cli/resolver.py +72 -0
  24. mayhem/cli/services.py +459 -0
  25. mayhem/cli/style.py +101 -0
  26. mayhem/cli/toolkit.py +41 -0
  27. mayhem/cli/topology.py +127 -0
  28. mayhem/config.py +208 -0
  29. mayhem/controller/__init__.py +1 -0
  30. mayhem/controller/compensation.py +2156 -0
  31. mayhem/controller/executor.py +1719 -0
  32. mayhem/controller/janitor.py +196 -0
  33. mayhem/controller/observability_collector.py +382 -0
  34. mayhem/controller/observations.py +102 -0
  35. mayhem/controller/planner.py +715 -0
  36. mayhem/controller/recovery.py +245 -0
  37. mayhem/controller/resilience_report.py +585 -0
  38. mayhem/controller/resource_manager.py +457 -0
  39. mayhem/controller/safety.py +392 -0
  40. mayhem/domain/__init__.py +6 -0
  41. mayhem/domain/campaigns.py +118 -0
  42. mayhem/domain/cancellation.py +110 -0
  43. mayhem/domain/candidates.py +101 -0
  44. mayhem/domain/capabilities.py +86 -0
  45. mayhem/domain/catalog.py +727 -0
  46. mayhem/domain/checks.py +173 -0
  47. mayhem/domain/common.py +104 -0
  48. mayhem/domain/coverage.py +106 -0
  49. mayhem/domain/decisions.py +57 -0
  50. mayhem/domain/errors.py +87 -0
  51. mayhem/domain/events.py +61 -0
  52. mayhem/domain/execution_context.py +120 -0
  53. mayhem/domain/execution_loci.py +94 -0
  54. mayhem/domain/experiments.py +370 -0
  55. mayhem/domain/faults.py +239 -0
  56. mayhem/domain/identity.py +200 -0
  57. mayhem/domain/k8s_adapter.py +132 -0
  58. mayhem/domain/leases.py +186 -0
  59. mayhem/domain/load_strategy.py +98 -0
  60. mayhem/domain/m5_campaign.py +120 -0
  61. mayhem/domain/maniac.py +93 -0
  62. mayhem/domain/observability.py +146 -0
  63. mayhem/domain/outcomes.py +92 -0
  64. mayhem/domain/remote_agent_interface.py +70 -0
  65. mayhem/domain/resources.py +245 -0
  66. mayhem/domain/risks.py +61 -0
  67. mayhem/domain/run_outcome.py +146 -0
  68. mayhem/domain/runtime_adapter.py +256 -0
  69. mayhem/domain/success.py +329 -0
  70. mayhem/domain/topology.py +452 -0
  71. mayhem/infra/__init__.py +1 -0
  72. mayhem/infra/campaign_engine.py +205 -0
  73. mayhem/infra/candidate_gates.py +124 -0
  74. mayhem/infra/candidate_generator.py +110 -0
  75. mayhem/infra/coverage_repository.py +101 -0
  76. mayhem/infra/lease_repository.py +129 -0
  77. mayhem/infra/maniac.py +103 -0
  78. mayhem/infra/migrations.py +596 -0
  79. mayhem/infra/migrator.py +149 -0
  80. mayhem/infra/report.py +227 -0
  81. mayhem/infra/store.py +200 -0
  82. mayhem/py.typed +0 -0
  83. mayhem/spec.py +52 -0
  84. mayhem/toolkit/__init__.py +1 -0
  85. mayhem/toolkit/fingerprint.py +69 -0
  86. mayhem/toolkit/hashing.py +32 -0
  87. mayhem/toolkit/manifests/docker.yaml +11 -0
  88. mayhem/toolkit/manifests/podman.yaml +11 -0
  89. mayhem/toolkit/manifests/stress-ng.yaml +11 -0
  90. mayhem/toolkit/manifests/tc-netem.yaml +11 -0
  91. mayhem/toolkit/manifests/toxiproxy.yaml +10 -0
  92. mayhem/toolkit/registry.py +185 -0
  93. mayhem/toolkit/tool_runner.py +129 -0
  94. mayhem/topology/__init__.py +10 -0
  95. mayhem/topology/providers/__init__.py +0 -0
  96. mayhem/topology/providers/adapter_registry.py +60 -0
  97. mayhem/topology/providers/base.py +31 -0
  98. mayhem/topology/providers/compose.py +207 -0
  99. mayhem/topology/providers/docker_adapter.py +277 -0
  100. mayhem/topology/providers/docker_runtime.py +461 -0
  101. mayhem/topology/providers/podman_adapter.py +328 -0
  102. mayhem/topology/resolve.py +196 -0
  103. mayhem/topology/service.py +158 -0
  104. mayhem_cli-0.5.1.dist-info/METADATA +555 -0
  105. mayhem_cli-0.5.1.dist-info/RECORD +107 -0
  106. mayhem_cli-0.5.1.dist-info/WHEEL +4 -0
  107. mayhem_cli-0.5.1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,141 @@
1
+ """LeaseClient — the only way an agent touches lease state.
2
+
3
+ Every mutation is a legal state-machine transition (the domain module enforces
4
+ it) followed by an immediate sink write, so a crash between inject and release
5
+ still leaves a durable trail for the janitor.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from mayhem.domain.common import utc_now
14
+ from mayhem.domain.leases import FaultLease, LeaseState
15
+
16
+ if TYPE_CHECKING:
17
+ from datetime import datetime
18
+
19
+ from mayhem.agents.sinks import LeaseSink
20
+ from mayhem.toolkit.fingerprint import interpreter_marker
21
+
22
+
23
+ def _lease_id(sequence: int) -> str:
24
+ return f"l-{sequence:08d}"
25
+
26
+
27
+ class LeaseConflictError(Exception):
28
+ """Two agents raced for the same target; the second one loses."""
29
+
30
+
31
+ class LeaseClient:
32
+ def __init__(self, sink: LeaseSink, agent_id: str = f"ag-{interpreter_marker()}") -> None:
33
+ self._sink = sink
34
+ self._agent_id = agent_id
35
+ # Continue the counter from the store's high-water mark so lease ids stay
36
+ # unique across runs against a shared DB (the sink is single-writer per
37
+ # run; parallel steps within a run share this client).
38
+ self._sequence = sink.next_sequence()
39
+
40
+ @property
41
+ def agent_id(self) -> str:
42
+ return self._agent_id
43
+
44
+ def acquire(
45
+ self,
46
+ *,
47
+ run_id: str,
48
+ fault_id: str,
49
+ targets: frozenset[str] | set[str],
50
+ undo_ops: tuple[dict[str, Any], ...],
51
+ verify_probes: tuple[dict[str, Any], ...] = (),
52
+ ttl_seconds: float = 120.0,
53
+ runtime_identity: str | None = None,
54
+ ) -> FaultLease:
55
+ """Create a PENDING lease; the caller must activate() before injecting."""
56
+ self._sequence += 1
57
+ lease = FaultLease.model_validate(
58
+ {
59
+ "id": _lease_id(self._sequence),
60
+ "run_id": run_id,
61
+ "fault_id": fault_id,
62
+ "owner_agent": self._agent_id,
63
+ "targets": sorted(targets),
64
+ "undo_ops": list(undo_ops),
65
+ "verify_probes": list(verify_probes),
66
+ "ttl_seconds": ttl_seconds,
67
+ "runtime_identity": runtime_identity,
68
+ }
69
+ )
70
+ now = utc_now()
71
+ live: list[FaultLease] = []
72
+ overlap = [x for x in self._sink.active_leases() if x.targets & set(targets)]
73
+ for existing in overlap:
74
+ deadline = existing.created_at.timestamp() + float(existing.ttl_seconds)
75
+ if deadline < now.timestamp():
76
+ # Past TTL is abandoned by definition (ADR-0007 / janitor
77
+ # contract) — expire it durably so a crashed run cannot wedge
78
+ # every later run on the same targets.
79
+ self._expire_quietly(existing, now)
80
+ else:
81
+ live.append(existing)
82
+ if live:
83
+ holders = ", ".join(f"{x.id} ({x.owner_agent})" for x in live)
84
+ raise LeaseConflictError(
85
+ f"targets {sorted(targets)} already leased by {holders} "
86
+ "-- the owner will release them, or run `mayhem janitor` "
87
+ "to reclaim leases whose controller is gone"
88
+ )
89
+ self._sink.save(lease)
90
+ return lease
91
+
92
+ def _expire_quietly(self, lease: FaultLease, now: datetime) -> None:
93
+ """Best-effort TTL reap; a failed reap just gets retried next acquire."""
94
+ with contextlib.suppress(Exception):
95
+ expired = lease.transition(LeaseState.EXPIRED, mechanism="past-ttl", now=now)
96
+ self._sink.save(expired)
97
+
98
+ def activate(self, lease_id: str) -> FaultLease:
99
+ lease = self._require(lease_id)
100
+ activated = lease.transition(LeaseState.ACTIVE)
101
+ self._sink.save(activated)
102
+ return activated
103
+
104
+ def mark_releasing(self, lease_id: str) -> FaultLease:
105
+ lease = self._require(lease_id)
106
+ releasing = lease.transition(LeaseState.RELEASING)
107
+ self._sink.save(releasing)
108
+ return releasing
109
+
110
+ def confirm_release(self, lease_id: str, *, mechanism: str) -> FaultLease:
111
+ lease = self._require(lease_id)
112
+ released = lease.transition(LeaseState.RELEASED, mechanism=mechanism)
113
+ self._sink.save(released)
114
+ return released
115
+
116
+ def mark_dirty(self, lease_id: str, *, notes: str) -> FaultLease:
117
+ lease = self._require(lease_id)
118
+ dirty = lease.transition(LeaseState.DIRTY, escalation_notes=notes)
119
+ self._sink.save(dirty)
120
+ return dirty
121
+
122
+ def mark_orphaned(self, lease_id: str, *, notes: str | None = None) -> FaultLease:
123
+ lease = self._require(lease_id)
124
+ orphaned = lease.transition(
125
+ LeaseState.ORPHANED,
126
+ escalation_notes=notes or f"owner {lease.owner_agent} missed its heartbeat",
127
+ )
128
+ self._sink.save(orphaned)
129
+ return orphaned
130
+
131
+ def get(self, lease_id: str) -> FaultLease | None:
132
+ return self._sink.load(lease_id)
133
+
134
+ def active_leases(self) -> tuple[FaultLease, ...]:
135
+ return self._sink.active_leases()
136
+
137
+ def _require(self, lease_id: str) -> FaultLease:
138
+ lease = self._sink.load(lease_id)
139
+ if lease is None:
140
+ raise KeyError(f"unknown lease {lease_id!r}")
141
+ return lease
@@ -0,0 +1,284 @@
1
+ """Verify probes — proof the world returned to normal (or that it didn't).
2
+
3
+ A probe result is evidence, not a judgement; ``satisfied`` is computed from
4
+ ``expect_present`` so negative probes (chain absent) work identically to
5
+ positive ones (service healthy).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import socket
13
+ import urllib.error
14
+ import urllib.request
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING
18
+
19
+ from mayhem.toolkit.tool_runner import run_tool
20
+
21
+ if TYPE_CHECKING:
22
+ from mayhem.domain.leases import VerifyProbe
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ProbeResult:
27
+ probe: str
28
+ satisfied: bool
29
+ detail: str
30
+
31
+ def to_row(self, lease_id: str) -> dict[str, object]:
32
+ return {
33
+ "lease_id": lease_id,
34
+ "probe": self.probe,
35
+ "satisfied": int(self.satisfied),
36
+ "detail": self.detail,
37
+ }
38
+
39
+
40
+ def _arg_float(args: dict[str, object], key: str, default: float) -> float:
41
+ raw = args.get(key)
42
+ if not isinstance(raw, (int, float, str)) or isinstance(raw, bool):
43
+ return default
44
+ try:
45
+ return float(raw)
46
+ except ValueError:
47
+ return default
48
+
49
+
50
+ def _arg_str_list(args: dict[str, object], key: str) -> list[str] | None:
51
+ value = args.get(key)
52
+ if isinstance(value, list) and all(isinstance(item, str) for item in value):
53
+ return value
54
+ return None
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class VerifyReport:
59
+ results: tuple[ProbeResult, ...]
60
+
61
+ @property
62
+ def all_satisfied(self) -> bool:
63
+ return bool(self.results) and all(r.satisfied for r in self.results)
64
+
65
+
66
+ def _run_exec(args: dict[str, object]) -> ProbeResult:
67
+ cmd = _arg_str_list(args, "cmd")
68
+ if cmd is None or not cmd:
69
+ return ProbeResult("exec", False, "exec probe requires cmd: list[str]")
70
+ engine = args.get("engine")
71
+ cont = args.get("cont")
72
+ if engine and cont:
73
+ # Container-addressed presence check. The resolved pid is a pid inside
74
+ # the runtime VM (podman-machine on macOS), which a host ``ps`` cannot
75
+ # see. ``<engine> inspect`` addresses the container main process across
76
+ # the VM boundary, so a nonzero/inspectable container means "present".
77
+ if args.get("incontainer"):
78
+ argv = [str(engine), "exec", str(cont), *cmd]
79
+ result = run_tool(argv, timeout_s=_arg_float(args, "timeout_s", 10.0))
80
+ detail = f"exec {cont} exit={result.exit_code} stderr={result.stderr[:120]!r}"
81
+ return ProbeResult("exec", result.succeeded, detail)
82
+ argv = [str(engine), "inspect", "--format", "{{.State.Pid}}", str(cont)]
83
+ result = run_tool(argv, timeout_s=_arg_float(args, "timeout_s", 10.0))
84
+ pid = result.stdout.strip()
85
+ present = result.succeeded and pid.isdigit() and int(pid) > 0
86
+ return ProbeResult("exec", present, f"inspect {cont} pid={pid!r}")
87
+ result = run_tool(cmd, timeout_s=_arg_float(args, "timeout_s", 10.0))
88
+ detail = f"exit={result.exit_code} stderr={result.stderr[:120]!r}"
89
+ out = (result.stdout or "").strip()
90
+ if out:
91
+ detail = f"{detail} stdout={out[:120]!r}"
92
+ return ProbeResult("exec", result.succeeded, detail)
93
+
94
+
95
+ def _run_tcp(args: dict[str, object]) -> ProbeResult:
96
+ host = str(args.get("host", "127.0.0.1"))
97
+ raw_port = args.get("port")
98
+ if not isinstance(raw_port, int):
99
+ return ProbeResult("tcp.open", False, "tcp probe requires port:int")
100
+ timeout = _arg_float(args, "timeout_s", 3.0)
101
+ try:
102
+ with socket.create_connection((host, raw_port), timeout=timeout):
103
+ return ProbeResult("tcp.open", True, f"{host}:{raw_port} accepted")
104
+ except OSError as exc:
105
+ return ProbeResult("tcp.open", False, f"{host}:{raw_port} refused ({exc})")
106
+
107
+
108
+ def _run_http(args: dict[str, object]) -> ProbeResult:
109
+ url = str(args.get("url", ""))
110
+ expected_raw = args.get("expect_status", 200)
111
+ if not isinstance(expected_raw, int):
112
+ return ProbeResult("http.status", False, "expect_status must be an int")
113
+ timeout = _arg_float(args, "timeout_s", 5.0)
114
+ if not url.startswith(("http://", "https://")):
115
+ return ProbeResult("http.status", False, f"bad url {url!r}")
116
+ try:
117
+ with urllib.request.urlopen(url, timeout=timeout) as response:
118
+ status = response.status
119
+ except urllib.error.HTTPError as exc:
120
+ status = exc.code
121
+ except (urllib.error.URLError, OSError) as exc:
122
+ return ProbeResult("http.status", False, f"{url} unreachable ({exc})")
123
+ return ProbeResult("http.status", status == expected_raw, f"{url} -> {status}")
124
+
125
+
126
+ def _process_state(pid: int) -> str | None:
127
+ """Kernel state of ``pid`` ('R'/'S'/'T'/'Z'/…), or None when it is gone.
128
+
129
+ Reads ``/proc/<pid>/stat`` on Linux (field 3, after the parenthesised
130
+ comm); falls back to ``ps -o state=`` which is portable (macOS included).
131
+ A process that is gone — reaped, or never there — yields None.
132
+ """
133
+ try:
134
+ content = Path(f"/proc/{pid}/stat").read_text(errors="replace")
135
+ except OSError:
136
+ content = None
137
+ if content:
138
+ fields = content.rsplit(")", 1)
139
+ if len(fields) == 2:
140
+ parts = fields[1].split()
141
+ if parts:
142
+ return parts[0]
143
+ result = run_tool(["ps", "-o", "stat=", "-p", str(pid)], timeout_s=5.0)
144
+ if not result.succeeded:
145
+ return None
146
+ state = (result.stdout or "").strip()
147
+ return state[0] if state else None
148
+
149
+
150
+ def _pid_alive(pid: int) -> bool:
151
+ """True when ``pid`` is a live, non-zombie process.
152
+
153
+ ``os.kill(pid, 0)`` still succeeds for a zombie — a terminated child that
154
+ is merely awaiting reap — which would defeat negative process probes.
155
+ Treat zombies (Z/x) as not alive: the target *was* terminated.
156
+ """
157
+ try:
158
+ os.kill(pid, 0)
159
+ except OSError:
160
+ return False
161
+ return _process_state(pid) not in (None, "Z", "X")
162
+
163
+
164
+ def _run_process(args: dict[str, object]) -> ProbeResult:
165
+ name = str(args.get("name", ""))
166
+ raw_pid = args.get("pid")
167
+ engine = args.get("engine")
168
+ cont = args.get("cont")
169
+ if raw_pid is not None and not isinstance(raw_pid, bool):
170
+ if isinstance(raw_pid, int):
171
+ pid: int = raw_pid
172
+ elif isinstance(raw_pid, str) and raw_pid.strip().lstrip("-").isdigit():
173
+ pid = int(raw_pid)
174
+ else:
175
+ return ProbeResult("process", False, f"bad pid {raw_pid!r}")
176
+ if engine and cont:
177
+ # Container-addressed: the resolved pid lives inside the runtime
178
+ # VM (podman-machine) which a host ``kill -0`` cannot reach; the
179
+ # container's main process is inspected host-side instead.
180
+ argv = [str(engine), "inspect", "--format", "{{.State.Pid}}", str(cont)]
181
+ result = run_tool(argv, timeout_s=_arg_float(args, "timeout_s", 5.0))
182
+ pid_out = result.stdout.strip()
183
+ present = result.succeeded and pid_out.isdigit() and int(pid_out) > 0
184
+ return ProbeResult("process", present, f"inspect {cont} pid={pid_out!r}")
185
+ alive = _pid_alive(pid)
186
+ return ProbeResult(
187
+ "process", alive, f"pid {pid} {'alive' if alive else 'gone (or zombie)'}"
188
+ )
189
+ if name:
190
+ result = run_tool(["pgrep", "-f", name], timeout_s=_arg_float(args, "timeout_s", 5.0))
191
+ found = result.succeeded and bool((result.stdout or "").strip())
192
+ return ProbeResult("process", found, f"pgrep {name!r} -> {result.stdout!r}")
193
+ return ProbeResult("process", False, "process probe requires name or pid")
194
+
195
+
196
+ def _run_metric(args: dict[str, object]) -> ProbeResult: # noqa: PLR0911 (one branch per failure mode)
197
+ endpoint = str(args.get("endpoint", ""))
198
+ query = str(args.get("query", ""))
199
+ if not endpoint:
200
+ return ProbeResult("metric", False, "metric probe requires endpoint")
201
+ try:
202
+ with urllib.request.urlopen(
203
+ endpoint, timeout=_arg_float(args, "timeout_s", 5.0)
204
+ ) as response:
205
+ body = response.read(10_000).decode("utf-8", errors="replace")
206
+ except Exception as exc:
207
+ return ProbeResult("metric", False, f"{endpoint} unreachable ({exc})")
208
+ found = (not query) or (query in body)
209
+ if not found:
210
+ return ProbeResult("metric", False, f"metric {query!r} not present")
211
+ threshold_raw = args.get("threshold")
212
+ threshold_num: float | None = None
213
+ if (
214
+ threshold_raw is not None
215
+ and not isinstance(threshold_raw, bool)
216
+ and isinstance(threshold_raw, (int, float, str))
217
+ ):
218
+ try:
219
+ threshold_num = float(threshold_raw)
220
+ except ValueError:
221
+ return ProbeResult("metric", False, f"bad threshold {threshold_raw!r}")
222
+ elif threshold_raw is not None and not isinstance(threshold_raw, bool):
223
+ return ProbeResult("metric", False, f"bad threshold {threshold_raw!r}")
224
+ if threshold_num is not None:
225
+ try:
226
+ match = re.search(rf"{re.escape(query)}\s+([-+0-9.eE]+)", body)
227
+ if match is None:
228
+ return ProbeResult("metric", False, f"metric {query!r} lacks a value")
229
+ value = float(match.group(1))
230
+ ok = value >= threshold_num
231
+ return ProbeResult("metric", ok, f"{query}={value} (threshold {threshold_num})")
232
+ except (ValueError, IndexError):
233
+ return ProbeResult("metric", False, f"cannot parse {query!r} value")
234
+ return ProbeResult("metric", True, f"metric {query!r} present")
235
+
236
+
237
+ def _run_file(args: dict[str, object]) -> ProbeResult:
238
+ path = str(args.get("path", ""))
239
+ if not path:
240
+ return ProbeResult("file", False, "file probe requires path")
241
+ p = Path(path)
242
+ if not p.exists():
243
+ return ProbeResult("file", False, f"{path} missing")
244
+ contains = args.get("contains")
245
+ if contains:
246
+ try:
247
+ content = p.read_text(errors="replace")
248
+ except OSError as exc:
249
+ return ProbeResult("file", False, f"{path} unreadable ({exc})")
250
+ if str(contains) not in content:
251
+ return ProbeResult("file", False, f"{path} lacks {contains!r}")
252
+ return ProbeResult("file", True, f"{path} present")
253
+
254
+
255
+ _HANDLERS = {
256
+ "exec": _run_exec,
257
+ "tcp": _run_tcp,
258
+ "http": _run_http,
259
+ "process": _run_process,
260
+ "metric": _run_metric,
261
+ "file": _run_file,
262
+ }
263
+
264
+
265
+ def run_probe(probe: VerifyProbe) -> ProbeResult:
266
+ handler = _HANDLERS.get(probe.probe.split(".", 1)[0])
267
+ if handler is None:
268
+ return ProbeResult(probe.probe, False, f"unknown probe kind {probe.probe!r}")
269
+ observed = handler(dict(probe.args))
270
+ # expect_present=True → want the thing to exist → satisfied iff observed.
271
+ # expect_present=False → want absence (e.g. chain gone) → satisfied iff NOT.
272
+ satisfied = observed.satisfied if probe.expect_present else not observed.satisfied
273
+ return ProbeResult(probe.probe, satisfied, observed.detail)
274
+
275
+
276
+ def verify_all(probes: tuple[VerifyProbe, ...], lease_id: str) -> VerifyReport:
277
+ """Run every probe; never raises — a crashing probe is a failed probe."""
278
+ results = []
279
+ for probe in probes:
280
+ try:
281
+ results.append(run_probe(probe))
282
+ except Exception as exc: # probe crashes are evidence too
283
+ results.append(ProbeResult(probe.probe, False, f"probe crashed: {exc}"))
284
+ return VerifyReport(tuple(results))
@@ -0,0 +1,134 @@
1
+ """ndjson JSON-RPC 2.0 framing for controller↔agent communication (ADR-0003).
2
+
3
+ One JSON object per line. Requests carry an id; notifications do not.
4
+ Every params payload carries ``run_id``/``agent_id`` context for correlation —
5
+ enforced here at the frame level so no handler can forget it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from enum import IntEnum
12
+ from typing import Any
13
+
14
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
15
+
16
+ PROTOCOL_VERSION = "mayhem/1"
17
+
18
+
19
+ class RpcErrorCode(IntEnum):
20
+ PARSE_ERROR = -32700
21
+ INVALID_REQUEST = -32600
22
+ METHOD_NOT_FOUND = -32601
23
+ INVALID_PARAMS = -32602
24
+ INTERNAL_ERROR = -32603
25
+ TASK_FAILED = -32000 # implementation-defined server error range
26
+ SAFETY_REFUSED = -32001
27
+
28
+
29
+ class ProtocolError(Exception):
30
+ def __init__(self, code: int, message: str) -> None:
31
+ super().__init__(message)
32
+ self.code = code
33
+
34
+
35
+ class _Frame(BaseModel):
36
+ model_config = ConfigDict(frozen=True, extra="forbid")
37
+
38
+ jsonrpc: str = "2.0"
39
+
40
+ @model_validator(mode="after")
41
+ def _version(self) -> Any:
42
+ if self.jsonrpc != "2.0":
43
+ raise ValueError("jsonrpc must be '2.0'")
44
+ return self
45
+
46
+
47
+ def _require_context(params: dict[str, Any]) -> None:
48
+ missing = [key for key in ("run_id", "agent_id") if key not in params]
49
+ if missing:
50
+ raise ProtocolError(
51
+ RpcErrorCode.INVALID_PARAMS,
52
+ f"params missing correlation context: {', '.join(missing)}",
53
+ )
54
+
55
+
56
+ class RpcRequest(_Frame):
57
+ id: int | str
58
+ method: str
59
+ params: dict[str, Any] = Field(default_factory=dict)
60
+
61
+
62
+ class RpcNotification(_Frame):
63
+ method: str
64
+ params: dict[str, Any] = Field(default_factory=dict)
65
+
66
+
67
+ class RpcResponse(_Frame):
68
+ id: int | str | None
69
+ result: dict[str, Any] | None = None
70
+ error: dict[str, Any] | None = None # {"code": int, "message": str}
71
+
72
+ @model_validator(mode="after")
73
+ def _exactly_one(self) -> Any:
74
+ if (self.result is None) == (self.error is None):
75
+ raise ValueError("response must carry exactly one of result|error")
76
+ return self
77
+
78
+
79
+ def encode(frame: RpcRequest | RpcNotification | RpcResponse) -> bytes:
80
+ """Serialize one frame as a single ndjson line (newline included)."""
81
+ return (frame.model_dump_json(exclude_none=True) + "\n").encode("utf-8")
82
+
83
+
84
+ def decode(line: str | bytes) -> RpcRequest | RpcNotification | RpcResponse:
85
+ """Parse one ndjson line into a typed frame.
86
+
87
+ Raises ProtocolError(PARSE_ERROR) on malformed JSON and
88
+ ProtocolError(INVALID_REQUEST) on structurally invalid frames.
89
+ """
90
+ try:
91
+ raw = json.loads(line)
92
+ except json.JSONDecodeError as exc:
93
+ raise ProtocolError(RpcErrorCode.PARSE_ERROR, f"bad json: {exc.msg}") from exc
94
+ if not isinstance(raw, dict):
95
+ raise ProtocolError(RpcErrorCode.INVALID_REQUEST, "frame must be a JSON object")
96
+ try:
97
+ if "method" in raw:
98
+ if "id" in raw:
99
+ return RpcRequest.model_validate(raw)
100
+ return RpcNotification.model_validate(raw)
101
+ return RpcResponse.model_validate(raw)
102
+ except ValueError as exc:
103
+ raise ProtocolError(RpcErrorCode.INVALID_REQUEST, str(exc)) from exc
104
+
105
+
106
+ def error_response(request_id: int | str | None, code: int, message: str) -> RpcResponse:
107
+ return RpcResponse(id=request_id, error={"code": int(code), "message": message})
108
+
109
+
110
+ def result_response(request_id: int | str | None, result: dict[str, Any]) -> RpcResponse:
111
+ return RpcResponse(id=request_id, result=result)
112
+
113
+
114
+ METHOD_HANDSHAKE = "handshake"
115
+ METHOD_CAPABILITIES_QUERY = "capabilities.query"
116
+ METHOD_TASK_EXECUTE = "task.execute"
117
+ METHOD_TASK_CANCEL = "task.cancel"
118
+ METHOD_TASK_STATUS = "task.status"
119
+ METHOD_LEASE_EXTEND = "lease.extend"
120
+ METHOD_HEALTH_PING = "health.ping"
121
+ NOTIFICATION_EVENT_EMIT = "event.emit"
122
+ NOTIFICATION_LOG_EMIT = "log.emit"
123
+ KNOWN_METHODS = frozenset(
124
+ {
125
+ METHOD_HANDSHAKE,
126
+ METHOD_CAPABILITIES_QUERY,
127
+ METHOD_TASK_EXECUTE,
128
+ METHOD_TASK_CANCEL,
129
+ METHOD_TASK_STATUS,
130
+ METHOD_LEASE_EXTEND,
131
+ METHOD_HEALTH_PING,
132
+ }
133
+ )
134
+ KNOWN_NOTIFICATIONS = frozenset({NOTIFICATION_EVENT_EMIT, NOTIFICATION_LOG_EMIT})