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.
- mayhem/agent/__init__.py +1 -0
- mayhem/agent/cli.py +36 -0
- mayhem/agents/__init__.py +1 -0
- mayhem/agents/capabilities.py +106 -0
- mayhem/agents/executors.py +430 -0
- mayhem/agents/impact.py +729 -0
- mayhem/agents/lease_client.py +141 -0
- mayhem/agents/probes.py +284 -0
- mayhem/agents/protocol.py +134 -0
- mayhem/agents/server.py +281 -0
- mayhem/agents/sinks.py +60 -0
- mayhem/agents/transports.py +134 -0
- mayhem/agents/watchdog.py +140 -0
- mayhem/cli/__init__.py +11 -0
- mayhem/cli/app.py +154 -0
- mayhem/cli/campaign.py +496 -0
- mayhem/cli/config_cmd.py +47 -0
- mayhem/cli/context.py +23 -0
- mayhem/cli/dependency.py +429 -0
- mayhem/cli/exit_codes.py +24 -0
- mayhem/cli/experiment.py +24 -0
- mayhem/cli/lifecycle.py +805 -0
- mayhem/cli/resolver.py +72 -0
- mayhem/cli/services.py +459 -0
- mayhem/cli/style.py +101 -0
- mayhem/cli/toolkit.py +41 -0
- mayhem/cli/topology.py +127 -0
- mayhem/config.py +208 -0
- mayhem/controller/__init__.py +1 -0
- mayhem/controller/compensation.py +2156 -0
- mayhem/controller/executor.py +1719 -0
- mayhem/controller/janitor.py +196 -0
- mayhem/controller/observability_collector.py +382 -0
- mayhem/controller/observations.py +102 -0
- mayhem/controller/planner.py +715 -0
- mayhem/controller/recovery.py +245 -0
- mayhem/controller/resilience_report.py +585 -0
- mayhem/controller/resource_manager.py +457 -0
- mayhem/controller/safety.py +392 -0
- mayhem/domain/__init__.py +6 -0
- mayhem/domain/campaigns.py +118 -0
- mayhem/domain/cancellation.py +110 -0
- mayhem/domain/candidates.py +101 -0
- mayhem/domain/capabilities.py +86 -0
- mayhem/domain/catalog.py +727 -0
- mayhem/domain/checks.py +173 -0
- mayhem/domain/common.py +104 -0
- mayhem/domain/coverage.py +106 -0
- mayhem/domain/decisions.py +57 -0
- mayhem/domain/errors.py +87 -0
- mayhem/domain/events.py +61 -0
- mayhem/domain/execution_context.py +120 -0
- mayhem/domain/execution_loci.py +94 -0
- mayhem/domain/experiments.py +370 -0
- mayhem/domain/faults.py +239 -0
- mayhem/domain/identity.py +200 -0
- mayhem/domain/k8s_adapter.py +132 -0
- mayhem/domain/leases.py +186 -0
- mayhem/domain/load_strategy.py +98 -0
- mayhem/domain/m5_campaign.py +120 -0
- mayhem/domain/maniac.py +93 -0
- mayhem/domain/observability.py +146 -0
- mayhem/domain/outcomes.py +92 -0
- mayhem/domain/remote_agent_interface.py +70 -0
- mayhem/domain/resources.py +245 -0
- mayhem/domain/risks.py +61 -0
- mayhem/domain/run_outcome.py +146 -0
- mayhem/domain/runtime_adapter.py +256 -0
- mayhem/domain/success.py +329 -0
- mayhem/domain/topology.py +452 -0
- mayhem/infra/__init__.py +1 -0
- mayhem/infra/campaign_engine.py +205 -0
- mayhem/infra/candidate_gates.py +124 -0
- mayhem/infra/candidate_generator.py +110 -0
- mayhem/infra/coverage_repository.py +101 -0
- mayhem/infra/lease_repository.py +129 -0
- mayhem/infra/maniac.py +103 -0
- mayhem/infra/migrations.py +596 -0
- mayhem/infra/migrator.py +149 -0
- mayhem/infra/report.py +227 -0
- mayhem/infra/store.py +200 -0
- mayhem/py.typed +0 -0
- mayhem/spec.py +52 -0
- mayhem/toolkit/__init__.py +1 -0
- mayhem/toolkit/fingerprint.py +69 -0
- mayhem/toolkit/hashing.py +32 -0
- mayhem/toolkit/manifests/docker.yaml +11 -0
- mayhem/toolkit/manifests/podman.yaml +11 -0
- mayhem/toolkit/manifests/stress-ng.yaml +11 -0
- mayhem/toolkit/manifests/tc-netem.yaml +11 -0
- mayhem/toolkit/manifests/toxiproxy.yaml +10 -0
- mayhem/toolkit/registry.py +185 -0
- mayhem/toolkit/tool_runner.py +129 -0
- mayhem/topology/__init__.py +10 -0
- mayhem/topology/providers/__init__.py +0 -0
- mayhem/topology/providers/adapter_registry.py +60 -0
- mayhem/topology/providers/base.py +31 -0
- mayhem/topology/providers/compose.py +207 -0
- mayhem/topology/providers/docker_adapter.py +277 -0
- mayhem/topology/providers/docker_runtime.py +461 -0
- mayhem/topology/providers/podman_adapter.py +328 -0
- mayhem/topology/resolve.py +196 -0
- mayhem/topology/service.py +158 -0
- mayhem_cli-0.5.1.dist-info/METADATA +555 -0
- mayhem_cli-0.5.1.dist-info/RECORD +107 -0
- mayhem_cli-0.5.1.dist-info/WHEEL +4 -0
- mayhem_cli-0.5.1.dist-info/entry_points.txt +3 -0
mayhem/domain/checks.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Steady-state checks and evaluation records.
|
|
2
|
+
|
|
3
|
+
Checks are evaluated in three phases — pre (baseline gate), during (violation
|
|
4
|
+
policy), post (recovery proof). Measured values are recorded verbatim so
|
|
5
|
+
summaries can quote numbers rather than adjectives.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from typing import Annotated, Any, Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
|
|
14
|
+
|
|
15
|
+
from mayhem.domain.common import Duration
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ProbeType(StrEnum):
|
|
19
|
+
HTTP = "http"
|
|
20
|
+
EXEC = "exec"
|
|
21
|
+
TCP = "tcp"
|
|
22
|
+
PROCESS = "process"
|
|
23
|
+
METRIC = "metric"
|
|
24
|
+
FILE = "file"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HttpProbe(BaseModel):
|
|
28
|
+
model_config = ConfigDict(frozen=True)
|
|
29
|
+
|
|
30
|
+
type: Literal[ProbeType.HTTP] = ProbeType.HTTP
|
|
31
|
+
url: str
|
|
32
|
+
method: str = "GET"
|
|
33
|
+
timeout: Duration = 5.0
|
|
34
|
+
expected_status: int = 200
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ExecProbe(BaseModel):
|
|
38
|
+
model_config = ConfigDict(frozen=True)
|
|
39
|
+
|
|
40
|
+
type: Literal[ProbeType.EXEC] = ProbeType.EXEC
|
|
41
|
+
cmd: tuple[str, ...]
|
|
42
|
+
timeout: Duration = 10.0
|
|
43
|
+
expected_exit_code: int = 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TcpProbe(BaseModel):
|
|
47
|
+
model_config = ConfigDict(frozen=True)
|
|
48
|
+
|
|
49
|
+
type: Literal[ProbeType.TCP] = ProbeType.TCP
|
|
50
|
+
host: str
|
|
51
|
+
port: int = Field(ge=1, le=65535)
|
|
52
|
+
timeout: Duration = 3.0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ProcessProbe(BaseModel):
|
|
56
|
+
model_config = ConfigDict(frozen=True)
|
|
57
|
+
|
|
58
|
+
type: Literal[ProbeType.PROCESS] = ProbeType.PROCESS
|
|
59
|
+
name: str = "" # process name/pattern (e.g. "nginx")
|
|
60
|
+
pid: int | None = Field(default=None, ge=1)
|
|
61
|
+
timeout: Duration = 5.0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class MetricProbe(BaseModel):
|
|
65
|
+
model_config = ConfigDict(frozen=True)
|
|
66
|
+
|
|
67
|
+
type: Literal[ProbeType.METRIC] = ProbeType.METRIC
|
|
68
|
+
endpoint: str = "" # metrics endpoint (e.g. "http://svc:9090/metrics")
|
|
69
|
+
query: str = "" # metric name / label selector
|
|
70
|
+
threshold: float | None = None # lower bound for the sampled value
|
|
71
|
+
timeout: Duration = 5.0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class FileProbe(BaseModel):
|
|
75
|
+
model_config = ConfigDict(frozen=True)
|
|
76
|
+
|
|
77
|
+
type: Literal[ProbeType.FILE] = ProbeType.FILE
|
|
78
|
+
path: str # path to check inside the execution locus
|
|
79
|
+
contains: str | None = None # optional content substring to require
|
|
80
|
+
timeout: Duration = 5.0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
Probe = Annotated[
|
|
84
|
+
HttpProbe | ExecProbe | TcpProbe | ProcessProbe | MetricProbe | FileProbe,
|
|
85
|
+
Field(discriminator="type"),
|
|
86
|
+
]
|
|
87
|
+
_probe_adapter: TypeAdapter[Probe] = TypeAdapter(Probe)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_probe(data: object) -> Probe:
|
|
91
|
+
"""Parse untyped probe data into the closed probe union."""
|
|
92
|
+
return _probe_adapter.validate_python(data)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Expectation(BaseModel):
|
|
96
|
+
"""Declarative pass criteria over measured probe results."""
|
|
97
|
+
|
|
98
|
+
model_config = ConfigDict(frozen=True)
|
|
99
|
+
|
|
100
|
+
status_eq: int | None = None
|
|
101
|
+
exit_code_eq: int | None = None
|
|
102
|
+
reachable: bool | None = None
|
|
103
|
+
p99_ms_lt: float | None = None
|
|
104
|
+
error_rate_lt: float | None = None # fraction 0..1
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class OnPreFailure(StrEnum):
|
|
108
|
+
SKIP_RUN = "skip_run"
|
|
109
|
+
ABORT = "abort"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class CheckLocus(StrEnum):
|
|
113
|
+
"""Where a check is evaluated — distinct from the fault target's locus.
|
|
114
|
+
|
|
115
|
+
A bare (unqualified) check infers its locus from the fault target for
|
|
116
|
+
backward compatibility (ADR-M4-2); an explicit locus is honored as-is.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
HOST = "host"
|
|
120
|
+
CONTAINER = "container"
|
|
121
|
+
SERVICE = "service"
|
|
122
|
+
PROCESS = "process"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class SteadyStateCheck(BaseModel):
|
|
126
|
+
model_config = ConfigDict(frozen=True)
|
|
127
|
+
|
|
128
|
+
id: str
|
|
129
|
+
probe: Probe
|
|
130
|
+
expect: Expectation = Field(default_factory=Expectation)
|
|
131
|
+
description: str = ""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class CheckSpec(BaseModel):
|
|
135
|
+
"""A drill check: a probe evaluated at an explicit or inferred locus (ADR-M4-2).
|
|
136
|
+
|
|
137
|
+
``execution`` declares where the check runs (host / container / service /
|
|
138
|
+
process); when unset (None) the executor infers it from the fault target so
|
|
139
|
+
pre-0.3.0 specs behave unchanged. ``target`` names the fault target container
|
|
140
|
+
used for that inference.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
model_config = ConfigDict(frozen=True)
|
|
144
|
+
|
|
145
|
+
id: str
|
|
146
|
+
probe: Probe
|
|
147
|
+
execution: CheckLocus | None = None # None → infer from fault target
|
|
148
|
+
expect: Expectation = Field(default_factory=Expectation)
|
|
149
|
+
target: str | None = None # fault target container for locus inference
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class CheckPhase(StrEnum):
|
|
153
|
+
PRE = "pre"
|
|
154
|
+
DURING = "during"
|
|
155
|
+
POST = "post"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class EvaluationResult(BaseModel):
|
|
159
|
+
"""One check evaluation in one phase; stored verbatim in history."""
|
|
160
|
+
|
|
161
|
+
model_config = ConfigDict(frozen=True)
|
|
162
|
+
|
|
163
|
+
check_id: str
|
|
164
|
+
phase: CheckPhase
|
|
165
|
+
passed: bool
|
|
166
|
+
measured: dict[str, Any] # e.g. {"status": 200, "p99_ms": 182.4}
|
|
167
|
+
evaluated_at_epoch_s: float
|
|
168
|
+
detail: str = ""
|
|
169
|
+
|
|
170
|
+
def summary(self) -> str:
|
|
171
|
+
verdict = "PASS" if self.passed else "FAIL"
|
|
172
|
+
measured = ", ".join(f"{k}={v}" for k, v in self.measured.items())
|
|
173
|
+
return f"[{self.phase.value}] {self.check_id}: {verdict} ({measured})"
|
mayhem/domain/common.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Shared scalar types and small pure helpers used across the domain.
|
|
2
|
+
|
|
3
|
+
Duration parsing accepts the DSL grammar documented in
|
|
4
|
+
``docs/reference/experiment-dsl.md``: ``30s | 5m | 1h`` (plus plain seconds).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
from pydantic import AfterValidator, BeforeValidator, PlainSerializer
|
|
14
|
+
|
|
15
|
+
from mayhem.domain.errors import SchemaValidationError
|
|
16
|
+
|
|
17
|
+
_DURATION_RE = re.compile(r"^(?P<value>\d+(?:\.\d+)?)(?P<unit>s|m|h)$")
|
|
18
|
+
|
|
19
|
+
_BYTES_RE = re.compile(
|
|
20
|
+
r"^(?P<value>\d+(?:\.\d+)?)(?P<unit>TiB|GiB|MiB|KiB|TB|GB|MB|KB|T|G|M|K|B)?$"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_duration(raw: str) -> float:
|
|
25
|
+
"""Parse a duration string into seconds.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
SchemaValidationError: If the string does not match the grammar.
|
|
29
|
+
"""
|
|
30
|
+
match = _DURATION_RE.fullmatch(raw.strip())
|
|
31
|
+
if match is None:
|
|
32
|
+
raise SchemaValidationError("duration", f"expected '<n>s|<n>m|<n|h' format, got {raw!r}")
|
|
33
|
+
value = float(match.group("value"))
|
|
34
|
+
unit = match.group("unit")
|
|
35
|
+
multiplier = {"s": 1.0, "m": 60.0, "h": 3600.0}[unit]
|
|
36
|
+
return value * multiplier
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_bytes(raw: str) -> float:
|
|
40
|
+
"""Parse a byte quantity string (e.g. ``256M``, ``1.5GiB``) into bytes.
|
|
41
|
+
|
|
42
|
+
Grammar accepts ``<n>`` followed by an optional unit: ``B``, ``K``/``KiB``,
|
|
43
|
+
``M``/``MiB``, ``G``/``GiB``, ``T``/``TiB`` computed in powers of 1024, or
|
|
44
|
+
``KB``/``MB``/``GB``/``TB`` computed in powers of 1000. A bare number is
|
|
45
|
+
plain bytes.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
SchemaValidationError: If the string does not match the grammar.
|
|
49
|
+
"""
|
|
50
|
+
match = _BYTES_RE.fullmatch(raw.strip())
|
|
51
|
+
if match is None:
|
|
52
|
+
raise SchemaValidationError(
|
|
53
|
+
"bytes", f"expected '<n>[B|K|KB|KiB|M|MB|MiB|G|GB|GiB|T|TB|TiB]', got {raw!r}"
|
|
54
|
+
)
|
|
55
|
+
value = float(match.group("value"))
|
|
56
|
+
unit = match.group("unit") or "B"
|
|
57
|
+
base = 1024.0 if unit.endswith("iB") or len(unit) == 1 else 1000.0
|
|
58
|
+
power = {"B": 0, "K": 1, "M": 2, "G": 3, "T": 4}[unit[0]]
|
|
59
|
+
return value * base**power
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _coerce_duration(v: object) -> object:
|
|
63
|
+
return parse_duration(v) if isinstance(v, str) else v
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _check_non_negative(v: float | str) -> float:
|
|
67
|
+
seconds = parse_duration(v) if isinstance(v, str) else float(v)
|
|
68
|
+
if seconds < 0:
|
|
69
|
+
raise SchemaValidationError("duration", f"must be >= 0, got {v}")
|
|
70
|
+
return seconds
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _serialize_duration(v: float | str) -> str:
|
|
74
|
+
seconds = parse_duration(v) if isinstance(v, str) else float(v)
|
|
75
|
+
return f"{seconds:g}s"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
Duration = Annotated[
|
|
79
|
+
float | str,
|
|
80
|
+
BeforeValidator(_coerce_duration),
|
|
81
|
+
AfterValidator(_check_non_negative),
|
|
82
|
+
PlainSerializer(_serialize_duration, return_type=str),
|
|
83
|
+
]
|
|
84
|
+
"""Seconds; accepts DSL duration strings (``"30s"``/``"5m"``/``"1h"``) or a
|
|
85
|
+
plain ``float`` of seconds, and emits a seconds string on serialization.
|
|
86
|
+
|
|
87
|
+
The declared type is ``float | str`` because an unpassed class default is
|
|
88
|
+
returned by Pydantic v2 *without* running the validator (see
|
|
89
|
+
``tests/unit/test_drill_spec.py`` asserting ``DrillConfig().timeout == "30m"``),
|
|
90
|
+
so a string default must be type-valid. Explicitly supplied values are coerced
|
|
91
|
+
to ``float`` seconds at validation time.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def utc_now() -> datetime:
|
|
96
|
+
"""Timezone-aware UTC now (DTZ discipline: never naive datetimes)."""
|
|
97
|
+
return datetime.now(UTC)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def iso_utc(dt: datetime) -> str:
|
|
101
|
+
"""Render a datetime as ISO-8601 UTC text, as persisted in storage."""
|
|
102
|
+
if dt.tzinfo is None:
|
|
103
|
+
raise SchemaValidationError("timestamp", "naive datetime refused; require tz-aware")
|
|
104
|
+
return dt.astimezone(UTC).isoformat()
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Coverage accounting — ADR-M5-3 (coverage is the objective).
|
|
2
|
+
|
|
3
|
+
A ``CoverageCell`` is a point in the experiment landscape:
|
|
4
|
+
(``target``, ``fault_kind``, ``execution_context``, ``parameter_band``).
|
|
5
|
+
|
|
6
|
+
A recorded ``Outcome`` marks the cell for its run as *seen*; Maniac then
|
|
7
|
+
optimizes toward covering new (``UNKNOWN``) cells rather than re-running
|
|
8
|
+
already-covered ones. A re-run of the same cell never double-counts.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class CoverageCell:
|
|
19
|
+
"""One point in the (target, fault-kind, context, parameter-band) landscape."""
|
|
20
|
+
|
|
21
|
+
target: str
|
|
22
|
+
fault_kind: str
|
|
23
|
+
execution_context: str
|
|
24
|
+
parameter_band: str
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def key(self) -> str:
|
|
28
|
+
"""Canonical, collision-resistant key for this cell."""
|
|
29
|
+
return _cell_key(self.target, self.fault_kind, self.execution_context, self.parameter_band)
|
|
30
|
+
|
|
31
|
+
def to_tuple(self) -> tuple[str, str, str, str]:
|
|
32
|
+
return (self.target, self.fault_kind, self.execution_context, self.parameter_band)
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_tuple(cls, row: tuple[str, str, str, str]) -> CoverageCell:
|
|
36
|
+
return cls(*row)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cell_key(
|
|
40
|
+
target: str,
|
|
41
|
+
fault_kind: str,
|
|
42
|
+
execution_context: str,
|
|
43
|
+
parameter_band: str,
|
|
44
|
+
) -> str:
|
|
45
|
+
"""Deterministic key; ``"\x1f"`` (unit separator) is not valid in the parts."""
|
|
46
|
+
if any("\x1f" in p for p in (target, fault_kind, execution_context, parameter_band)):
|
|
47
|
+
raise ValueError("coverage cell parts must not contain the unit separator char")
|
|
48
|
+
return "\x1f".join((target, fault_kind, execution_context, parameter_band))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cell_key(
|
|
52
|
+
target: str,
|
|
53
|
+
fault_kind: str,
|
|
54
|
+
execution_context: str,
|
|
55
|
+
parameter_band: str,
|
|
56
|
+
) -> str:
|
|
57
|
+
"""Module-level helper mirroring ``CoverageCell.key``."""
|
|
58
|
+
return _cell_key(target, fault_kind, execution_context, parameter_band)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class CoverageRecord:
|
|
63
|
+
"""A persisted *seen* cell plus metadata about its originating run."""
|
|
64
|
+
|
|
65
|
+
cell: CoverageCell
|
|
66
|
+
run_id: str
|
|
67
|
+
outcome_marks_seen: bool = True
|
|
68
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def key(self) -> str:
|
|
72
|
+
return self.cell.key
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class CoverageSummary:
|
|
77
|
+
"""Aggregate view of covered vs unknown cells over a landscape."""
|
|
78
|
+
|
|
79
|
+
covered_keys: frozenset[str]
|
|
80
|
+
total_cells: int
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def covered_count(self) -> int:
|
|
84
|
+
return len(self.covered_keys)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def unknown_count(self) -> int:
|
|
88
|
+
return self.total_cells - self.covered_count
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def fraction(self) -> float:
|
|
92
|
+
"""Fraction of the landscape covered, in [0.0, 1.0]."""
|
|
93
|
+
if self.total_cells == 0:
|
|
94
|
+
return 0.0
|
|
95
|
+
return self.covered_count / self.total_cells
|
|
96
|
+
|
|
97
|
+
def is_covered(self, cell: CoverageCell) -> bool:
|
|
98
|
+
return cell.key in self.covered_keys
|
|
99
|
+
|
|
100
|
+
def unknown_cells(self, landscape: tuple[CoverageCell, ...]) -> tuple[CoverageCell, ...]:
|
|
101
|
+
"""Cells in ``landscape`` not yet covered, in landscape order."""
|
|
102
|
+
return tuple(c for c in landscape if c.key not in self.covered_keys)
|
|
103
|
+
|
|
104
|
+
def cell_was_seen(self, value: CoverageCell) -> bool:
|
|
105
|
+
"""Alias of ``is_covered`` for call sites reading 'seen' language."""
|
|
106
|
+
return self.is_covered(value)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Governing-decision registry — ADR decision IDs + decided-on timestamps.
|
|
2
|
+
|
|
3
|
+
Every outcome record carries the decision refs that produced it, so a replay
|
|
4
|
+
can attribute a verdict, its criteria, and its observations to the approved
|
|
5
|
+
decisions that defined them (ADR-M4-1/4-3/4-4/4-5). The ID is the ADR's own
|
|
6
|
+
name and the timestamp is the date it was approved — captured here once and
|
|
7
|
+
persisted on the run row by the executor.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DecisionRef(BaseModel):
|
|
16
|
+
"""A single governing decision: ADR id + approval timestamp."""
|
|
17
|
+
|
|
18
|
+
model_config = ConfigDict(frozen=True)
|
|
19
|
+
|
|
20
|
+
decision_id: str
|
|
21
|
+
decided_on: str # ISO date the decision was approved
|
|
22
|
+
title: str = ""
|
|
23
|
+
|
|
24
|
+
def summary(self) -> str:
|
|
25
|
+
"""One-line human description: ``ADR-M4-3 2026-09-05 (title)``."""
|
|
26
|
+
return f"{self.decision_id} {self.decided_on} ({self.title})"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
DECISION_M4_1_ADDITIVE_DSL = DecisionRef(
|
|
30
|
+
decision_id="ADR-M4-1",
|
|
31
|
+
decided_on="2026-09-02",
|
|
32
|
+
title="Additive, non-breaking DSL sections + typed Duration",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
DECISION_M4_3_SUCCESS_CRITERIA = DecisionRef(
|
|
36
|
+
decision_id="ADR-M4-3",
|
|
37
|
+
decided_on="2026-09-05",
|
|
38
|
+
title="Machine-evaluable success criteria + run verdict",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
DECISION_M4_4_OBSERVABILITY = DecisionRef(
|
|
42
|
+
decision_id="ADR-M4-4",
|
|
43
|
+
decided_on="2026-09-05",
|
|
44
|
+
title="Declarative observability/metrics sources",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
DECISION_M4_5_SCHEMA_FREEZE = DecisionRef(
|
|
48
|
+
decision_id="ADR-M4-5",
|
|
49
|
+
decided_on="2026-09-02",
|
|
50
|
+
title="Schema freeze + versioned migrations with up/down",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
DECISION_M5_1_MANIAC = DecisionRef(
|
|
54
|
+
decision_id="ADR-M5-1",
|
|
55
|
+
decided_on="2026-09-06",
|
|
56
|
+
title="Maniac mode: random fault injection dialed by config",
|
|
57
|
+
)
|
mayhem/domain/errors.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Structured domain failures.
|
|
2
|
+
|
|
3
|
+
Errors are part of the API (ADR-0002 discipline): every refusal or invariant
|
|
4
|
+
violation raises a typed error that is safe to render, log, and persist.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DomainError(Exception):
|
|
11
|
+
"""Base class for all domain-layer failures."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InvalidTransitionError(DomainError):
|
|
15
|
+
"""A state machine rejected a transition.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
entity: Kind of stateful entity, e.g. ``"fault_lease"``.
|
|
19
|
+
entity_id: Identifier of the offending instance.
|
|
20
|
+
current: Current state value.
|
|
21
|
+
requested: State value that was requested.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, entity: str, entity_id: str, current: str, requested: str) -> None:
|
|
25
|
+
self.entity = entity
|
|
26
|
+
self.entity_id = entity_id
|
|
27
|
+
self.current = current
|
|
28
|
+
self.requested = requested
|
|
29
|
+
super().__init__(
|
|
30
|
+
f"{entity} '{entity_id}' cannot transition from '{current}' to '{requested}'"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InvariantViolationError(DomainError):
|
|
35
|
+
"""A documented domain invariant was violated.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
rule: Stable identifier of the violated invariant, e.g. ``undo_required_before_active``.
|
|
39
|
+
message: Human-readable explanation with enough context to debug.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, rule: str, message: str) -> None:
|
|
43
|
+
self.rule = rule
|
|
44
|
+
super().__init__(f"[{rule}] {message}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TargetResolutionError(DomainError):
|
|
48
|
+
"""A declarative target selector matched nothing resolvable."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, selector: str, reason: str) -> None:
|
|
51
|
+
self.selector = selector
|
|
52
|
+
super().__init__(f"target selector '{selector}' unresolved: {reason}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SchemaValidationError(DomainError):
|
|
56
|
+
"""Fault params or experiment spec failed schema validation."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, subject: str, reason: str) -> None:
|
|
59
|
+
self.subject = subject
|
|
60
|
+
super().__init__(f"{subject}: {reason}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TargetDriftError(DomainError):
|
|
64
|
+
"""The planned ``RuntimeIdentity`` no longer matches the live one.
|
|
65
|
+
|
|
66
|
+
Declared in M1 (ADR-M1-3); *raised* in M2 when live re-validation
|
|
67
|
+
detects that a container was recreated mid-run under the same authored
|
|
68
|
+
name. Distinct from capability failures and ownership contention — a
|
|
69
|
+
drifted target is mismatched, not failed.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
run_id: str,
|
|
75
|
+
step_id: str,
|
|
76
|
+
planned: str,
|
|
77
|
+
live: str,
|
|
78
|
+
) -> None:
|
|
79
|
+
self.run_id = run_id
|
|
80
|
+
self.step_id = step_id
|
|
81
|
+
self.planned = planned
|
|
82
|
+
self.live = live
|
|
83
|
+
super().__init__(
|
|
84
|
+
f"target drift on step '{step_id}' (run '{run_id}'): "
|
|
85
|
+
f"planned identity '{planned}' no longer matches live "
|
|
86
|
+
f"identity '{live}'"
|
|
87
|
+
)
|
mayhem/domain/events.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Typed event union flowing through the observation hub.
|
|
2
|
+
|
|
3
|
+
Events are append-only facts. Agents emit them over their channel; the
|
|
4
|
+
controller persists them (SQLite) and mirrors them into the run journal.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
|
|
13
|
+
|
|
14
|
+
from mayhem.domain.common import utc_now
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EventKind(StrEnum):
|
|
18
|
+
RUN_STARTED = "run.started"
|
|
19
|
+
RUN_COMPLETED = "run.completed"
|
|
20
|
+
RUN_FAILED = "run.failed"
|
|
21
|
+
RUN_ABORT_REQUESTED = "run.abort_requested"
|
|
22
|
+
RUN_ABORTED = "run.aborted"
|
|
23
|
+
STEP_STARTED = "step.started"
|
|
24
|
+
STEP_FINISHED = "step.finished"
|
|
25
|
+
STEP_SKIPPED = "step.skipped"
|
|
26
|
+
FAULT_INJECTED = "fault.injected"
|
|
27
|
+
FAULT_OBSERVED = "fault.observed"
|
|
28
|
+
FAULT_RECOVERED = "fault.recovered"
|
|
29
|
+
FAULT_FAILED = "fault.failed"
|
|
30
|
+
TOOL_EXECUTED = "tool.executed"
|
|
31
|
+
CHECK_EVALUATED = "check.evaluated"
|
|
32
|
+
CRITERIA_EVALUATED = "criteria.evaluated"
|
|
33
|
+
OBSERVABILITY_COLLECTED = "observability.collected"
|
|
34
|
+
OBSERVABILITY_SOURCE_FAILED = "observability.source_failed"
|
|
35
|
+
LEASE_STATE_CHANGED = "lease.state_changed"
|
|
36
|
+
AGENT_STATE_CHANGED = "agent.state_changed"
|
|
37
|
+
DRIFT_REPORTED = "drift.reported"
|
|
38
|
+
SAFETY_REFUSED = "safety.refused"
|
|
39
|
+
MANIAC_DECISION = "maniac.decision"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Event(BaseModel):
|
|
43
|
+
"""Base envelope; concrete events add typed payloads via ``detail``."""
|
|
44
|
+
|
|
45
|
+
model_config = ConfigDict(frozen=True)
|
|
46
|
+
|
|
47
|
+
kind: EventKind
|
|
48
|
+
run_id: str | None = None
|
|
49
|
+
detail: dict[str, Any] = Field(default_factory=dict)
|
|
50
|
+
created_at_epoch_s: float = Field(default_factory=lambda: utc_now().timestamp())
|
|
51
|
+
|
|
52
|
+
def render_line(self) -> str:
|
|
53
|
+
"""Single-line journal rendering; structured enough to grep."""
|
|
54
|
+
parts = [f"{self.kind.value}"]
|
|
55
|
+
if self.run_id:
|
|
56
|
+
parts.append(f"run={self.run_id}")
|
|
57
|
+
parts.extend(f"{k}={v}" for k, v in sorted(self.detail.items()))
|
|
58
|
+
return " ".join(parts)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
EventStream: TypeAdapter[list[Event]] = TypeAdapter(list[Event])
|