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
mayhem/cli/resolver.py ADDED
@@ -0,0 +1,72 @@
1
+ """General command-tree prefix resolution (the "unique prefix" contract).
2
+
3
+ Every group in the mayhem CLI is a :class:`PrefixGroup`, so abbreviation works
4
+ at *every* level of the tree, not just the root: ``mayhem e s`` resolves to
5
+ ``experiment show`` because ``s`` is unambiguous inside ``experiment``.
6
+
7
+ Resolution rules, applied per level:
8
+
9
+ 1. exact name wins;
10
+ 2. otherwise collect all commands whose names start with the given prefix;
11
+ 3. exactly one candidate -> resolved;
12
+ 4. zero candidates -> usage error with close-match suggestions;
13
+ 5. multiple candidates -> educational ambiguity error listing every match.
14
+
15
+ Prefix resolution happens strictly before any callback runs, so a shorthand
16
+ invocation can never bypass validation or safety gates — it dispatches to the
17
+ identical handler object.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import difflib
23
+
24
+ import click
25
+
26
+ PREFIX_HELP = (
27
+ "Commands may be abbreviated to any unique prefix at every level of this "
28
+ "tree (e.g. 'mayhem e v' for 'mayhem experiment validate'). Exact names and "
29
+ "'--help' always work."
30
+ )
31
+
32
+
33
+ class CommandResolutionError(click.UsageError):
34
+ """A command token could not resolve to exactly one command."""
35
+
36
+ def __init__(self, token: str, candidates: tuple[str, ...]) -> None:
37
+ self.token = token
38
+ self.candidates = candidates
39
+ super().__init__(self._render(), ctx=None)
40
+
41
+ def _render(self) -> str:
42
+ if not self.candidates:
43
+ difflib.get_close_matches(self.token, [], n=1)
44
+ return f"No command matches {self.token!r}."
45
+ lines = "\n".join(f" - {name}" for name in self.candidates)
46
+ return (
47
+ f"Command prefix {self.token!r} is ambiguous; it matches "
48
+ f"{len(self.candidates)} commands:\n{lines}\n"
49
+ "Use a longer prefix to select one of them."
50
+ )
51
+
52
+
53
+ class PrefixGroup(click.Group):
54
+ """A Click Group whose subcommands resolve by unique prefix."""
55
+
56
+ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
57
+ exact = super().get_command(ctx, cmd_name)
58
+ if exact is not None:
59
+ return exact
60
+ candidates = sorted(name for name in self.list_commands(ctx) if name.startswith(cmd_name))
61
+ if len(candidates) == 1:
62
+ return super().get_command(ctx, candidates[0])
63
+ if not candidates:
64
+ raise CommandResolutionError(cmd_name, ())
65
+ raise CommandResolutionError(cmd_name, tuple(candidates))
66
+
67
+
68
+ def make_group(name: str, help_text: str, **attrs: object) -> PrefixGroup:
69
+ """Factory so nested groups inherit prefix resolution automatically."""
70
+ attrs.setdefault("help", help_text)
71
+ attrs.setdefault("epilog", PREFIX_HELP)
72
+ return PrefixGroup(name=name, **attrs) # type: ignore[arg-type]
mayhem/cli/services.py ADDED
@@ -0,0 +1,459 @@
1
+ """Application services for the CLI — the only place CLI touches mayhem internals.
2
+
3
+ Handlers stay thin: they translate arguments into service calls and results
4
+ into output. Everything here is UI-framework-agnostic so a future REST/UI
5
+ layer can reuse it verbatim.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import time
12
+ import uuid
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any, Protocol
17
+
18
+ from mayhem.config import load_config, save_snapshot
19
+ from mayhem.controller.executor import RunEngine, RunResult
20
+ from mayhem.controller.planner import plan_drill, plan_maniac
21
+ from mayhem.controller.safety import SafetyContext, environment_fingerprint
22
+ from mayhem.domain.experiments import BlastRadiusBudget, DrillSpec, ExecutionPlan
23
+ from mayhem.domain.topology import (
24
+ Edge,
25
+ EdgeKind,
26
+ NodeKind,
27
+ ProcessNode,
28
+ TopologyGraph,
29
+ )
30
+ from mayhem.infra.lease_repository import SQLiteLeaseSink
31
+ from mayhem.infra.store import Store
32
+ from mayhem.spec import load_drill
33
+
34
+ if TYPE_CHECKING:
35
+ from collections.abc import Callable
36
+
37
+ from mayhem.domain.events import Event
38
+ from mayhem.toolkit.registry import CapabilityReport
39
+
40
+ DEFAULT_DB = "mayhem.db"
41
+
42
+
43
+ def open_store(db: str) -> Store:
44
+ return Store.open_migrated(Path(db))
45
+
46
+
47
+ def build_graph(compose: str | None) -> TopologyGraph:
48
+ """Build a topology graph from a compose blueprint (ADR-0006).
49
+
50
+ Drill specs are compose-native (Phase 6): the graph is always derived
51
+ from ``docker-compose.yaml``, so the manual ``--process``/``--service``/
52
+ ``--host`` overrides are gone. ``compose`` defaults to auto-detect in the
53
+ caller (``_resolve_compose``), so reaching here with ``None`` means no
54
+ blueprint was found.
55
+ """
56
+ if compose is None:
57
+ raise ValueError(
58
+ "no docker-compose blueprint found — pass --compose <path> "
59
+ "or place a compose file in the working directory"
60
+ )
61
+ from mayhem.cli.app import _STATE
62
+ from mayhem.cli.topology import _resolve_engine
63
+ from mayhem.topology.providers.adapter_registry import best_effort as runtime_best_effort
64
+ from mayhem.topology.providers.compose import ComposeFileProvider
65
+ from mayhem.topology.service import TopologyService
66
+
67
+ engine = _resolve_engine(str(_STATE.get("engine", "")))
68
+
69
+ compose_provider = ComposeFileProvider(compose)
70
+ runtime_provider = runtime_best_effort(engine)
71
+ if runtime_provider is not None:
72
+ runtime_provider.filter_by_compose(
73
+ compose_provider.project_name,
74
+ compose_provider.service_names,
75
+ )
76
+
77
+ result = (
78
+ TopologyService()
79
+ .discover(
80
+ [provider for provider in (compose_provider, runtime_provider) if provider is not None]
81
+ )
82
+ .graph
83
+ )
84
+
85
+ existing_process_names = {n.name for n in result.nodes if n.kind == NodeKind.PROCESS}
86
+ extra_nodes: list[Any] = []
87
+ extra_edges: list[Edge] = []
88
+ for svc in result.nodes:
89
+ if svc.kind == NodeKind.SERVICE and svc.name not in existing_process_names:
90
+ proc_id = f"p-{svc.name}"
91
+ extra_nodes.append(ProcessNode(id=proc_id, name=svc.name, pid=0, host_id="h-local"))
92
+ extra_edges.append(Edge(src=svc.id, dst=proc_id, kind=EdgeKind.RUNS_ON))
93
+ if extra_nodes:
94
+ result = TopologyGraph(
95
+ nodes=tuple(result.nodes) + tuple(extra_nodes),
96
+ edges=tuple(result.edges) + tuple(extra_edges),
97
+ )
98
+ return result
99
+
100
+
101
+ @dataclass(frozen=True, slots=True)
102
+ class Prepared:
103
+ config_snapshot_id: str
104
+ topology_snapshot_id: str
105
+ fingerprint: str
106
+ safety: SafetyContext
107
+
108
+
109
+ def prepare(
110
+ *,
111
+ config_path: str | None,
112
+ profile: str | None,
113
+ allow_critical: bool,
114
+ store: Store,
115
+ graph: TopologyGraph,
116
+ compose: str | None,
117
+ spec_path: str | None = None,
118
+ ) -> Prepared:
119
+ cfg, sources = load_config(
120
+ config_path=config_path,
121
+ profile=profile,
122
+ environ={"MAYHEM_LOG_LEVEL": "INFO"},
123
+ skip_default_file_if_spec=spec_path,
124
+ )
125
+ cfg_snapshot_id = save_snapshot(store, cfg, sources)
126
+
127
+ fingerprint = environment_fingerprint(
128
+ host_names=[n.name for n in graph.of_kind(NodeKind.HOST)],
129
+ compose_digest=_compose_digest(compose),
130
+ profile=profile,
131
+ )
132
+ topo_snapshot_id = "topo-" + fingerprint[:12]
133
+ with store.write() as conn:
134
+ conn.execute(
135
+ "INSERT OR IGNORE INTO topology_snapshots (id, run_id, graph_json, drift_report,"
136
+ " fingerprint) VALUES (?, NULL, ?, '{}', ?)",
137
+ (topo_snapshot_id, graph.model_dump_json(), fingerprint),
138
+ )
139
+
140
+ budget = cfg.blast_radius or BlastRadiusBudget()
141
+ return Prepared(
142
+ config_snapshot_id=cfg_snapshot_id,
143
+ topology_snapshot_id=topo_snapshot_id,
144
+ fingerprint=fingerprint,
145
+ safety=SafetyContext(
146
+ policy=cfg.policy,
147
+ budget=budget,
148
+ fingerprint=fingerprint,
149
+ allow_critical_cli=allow_critical,
150
+ ),
151
+ )
152
+
153
+
154
+ @dataclass(frozen=True, slots=True)
155
+ class CompiledPlan:
156
+ run_id: str
157
+ plan: ExecutionPlan
158
+
159
+
160
+ def plan_from_spec(
161
+ spec_path: str,
162
+ graph: TopologyGraph,
163
+ *,
164
+ prepared: Prepared,
165
+ engine: str = "podman",
166
+ ) -> CompiledPlan:
167
+ """Compile a drill spec into a frozen :class:`ExecutionPlan`.
168
+
169
+ The drill spec is validated (schema), then every referenced container is
170
+ required to exist in the topology graph before the plan is compiled
171
+ ([ADR-0019]/[ADR-0021]). ``engine`` flows into the plan so PIDs/IPs are
172
+ resolved against the right runtime at execution time ([ADR-0020]).
173
+ """
174
+ spec = load_drill(spec_path)
175
+ # The run id is `r-<name>-<suffix>`: the readable base keeps the drill
176
+ # identifiable, while the unique suffix lets any number of runs against the
177
+ # same spec be recorded in one persistent DB without colliding on the
178
+ # `runs.id` PRIMARY KEY.
179
+ run_id = f"r-{spec.name}-{uuid.uuid4().hex[:8]}"
180
+ common: dict[str, str] = {
181
+ "config_snapshot_id": prepared.config_snapshot_id,
182
+ "topology_snapshot_id": prepared.topology_snapshot_id,
183
+ "environment_fingerprint": prepared.fingerprint,
184
+ }
185
+ plan = plan_drill(
186
+ run_id,
187
+ spec,
188
+ graph,
189
+ **common,
190
+ engine=engine,
191
+ spec_dir=str(Path(spec_path).parent),
192
+ )
193
+ return CompiledPlan(run_id=run_id, plan=plan)
194
+
195
+
196
+ def plan_maniac_from_spec(
197
+ spec_path: str,
198
+ graph: TopologyGraph,
199
+ *,
200
+ prepared: Prepared,
201
+ engine: str = "podman",
202
+ config_path: str | None = None,
203
+ profile: str | None = None,
204
+ steps: int | None = None,
205
+ spec: DrillSpec | None = None,
206
+ ) -> CompiledPlan:
207
+ """Compile a drill spec into a random maniac plan (ADR-M5-1).
208
+
209
+ Behaves like :func:`plan_from_spec` (schema validation, topology
210
+ resolution) but replaces the authored execution with ``run_level`` random
211
+ rounds. The maniac settings come from the spec's own ``config.maniac``
212
+ block when present, otherwise from the layered ``mayhem.yaml`` config
213
+ (``maniac:`` key); the spec wins when both exist (ADR-M5-1). ``steps``
214
+ (CLI ``-s/--steps``) overrides the round count on top of either source.
215
+
216
+ ``spec`` supplies an already-built spec instead of loading ``spec_path``
217
+ — the ``mayhem maniac -c compose`` no-spec mode synthesizes its config
218
+ from the topology (:func:`mayhem.controller.planner.synthesize_maniac_spec`)
219
+ and has no file to load. ``spec_path`` then names the cwd for relative
220
+ artifacts (load-script embedding) and is not read.
221
+ """
222
+ document = spec if spec is not None else load_drill(spec_path)
223
+ maniac = document.config.maniac
224
+ if maniac is None:
225
+ cfg, _sources = load_config(
226
+ config_path=config_path,
227
+ profile=profile,
228
+ environ={},
229
+ skip_default_file_if_spec=None if spec is not None else spec_path,
230
+ )
231
+ maniac = cfg.maniac
232
+ if steps is not None:
233
+ maniac = maniac.model_copy(update={"run_level": steps})
234
+ run_id = f"r-{document.name}-{uuid.uuid4().hex[:8]}"
235
+ common: dict[str, str] = {
236
+ "config_snapshot_id": prepared.config_snapshot_id,
237
+ "topology_snapshot_id": prepared.topology_snapshot_id,
238
+ "environment_fingerprint": prepared.fingerprint,
239
+ }
240
+ plan = plan_maniac(
241
+ run_id,
242
+ document,
243
+ graph,
244
+ **common,
245
+ engine=engine,
246
+ spec_dir=str(Path(spec_path).parent if spec_path else Path.cwd()),
247
+ maniac=maniac,
248
+ )
249
+ return CompiledPlan(run_id=run_id, plan=plan)
250
+
251
+
252
+ def engine_for(
253
+ store: Store,
254
+ engine: str = "podman",
255
+ *,
256
+ live_graph: Callable[[], TopologyGraph] | None = None,
257
+ on_event: Callable[[Event], None] | None = None,
258
+ bypass: dict[tuple[str, str], str] | None = None,
259
+ ) -> RunEngine:
260
+ return RunEngine(
261
+ store,
262
+ SQLiteLeaseSink(store),
263
+ engine=engine,
264
+ live_graph=live_graph,
265
+ on_event=on_event,
266
+ bypass=bypass,
267
+ )
268
+
269
+
270
+ def recent_runs(store: Store, limit: int) -> list[dict[str, Any]]:
271
+ rows = store.query(
272
+ "SELECT id, kind, status, started_at FROM runs ORDER BY started_at DESC LIMIT ?",
273
+ (limit,),
274
+ )
275
+ return [dict(row) for row in rows]
276
+
277
+
278
+ def run_detail(store: Store, run_id: str) -> dict[str, Any] | None:
279
+ rows = store.query("SELECT * FROM runs WHERE id = ?", (run_id,))
280
+ return dict(rows[0]) if rows else None
281
+
282
+
283
+ def run_journal(store: Store, run_id: str) -> dict[str, Any]:
284
+ steps = [
285
+ dict(row)
286
+ for row in store.query(
287
+ "SELECT seq, id, action_type, status, started_at, ended_at, error"
288
+ " FROM step_runs WHERE run_id = ? ORDER BY seq",
289
+ (run_id,),
290
+ )
291
+ ]
292
+ events = [
293
+ dict(row)
294
+ for row in store.query(
295
+ "SELECT ts, kind, payload_json FROM events WHERE run_id = ? ORDER BY ts",
296
+ (run_id,),
297
+ )
298
+ ]
299
+ leases = [
300
+ dict(row)
301
+ for row in store.query(
302
+ "SELECT id, state, fault_id, release_mechanism FROM fault_leases"
303
+ " WHERE run_id = ? ORDER BY created_epoch_s",
304
+ (run_id,),
305
+ )
306
+ ]
307
+ return {"steps": steps, "events": events, "leases": leases}
308
+
309
+
310
+ def effective_config(config_path: str | None, profile: str | None) -> tuple[Any, dict[str, str]]:
311
+ return load_config(config_path=config_path, profile=profile, environ={})
312
+
313
+
314
+ def probe_capabilities(host: str) -> CapabilityReport:
315
+ from mayhem.toolkit.registry import default_registry
316
+
317
+ return default_registry().probe(host=host)
318
+
319
+
320
+ class CampaignExecutionError(Exception):
321
+ """Raised when a campaign abort-campaign policy hits an unexpected error."""
322
+
323
+
324
+ class ObservationSink(Protocol):
325
+ """Anything able to append rows to the observations table."""
326
+
327
+ def save_observation(
328
+ self,
329
+ kind: str,
330
+ *,
331
+ run_id: str = "",
332
+ source: str = "",
333
+ data: dict[str, object] | None = None,
334
+ ) -> None: ...
335
+
336
+
337
+ @dataclass
338
+ class CampaignRunEntry:
339
+ """Outcome of one experiment spec executed within a campaign."""
340
+
341
+ spec_path: str
342
+ run_id: str
343
+ status: str # completed | failed
344
+ verdict: str = ""
345
+
346
+
347
+ @dataclass
348
+ class CampaignRunResult:
349
+ """Aggregate result of executing a campaign's experiment sequence."""
350
+
351
+ campaign_id: str
352
+ status: str # completed | failed | aborted
353
+ runs: list[CampaignRunEntry] = field(default_factory=list)
354
+
355
+ def summary_md(self) -> str:
356
+ lines = [f"# Campaign {self.campaign_id}", "", f"**status**: {self.status}"]
357
+ lines.append(f"**runs**: {len(self.runs)}")
358
+ for entry in self.runs:
359
+ mark = "ok" if entry.status == "completed" else "FAIL"
360
+ lines.append(f"- [{mark}] {entry.spec_path} → {entry.run_id}")
361
+ return "\n".join(lines)
362
+
363
+
364
+ def run_campaign_sequence(
365
+ *,
366
+ campaign_id: str,
367
+ experiments: list[str],
368
+ policy: dict[str, Any],
369
+ window: dict[str, Any],
370
+ store: ObservationSink,
371
+ run_one: Callable[[str], RunResult],
372
+ now_fn: Callable[[], float] = time.time,
373
+ ) -> CampaignRunResult:
374
+ """Execute one spec at a time in order, honoring campaign policy and window.
375
+
376
+ ``run_one(spec_path)`` is the injectable per-spec executor, returning a
377
+ :class:`RunResult` or raising on failure. The on_failure policy is
378
+ ``abort_campaign`` (stop at first failure), ``skip_and_continue``, or
379
+ ``retry_then_abort`` (one bounded retry, then abort). ``window.max_duration_s``
380
+ is a hard deadline; ``window.cooldown_between_experiments_s`` is slept
381
+ between specs.
382
+ """
383
+ on_failure = policy.get("on_experiment_failure", "abort_campaign")
384
+ max_duration_s = float(window.get("max_duration_s") or 3600.0)
385
+ cooldown_s = max(0.0, float(window.get("cooldown_between_experiments_s") or 0.0))
386
+
387
+ started = now_fn()
388
+ result = CampaignRunResult(campaign_id=campaign_id, status="completed")
389
+ for i, spec_path in enumerate(experiments):
390
+ elapsed = now_fn() - started
391
+ if elapsed >= max_duration_s:
392
+ result.status = "aborted"
393
+ store.save_observation(
394
+ "campaign_stop",
395
+ source=campaign_id,
396
+ data={"reason": "deadline_passed", "elapsed_s": elapsed},
397
+ )
398
+ break
399
+
400
+ def _attempt(path: str) -> RunResult | None:
401
+ try:
402
+ return run_one(path)
403
+ except Exception as exc:
404
+ if on_failure == "abort_campaign":
405
+ raise CampaignExecutionError(path, exc) from exc
406
+ return None
407
+
408
+ def _record(ran: RunResult, _sp: str = spec_path, _i: int = i) -> None:
409
+ result.runs.append(
410
+ CampaignRunEntry(
411
+ spec_path=_sp,
412
+ run_id=ran.run_id,
413
+ status=ran.status,
414
+ verdict=ran.verdict.value if ran.verdict else "",
415
+ )
416
+ )
417
+ store.save_observation(
418
+ "campaign_run",
419
+ run_id=ran.run_id,
420
+ source=campaign_id,
421
+ data={"spec": _sp, "status": ran.status, "index": _i},
422
+ )
423
+
424
+ ran = _attempt(spec_path)
425
+ if ran is not None:
426
+ _record(ran)
427
+
428
+ if ran is not None and ran.status == "completed":
429
+ pass # continue; cooldown after non-final specs below
430
+ elif ran is not None: # failed / aborted
431
+ if on_failure == "skip_and_continue":
432
+ continue
433
+ if on_failure == "retry_then_abort":
434
+ retry = _attempt(spec_path)
435
+ if retry is not None and retry.status == "completed":
436
+ _record(retry)
437
+ continue
438
+ result.status = "failed"
439
+ break
440
+ elif on_failure != "skip_and_continue":
441
+ result.status = "failed"
442
+ break
443
+
444
+ if i < len(experiments) - 1 and cooldown_s > 0:
445
+ time.sleep(cooldown_s)
446
+
447
+ if result.status == "completed":
448
+ store.save_observation(
449
+ "campaign_done",
450
+ source=campaign_id,
451
+ data={"runs": len(result.runs)},
452
+ )
453
+ return result
454
+
455
+
456
+ def _compose_digest(graph_source: str | None) -> str:
457
+ if graph_source is None:
458
+ return "no-compose"
459
+ return hashlib.sha256(Path(graph_source).read_bytes()).hexdigest()
mayhem/cli/style.py ADDED
@@ -0,0 +1,101 @@
1
+ """TTY-aware ANSI styling shared by every CLI command.
2
+
3
+ Wrapping user-facing lines through these helpers keeps log streams scannable:
4
+ green for success, cyan for informational, yellow for warnings and bypasses,
5
+ orange for failures and red-zone states. Color auto-cancels when the stream is
6
+ not a terminal (pipes, CI, test capture) or ``NO_COLOR`` is set, so rendered —
7
+ and captured — output stays plain.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+ from typing import IO
15
+
16
+ import click
17
+
18
+ # Orange ≈ ANSI 38;2;255;159;26 — click has no 16-color orange.
19
+ ORANGE: tuple[int, int, int] = (255, 159, 26)
20
+
21
+
22
+ def _stream(*, err: bool) -> IO[str]:
23
+ return sys.stderr if err else sys.stdout
24
+
25
+
26
+ def _use_color(stream: IO[str]) -> bool:
27
+ if os.environ.get("NO_COLOR"):
28
+ return False
29
+ return bool(getattr(stream, "isatty", lambda: False)())
30
+
31
+
32
+ def _style(text: str, fg: str | tuple[int, int, int], *, err: bool, bold: bool) -> str:
33
+ if not _use_color(_stream(err=err)):
34
+ return text
35
+ return click.style(text, fg=fg, bold=bold)
36
+
37
+
38
+ def green(text: str, *, err: bool = False, bold: bool = False) -> str:
39
+ """Success and healthy-state marks."""
40
+ return _style(text, "green", err=err, bold=bold)
41
+
42
+
43
+ def cyan(text: str, *, err: bool = False, bold: bool = False) -> str:
44
+ """Informational lines, timestamps, and step identifiers."""
45
+ return _style(text, "cyan", err=err, bold=bold)
46
+
47
+
48
+ def yellow(text: str, *, err: bool = False, bold: bool = False) -> str:
49
+ """Warnings and bypassed (skip-with-reason) outcomes."""
50
+ return _style(text, "yellow", err=err, bold=bold)
51
+
52
+
53
+ def orange(text: str, *, err: bool = False, bold: bool = False) -> str:
54
+ """Failures and anything in the red zone."""
55
+ return _style(text, ORANGE, err=err, bold=bold)
56
+
57
+
58
+ def ts(text: str, *, err: bool = False, bold: bool = False) -> str:
59
+ """Timestamps and wall-clock — bright-cyan, visible on dark terminals."""
60
+ return _style(text, "bright_cyan", err=err, bold=bold)
61
+
62
+
63
+ def ok(text: str, *, err: bool = False, bold: bool = True) -> str:
64
+ """``[ok]`` marks and green confirmations."""
65
+ return _style(text, "green", err=err, bold=bold)
66
+
67
+
68
+ def warn(text: str, *, err: bool = True, bold: bool = True) -> str:
69
+ """``warning:`` prefix — yellow, bold."""
70
+ return _style(text, "yellow", err=err, bold=bold)
71
+
72
+
73
+ def info(text: str, *, err: bool = False, bold: bool = False) -> str:
74
+ """``info:`` prefix — cyan."""
75
+ return _style(text, "cyan", err=err, bold=bold)
76
+
77
+
78
+ def danger(text: str, *, err: bool = True, bold: bool = True) -> str:
79
+ """``error:`` / red-zone prefix — orange, bold."""
80
+ return _style(text, ORANGE, err=err, bold=bold)
81
+
82
+
83
+ def state(text: str) -> str:
84
+ """Color a run/step status value by what it means.
85
+
86
+ Call on already-padded text (``state(f"{status:<10}")``) so the escape
87
+ codes never disturb column alignment.
88
+ """
89
+ mapping = {
90
+ "completed": green,
91
+ "running": cyan,
92
+ "pending": cyan,
93
+ "created": cyan,
94
+ "planning": cyan,
95
+ "validated": cyan,
96
+ "recovering": yellow,
97
+ "skipped": yellow,
98
+ "cancelled": yellow,
99
+ "bypassed": yellow,
100
+ }
101
+ return mapping.get(text, orange)(text)
mayhem/cli/toolkit.py ADDED
@@ -0,0 +1,41 @@
1
+ """``toolkit`` group: fault catalog and capability probing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import click
8
+
9
+ from mayhem.cli.resolver import make_group
10
+ from mayhem.domain.catalog import all_definitions
11
+
12
+ toolkit = make_group("toolkit", "Inspect the fault catalog and local tool capabilities.")
13
+
14
+
15
+ @toolkit.command("faults")
16
+ def faults() -> None:
17
+ """List the fault catalog with risk and compensatability."""
18
+ for definition in sorted(all_definitions(), key=lambda d: d.id):
19
+ undoable = "yes" if definition.reversible else "no"
20
+ click.echo(f"{definition.id:<24} risk={definition.risk.value:<6} undo={undoable}")
21
+
22
+
23
+ @toolkit.command("list")
24
+ @click.option("--host", default="local", show_default=True, help="Host to probe.")
25
+ @click.option("--json", "as_json", is_flag=True, help="Emit the CapabilityReport as JSON.")
26
+ def list_tools(host: str, as_json: bool) -> None:
27
+ """Probe declared tool manifests on the host and report capabilities."""
28
+ from mayhem.cli.services import probe_capabilities
29
+ from mayhem.toolkit.registry import default_registry
30
+
31
+ report = probe_capabilities(host=host)
32
+ if as_json:
33
+ click.echo(json.dumps(report.model_dump(mode="json"), indent=2))
34
+ return
35
+ seen = {probed.manifest.tool for probed in report.tools}
36
+ for probed in report.tools:
37
+ caps = ",".join(probed.manifest.provides)
38
+ click.echo(f"{probed.manifest.tool:<16} ok {probed.version:<12} [{caps}]")
39
+ for manifest in default_registry().manifests:
40
+ if manifest.tool not in seen:
41
+ click.echo(f"{manifest.tool:<16} MISSING probe failed or version undetectable")