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,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})"
@@ -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
+ )
@@ -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
+ )
@@ -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])