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,149 @@
1
+ """Forward migration runner with down-migration support (ADR-M4-5).
2
+
3
+ Every migration is an ordered, immutable Python module exposing ``version``,
4
+ ``name``, ``statements``, and optionally ``down_statements``. Applied versions
5
+ are recorded in ``_schema_migrations``; ``run_migrations`` applies pending ones
6
+ in a single transaction each and refuses out-of-order application
7
+ (testing-strategy §5). ``run_down_migrations`` reverses applied migrations
8
+ back to an older version in descending order — the mechanism ADR-M4-5's
9
+ "down-migration restores the baseline" acceptance relies on.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sqlite3
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING
17
+
18
+ from mayhem.domain.errors import DomainError
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Sequence
22
+
23
+
24
+ class MigrationError(DomainError):
25
+ """Migration machinery failure."""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Migration:
30
+ version: int
31
+ name: str
32
+ statements: tuple[str, ...]
33
+ down_statements: tuple[str, ...] = ()
34
+
35
+ @property
36
+ def migration_id(self) -> str:
37
+ return f"{self.version:04d}_{self.name}"
38
+
39
+
40
+ def run_migrations(conn: sqlite3.Connection, migrations: Sequence[Migration]) -> list[str]:
41
+ """Apply all pending migrations; returns ids applied this call."""
42
+ conn.execute("""
43
+ CREATE TABLE IF NOT EXISTS _schema_migrations (
44
+ version INTEGER PRIMARY KEY,
45
+ name TEXT NOT NULL,
46
+ applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
47
+ )
48
+ """)
49
+ applied = {int(row[0]) for row in conn.execute("SELECT version FROM _schema_migrations")}
50
+ applied_now: list[str] = []
51
+ previous = -1
52
+ for migration in migrations:
53
+ if migration.version <= previous:
54
+ raise MigrationError(
55
+ f"migrations must be strictly increasing; got {migration.version} after {previous}"
56
+ )
57
+ previous = migration.version
58
+ if migration.version in applied:
59
+ continue
60
+ try:
61
+ # Schema changes may rebuild parent tables (e.g. extending a CHECK
62
+ # constraint); FK enforcement blocks those DROP/RENAME steps, so it
63
+ # is disabled per-migration and re-enabled afterwards. A migration
64
+ # runs as the single writer with no concurrent readers, so this is
65
+ # safe and matches the SQLite table-rebuild procedure.
66
+ conn.execute("PRAGMA foreign_keys=OFF")
67
+ with conn:
68
+ for statement in migration.statements:
69
+ conn.execute(statement)
70
+ conn.execute(
71
+ "INSERT INTO _schema_migrations (version, name) VALUES (?, ?)",
72
+ (migration.version, migration.name),
73
+ )
74
+ conn.execute("PRAGMA foreign_keys=ON")
75
+ except sqlite3.Error as exc:
76
+ conn.execute("PRAGMA foreign_keys=ON")
77
+ raise MigrationError(f"migration {migration.migration_id} failed: {exc}") from exc
78
+ applied_now.append(migration.migration_id)
79
+ return applied_now
80
+
81
+
82
+ def current_version(conn: sqlite3.Connection) -> int | None:
83
+ """Latest applied schema version; None when database is fresh."""
84
+ exists = conn.execute(
85
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='_schema_migrations'"
86
+ ).fetchone()
87
+ if exists is None:
88
+ return None
89
+ row = conn.execute("SELECT MAX(version) FROM _schema_migrations").fetchone()
90
+ return int(row[0]) if row and row[0] is not None else None
91
+
92
+
93
+ def run_down_migrations(
94
+ conn: sqlite3.Connection,
95
+ migrations: Sequence[Migration],
96
+ target_version: int,
97
+ ) -> list[str]:
98
+ """Roll an upgraded schema back to ``target_version`` (ADR-M4-5).
99
+
100
+ Applied migrations newer than ``target_version`` are reversed in descending
101
+ order using their ``down_statements``; each reversal runs in its own
102
+ transaction and the ``_schema_migrations`` row is deleted with it. A
103
+ migration without a down path refuses to roll back rather than truncate
104
+ history. Returns the migration ids reversed.
105
+ """
106
+ by_version = {m.version: m for m in migrations}
107
+ applied = {
108
+ int(row[0]): str(row[1])
109
+ for row in conn.execute("SELECT version, name FROM _schema_migrations")
110
+ }
111
+ current = max(applied, default=-1)
112
+ if current == -1:
113
+ return [] # fresh database has nothing to roll back
114
+ if target_version >= current:
115
+ raise MigrationError(
116
+ f"target version {target_version} is not below current schema {current}; "
117
+ "nothing to roll back"
118
+ )
119
+ missing = set(applied) - set(by_version)
120
+ if missing:
121
+ raise MigrationError(
122
+ f"database has applied migrations unknown to this store: {sorted(missing)}"
123
+ )
124
+ reversed_now: list[str] = []
125
+ ordered = sorted(applied, reverse=True)
126
+ for version in ordered:
127
+ if version <= target_version:
128
+ continue
129
+ migration = by_version[version]
130
+ if not migration.down_statements:
131
+ raise MigrationError(
132
+ f"migration {migration.migration_id} has no down path; "
133
+ "refusing to truncate schema history"
134
+ )
135
+ try:
136
+ conn.execute("PRAGMA foreign_keys=OFF")
137
+ with conn:
138
+ for statement in migration.down_statements:
139
+ conn.execute(statement)
140
+ conn.execute(
141
+ "DELETE FROM _schema_migrations WHERE version = ?",
142
+ (version,),
143
+ )
144
+ conn.execute("PRAGMA foreign_keys=ON")
145
+ except sqlite3.Error as exc:
146
+ conn.execute("PRAGMA foreign_keys=ON")
147
+ raise MigrationError(f"down-migration {migration.migration_id} failed: {exc}") from exc
148
+ reversed_now.append(migration.migration_id)
149
+ return reversed_now
mayhem/infra/report.py ADDED
@@ -0,0 +1,227 @@
1
+ """M5 report + experiment guidance builder (M5 Phase 5.7).
2
+
3
+ Consumes Run/Outcome history, coverage, and the Maniac candidate backlog to
4
+ produce a rich, model-only report:
5
+ - a coverage heatmap (rendered ASCII grid),
6
+ - per-cell verdicts with supported-by evidence (run links),
7
+ - the candidate backlog ranked by Maniac score,
8
+ - the guided "what to run next" list (untouched/``UNKNOWN`` cells).
9
+
10
+ The builder is deterministic and pure: given the same history it always
11
+ renders the same report. Assertions in tests are model/string level.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from typing import TYPE_CHECKING
18
+
19
+ from mayhem.infra.candidate_gates import CandidateGatePipeline
20
+ from mayhem.infra.maniac import SelectionInputs, select_next
21
+
22
+ if TYPE_CHECKING:
23
+ from mayhem.domain.candidates import ExperimentCandidate
24
+ from mayhem.domain.coverage import CoverageCell, CoverageRecord
25
+ from mayhem.domain.run_outcome import Outcome, RunRecord
26
+
27
+ # Verdict / heatmap symbols.
28
+ _CELL_COVERED = "█"
29
+ _CELL_UNKNOWN = "·"
30
+
31
+
32
+ class _PermissiveGate:
33
+ """A gate that passes every candidate (used when no gates are given)."""
34
+
35
+ def check(self, candidate: object) -> str | None:
36
+ return None
37
+
38
+
39
+ def _permissive_gates() -> CandidateGatePipeline:
40
+ return CandidateGatePipeline(safety=_PermissiveGate(), feasibility=_PermissiveGate())
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class CellVerdictReport:
45
+ """One cell's verdict plus the runs that support/evidence it."""
46
+
47
+ cell: CoverageCell
48
+ covered: bool
49
+ run_ids: tuple[str, ...] = ()
50
+ verdict: str = "UNKNOWN"
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class M5Report:
55
+ """Rendered report over a landscape + recorded history."""
56
+
57
+ landscape: tuple[CoverageCell, ...] = ()
58
+ heatmap: str = ""
59
+ cell_verdicts: tuple[CellVerdictReport, ...] = ()
60
+ coverage_fraction: float = 0.0
61
+ next_to_run: tuple[CoverageCell, ...] = ()
62
+ ranked_backlog: tuple[ExperimentCandidate, ...] = ()
63
+
64
+ def render_markdown(self) -> str:
65
+ """Render the whole report as a markdown string."""
66
+ lines = [
67
+ "# M5 Campaign Report",
68
+ "",
69
+ "## Coverage",
70
+ "",
71
+ self.heatmap,
72
+ "",
73
+ f"**coverage**: {self.coverage_fraction:.1%} of the landscape covered",
74
+ "",
75
+ "## Per-cell verdicts",
76
+ "",
77
+ ]
78
+ if not self.cell_verdicts:
79
+ lines.append("_no cells recorded_")
80
+ else:
81
+ lines.append("| cell | verdict | evidence runs |")
82
+ lines.append("| --- | --- | --- |")
83
+ for v in self.cell_verdicts:
84
+ ev = ", ".join(v.run_ids) if v.run_ids else "—"
85
+ lines.append(f"| `{v.cell.key}` | {v.verdict} | {ev} |")
86
+ lines.append("")
87
+ lines.append("## What to run next")
88
+ lines.append("")
89
+ if self.next_to_run:
90
+ for cell in self.next_to_run:
91
+ lines.append(f"- `{cell.key}` (UNKNOWN)")
92
+ else:
93
+ lines.append("_no gap remains_")
94
+ lines.append("")
95
+ lines.append("## Candidate backlog (Maniac-ranked)")
96
+ lines.append("")
97
+ if self.ranked_backlog:
98
+ for i, cand in enumerate(self.ranked_backlog, 1):
99
+ lines.append(f"{i}. `{cand.id}` -> {cand.target} [{cand.primary_fault}]")
100
+ else:
101
+ lines.append("_backlog empty_")
102
+ return "\n".join(lines) + "\n"
103
+
104
+
105
+ def _cell_verdict(
106
+ covered: bool,
107
+ run_ids: tuple[str, ...],
108
+ runs: dict[str, RunRecord],
109
+ outcomes: dict[str, Outcome],
110
+ ) -> str:
111
+ """Derive a verdict for a cell from its evidence (Runs/Outcomes)."""
112
+ if not covered or not run_ids:
113
+ return "UNKNOWN"
114
+ # Use the most recent run's outcome as the cell verdict.
115
+ for run_id in reversed(run_ids):
116
+ outcome = outcomes.get(run_id)
117
+ if outcome is not None and outcome.total_checks > 0:
118
+ return "PASS" if outcome.all_checks_passed else "FAIL"
119
+ # No outcome with checks; fall back to the run verdict if it FAILED.
120
+ for run_id in reversed(run_ids):
121
+ run = runs.get(run_id)
122
+ if run is not None and run.verdict.value == "fail":
123
+ return "FAIL"
124
+ return "PASS"
125
+
126
+
127
+ def render_heatmap(
128
+ landscape: tuple[CoverageCell, ...],
129
+ covered_keys: frozenset[str],
130
+ ) -> str:
131
+ """Render a compact ASCII heatmap: targets (rows) x faults (cols).
132
+
133
+ A cell is marked covered when any landscape cell in that (target, fault)
134
+ group is covered. Unknown cells use ``·``, covered cells use ``█``.
135
+ """
136
+ targets: list[str] = []
137
+ faults: list[str] = []
138
+ seen_cells: dict[tuple[str, str], bool] = {}
139
+ for cell in landscape:
140
+ group = (cell.target, cell.fault_kind)
141
+ covered = cell.key in covered_keys
142
+ if group not in seen_cells or (covered and not seen_cells[group]):
143
+ seen_cells[group] = covered
144
+ if cell.target not in targets:
145
+ targets.append(cell.target)
146
+ if cell.fault_kind not in faults:
147
+ faults.append(cell.fault_kind)
148
+
149
+ header = " " + " ".join(f"{f:<6}" for f in faults)
150
+ lines = [header]
151
+ for t in targets:
152
+ row_parts = []
153
+ for f in faults:
154
+ covered = seen_cells.get((t, f), False)
155
+ row_parts.append(f"{_CELL_COVERED if covered else _CELL_UNKNOWN:<6}")
156
+ lines.append(f"{t:<8} " + " ".join(row_parts))
157
+ lines.append("")
158
+ lines.append(f"legend: {_CELL_COVERED}=covered {_CELL_UNKNOWN}=unknown")
159
+ return "\n".join(lines)
160
+
161
+
162
+ def build_m5_report(
163
+ *,
164
+ landscape: tuple[CoverageCell, ...],
165
+ covered_records: tuple[CoverageRecord, ...] = (),
166
+ runs: dict[str, RunRecord] | None = None,
167
+ outcomes: dict[str, Outcome] | None = None,
168
+ candidates: tuple[ExperimentCandidate, ...] = (),
169
+ gates: CandidateGatePipeline | None = None,
170
+ seed: int = 0,
171
+ max_runs: int = 50,
172
+ ) -> M5Report:
173
+ """Assemble the report from recorded history (pure/deterministic)."""
174
+ runs = runs or {}
175
+ outcomes = outcomes or {}
176
+
177
+ covered_keys: set[str] = set()
178
+ run_ids_by_cell: dict[str, list[str]] = {}
179
+ for rec in covered_records:
180
+ covered_keys.add(rec.cell.key)
181
+ run_ids_by_cell.setdefault(rec.cell.key, []).append(rec.run_id)
182
+
183
+ covered_count = len(covered_keys)
184
+ fraction = covered_count / len(landscape) if landscape else 0.0
185
+
186
+ verdicts = tuple(
187
+ CellVerdictReport(
188
+ cell=cell,
189
+ covered=cell.key in covered_keys,
190
+ run_ids=tuple(run_ids_by_cell.get(cell.key, ())),
191
+ verdict=_cell_verdict(
192
+ cell.key in covered_keys,
193
+ tuple(run_ids_by_cell.get(cell.key, ())),
194
+ runs,
195
+ outcomes,
196
+ ),
197
+ )
198
+ for cell in landscape
199
+ )
200
+
201
+ unknown = [cell for cell in landscape if cell.key not in covered_keys]
202
+
203
+ # Candidate backlog ranked by Maniac (deterministic greedy coverage).
204
+ ranked: tuple[ExperimentCandidate, ...] = ()
205
+ if candidates:
206
+ result = select_next(
207
+ SelectionInputs(
208
+ candidates=candidates,
209
+ covered_keys=frozenset(covered_keys),
210
+ gates=gates if gates is not None else _permissive_gates(),
211
+ max_runs=max_runs,
212
+ coverage_target=max(len(landscape), 1),
213
+ seed=seed,
214
+ )
215
+ )
216
+ ranked = result.selected
217
+
218
+ heatmap = render_heatmap(landscape, frozenset(covered_keys))
219
+
220
+ return M5Report(
221
+ landscape=landscape,
222
+ heatmap=heatmap,
223
+ cell_verdicts=verdicts,
224
+ coverage_fraction=fraction,
225
+ next_to_run=tuple(unknown),
226
+ ranked_backlog=ranked,
227
+ )
mayhem/infra/store.py ADDED
@@ -0,0 +1,200 @@
1
+ """SQLite store: one connection, WAL, disciplined pragmas (ADR-0007).
2
+
3
+ The controller is the single writer. Agents never open this file.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import sqlite3
10
+ import threading
11
+ from contextlib import contextmanager
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ from mayhem.domain.common import utc_now
16
+ from mayhem.domain.run_outcome import Outcome, RunRecord, RunStatus, RunVerdict
17
+ from mayhem.infra.migrations import ALL_MIGRATIONS
18
+ from mayhem.infra.migrator import Migration, current_version, run_down_migrations, run_migrations
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Iterator
22
+
23
+ _BUSY_TIMEOUT_MS = 5_000
24
+
25
+
26
+ class Store:
27
+ """Owns the SQLite connection and migration lifecycle."""
28
+
29
+ def __init__(self, path: Path | str) -> None:
30
+ self._path = Path(path)
31
+ self._path.parent.mkdir(parents=True, exist_ok=True)
32
+ # One connection shared across threads (parallel fault execution, ADR-0022).
33
+ # Access is serialized by ``_lock``; WAL + busy_timeout handle cross-process writers.
34
+ self._conn = sqlite3.connect(
35
+ self._path, timeout=_BUSY_TIMEOUT_MS / 1000, check_same_thread=False
36
+ )
37
+ self._lock = threading.RLock()
38
+ self._conn.row_factory = sqlite3.Row
39
+ for pragma in (
40
+ "PRAGMA journal_mode=WAL",
41
+ f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}",
42
+ "PRAGMA foreign_keys=ON",
43
+ "PRAGMA synchronous=NORMAL",
44
+ ):
45
+ self._conn.execute(pragma)
46
+
47
+ @classmethod
48
+ def open_migrated(
49
+ cls, path: Path | str, migrations: tuple[Migration, ...] = ALL_MIGRATIONS
50
+ ) -> Store:
51
+ store = cls(path)
52
+ store.migrate(migrations)
53
+ return store
54
+
55
+ def migrate(self, migrations: tuple[Migration, ...] = ALL_MIGRATIONS) -> list[str]:
56
+ with self._lock:
57
+ return run_migrations(self._conn, migrations)
58
+
59
+ def migrate_down(
60
+ self, target_version: int, migrations: tuple[Migration, ...] = ALL_MIGRATIONS
61
+ ) -> list[str]:
62
+ """Roll the schema back to ``target_version`` (ADR-M4-5)."""
63
+ with self._lock:
64
+ return run_down_migrations(self._conn, migrations, target_version)
65
+
66
+ @property
67
+ def schema_version(self) -> int | None:
68
+ with self._lock:
69
+ return current_version(self._conn)
70
+
71
+ @contextmanager
72
+ def write(self) -> Iterator[sqlite3.Connection]:
73
+ """Single-writer transaction boundary; commits or rolls back atomically."""
74
+ try:
75
+ with self._lock, self._conn:
76
+ yield self._conn
77
+ except sqlite3.Error:
78
+ raise
79
+
80
+ def query(self, sql: str, params: tuple[object, ...] = ()) -> list[sqlite3.Row]:
81
+ with self._lock:
82
+ return list(self._conn.execute(sql, params))
83
+
84
+ def close(self) -> None:
85
+ self._conn.close()
86
+
87
+ # ── Run / Outcome persistence (ADR-M5-1) ──────────────────────────
88
+
89
+ def save_run_record(self, run: RunRecord) -> None:
90
+ """Persist a RunRecord to the m5_runs table."""
91
+ with self.write() as conn:
92
+ conn.execute(
93
+ """INSERT OR REPLACE INTO m5_runs
94
+ (id, experiment_name, spec_json, plan_json, seed,
95
+ status, environment_fingerprint, config_snapshot_id,
96
+ started_at, ended_at, description, verdict,
97
+ tags_json, extra_json)
98
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
99
+ (
100
+ run.run_id,
101
+ run.experiment_name,
102
+ run.spec_json,
103
+ run.plan_json,
104
+ run.seed,
105
+ run.status.value,
106
+ run.environment_fingerprint,
107
+ run.config_snapshot_id,
108
+ run.started_at,
109
+ run.ended_at,
110
+ run.description,
111
+ run.verdict.value,
112
+ json.dumps(list(run.tags)),
113
+ json.dumps(run.extra),
114
+ ),
115
+ )
116
+
117
+ def load_run_record(self, run_id: str) -> RunRecord | None:
118
+ """Load a RunRecord by id, or None if absent."""
119
+ rows = self.query("SELECT * FROM m5_runs WHERE id = ?", (run_id,))
120
+ if not rows:
121
+ return None
122
+ row = rows[0]
123
+ return RunRecord(
124
+ run_id=row["id"],
125
+ experiment_name=row["experiment_name"],
126
+ spec_json=row["spec_json"],
127
+ plan_json=row["plan_json"],
128
+ seed=row["seed"],
129
+ status=RunStatus(row["status"]),
130
+ environment_fingerprint=row["environment_fingerprint"],
131
+ config_snapshot_id=row["config_snapshot_id"],
132
+ started_at=row["started_at"],
133
+ ended_at=row["ended_at"],
134
+ description=row["description"],
135
+ verdict=RunVerdict(row["verdict"]),
136
+ tags=tuple(json.loads(row["tags_json"])),
137
+ extra=json.loads(row["extra_json"]),
138
+ )
139
+
140
+ def save_outcome(self, outcome: Outcome) -> None:
141
+ """Persist an Outcome to the m5_outcomes table."""
142
+ with self.write() as conn:
143
+ conn.execute(
144
+ """INSERT OR REPLACE INTO m5_outcomes
145
+ (run_id, body_json, body_hash, checks_passed, checks_failed,
146
+ metric_deltas_json, residual_effect, stability_signal,
147
+ extra_json)
148
+ VALUES (?,?,?,?,?,?,?,?,?)""",
149
+ (
150
+ outcome.run_id,
151
+ outcome.body_json,
152
+ outcome.body_hash,
153
+ outcome.checks_passed,
154
+ outcome.checks_failed,
155
+ json.dumps(outcome.metric_deltas),
156
+ outcome.residual_effect,
157
+ outcome.stability_signal,
158
+ json.dumps(outcome.extra),
159
+ ),
160
+ )
161
+
162
+ def save_observation(
163
+ self,
164
+ kind: str,
165
+ *,
166
+ run_id: str = "",
167
+ source: str = "",
168
+ data: dict[str, object] | None = None,
169
+ ) -> None:
170
+ """Persist one row to the observations table (ADR-M5 phase-gated)."""
171
+ with self.write() as conn:
172
+ conn.execute(
173
+ """INSERT INTO observations (kind, run_id, source, data_json, timestamp)
174
+ VALUES (?,?,?,?,?)""",
175
+ (
176
+ kind,
177
+ run_id,
178
+ source,
179
+ json.dumps(data or {}),
180
+ utc_now().isoformat(),
181
+ ),
182
+ )
183
+
184
+ def load_outcome(self, run_id: str) -> Outcome | None:
185
+ """Load an Outcome by run_id, or None if absent."""
186
+ rows = self.query("SELECT * FROM m5_outcomes WHERE run_id = ?", (run_id,))
187
+ if not rows:
188
+ return None
189
+ row = rows[0]
190
+ return Outcome(
191
+ run_id=row["run_id"],
192
+ body_json=row["body_json"],
193
+ body_hash=row["body_hash"],
194
+ checks_passed=row["checks_passed"],
195
+ checks_failed=row["checks_failed"],
196
+ metric_deltas=json.loads(row["metric_deltas_json"]),
197
+ residual_effect=row["residual_effect"],
198
+ stability_signal=row["stability_signal"],
199
+ extra=json.loads(row["extra_json"]),
200
+ )
mayhem/py.typed ADDED
File without changes
mayhem/spec.py ADDED
@@ -0,0 +1,52 @@
1
+ """YAML drill-spec loading — the authored entry point into the domain.
2
+
3
+ Spec files stay close to the domain models: keys map 1:1 onto pydantic
4
+ fields (durations as ``10s`` strings, enums as lowercase values), so the
5
+ loader is a thin validate-and-discriminate layer, not a second language.
6
+
7
+ ``load_drill`` / ``parse_drill`` handle the ``kind: drill`` format (ADR-0019)
8
+ — the only supported spec kind since the clean break (ADR-0021).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import yaml
17
+ from pydantic import ValidationError
18
+
19
+ from mayhem.domain.errors import SchemaValidationError
20
+ from mayhem.domain.experiments import DrillSpec
21
+
22
+
23
+ def _flatten(exc: ValidationError) -> str:
24
+ parts = []
25
+ for err in exc.errors()[:8]:
26
+ loc = ".".join(str(part) for part in err["loc"]) or "<root>"
27
+ parts.append(f"{loc}: {err['msg']}")
28
+ return "; ".join(parts)
29
+
30
+
31
+ def load_drill(path: str | Path) -> DrillSpec:
32
+ """Load a drill spec from YAML; refuse anything the domain refuses."""
33
+ raw_path = Path(path)
34
+ if not raw_path.is_file():
35
+ raise FileNotFoundError(f"spec file not found: {raw_path}")
36
+ try:
37
+ data = yaml.safe_load(raw_path.read_text(encoding="utf-8"))
38
+ except yaml.YAMLError as exc:
39
+ raise SchemaValidationError("drill", f"invalid YAML: {exc}") from None
40
+ return parse_drill(data)
41
+
42
+
43
+ def parse_drill(data: Any) -> DrillSpec:
44
+ """Parse raw YAML data into a DrillSpec."""
45
+ if not isinstance(data, dict):
46
+ raise SchemaValidationError("drill", "top level must be a mapping")
47
+ if data.get("kind") != "drill":
48
+ raise SchemaValidationError("drill", f"expected kind: drill, got: {data.get('kind')}")
49
+ try:
50
+ return DrillSpec.model_validate(data)
51
+ except ValidationError as exc:
52
+ raise SchemaValidationError("drill", _flatten(exc)) from None
@@ -0,0 +1 @@
1
+ """Shared toolkit primitives (Phase 2): tool_runner, snapshots, fingerprinting."""