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/cli/topology.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""``topology`` group: discovery and drift."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from mayhem.cli.resolver import make_group
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from mayhem.cli.context import CliContext
|
|
15
|
+
|
|
16
|
+
topology = make_group("topology", "Discover and inspect target-system topology.")
|
|
17
|
+
|
|
18
|
+
_COMPOSE_CANDIDATES = ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@topology.command("discover")
|
|
22
|
+
@click.option(
|
|
23
|
+
"--compose",
|
|
24
|
+
"compose_path",
|
|
25
|
+
default=None,
|
|
26
|
+
help="Compose file, directory containing one, or omit to auto-detect in cwd.",
|
|
27
|
+
)
|
|
28
|
+
@click.pass_context
|
|
29
|
+
def discover(ctx: click.Context, compose_path: str | None) -> None:
|
|
30
|
+
"""Run the topology provider pipeline and print graph + drift JSON."""
|
|
31
|
+
from mayhem.cli.app import _STATE
|
|
32
|
+
from mayhem.topology.providers.adapter_registry import best_effort as runtime_best_effort
|
|
33
|
+
from mayhem.topology.providers.compose import ComposeFileProvider
|
|
34
|
+
from mayhem.topology.service import TopologyService
|
|
35
|
+
|
|
36
|
+
resolved = _resolve_compose(compose_path)
|
|
37
|
+
engine = _resolve_engine(str(_STATE.get("engine", "")))
|
|
38
|
+
|
|
39
|
+
providers: list = []
|
|
40
|
+
|
|
41
|
+
# Compose blueprint — scoped runtime match.
|
|
42
|
+
if resolved is not None:
|
|
43
|
+
compose_provider = ComposeFileProvider(resolved)
|
|
44
|
+
runtime_provider = runtime_best_effort(engine)
|
|
45
|
+
if runtime_provider is not None:
|
|
46
|
+
runtime_provider.filter_by_compose(
|
|
47
|
+
compose_provider.project_name,
|
|
48
|
+
compose_provider.service_names,
|
|
49
|
+
)
|
|
50
|
+
providers = [p for p in (compose_provider, runtime_provider) if p is not None]
|
|
51
|
+
else:
|
|
52
|
+
# No compose file — fall back to mayhem.yaml target.containers,
|
|
53
|
+
# or discover all running containers.
|
|
54
|
+
from mayhem.config import load_config
|
|
55
|
+
from mayhem.domain.errors import SchemaValidationError
|
|
56
|
+
|
|
57
|
+
cli_ctx: CliContext | None = ctx.obj
|
|
58
|
+
try:
|
|
59
|
+
config, _sources = load_config(
|
|
60
|
+
config_path=cli_ctx.config if cli_ctx else None,
|
|
61
|
+
profile=cli_ctx.profile if cli_ctx else None,
|
|
62
|
+
)
|
|
63
|
+
except (OSError, ValueError, SchemaValidationError):
|
|
64
|
+
config = None
|
|
65
|
+
|
|
66
|
+
target_names: list[str] = []
|
|
67
|
+
if config is not None:
|
|
68
|
+
target_names = list(config.target.containers)
|
|
69
|
+
|
|
70
|
+
runtime_provider = runtime_best_effort(engine)
|
|
71
|
+
if runtime_provider is not None and target_names:
|
|
72
|
+
runtime_provider.filter_by_names(target_names)
|
|
73
|
+
|
|
74
|
+
providers = [p for p in (runtime_provider,) if p is not None]
|
|
75
|
+
|
|
76
|
+
if not providers:
|
|
77
|
+
raise click.ClickException(
|
|
78
|
+
"No docker-compose file found and no target containers "
|
|
79
|
+
"configured. Pass --compose <path> or add target.containers "
|
|
80
|
+
"to mayhem.yaml."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
result = TopologyService().discover(providers)
|
|
84
|
+
click.echo(
|
|
85
|
+
json.dumps(
|
|
86
|
+
{
|
|
87
|
+
"graph": result.graph.model_dump(mode="json"),
|
|
88
|
+
"drift": result.drift_report,
|
|
89
|
+
},
|
|
90
|
+
indent=2,
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _resolve_compose(explicit: str | None) -> str | None:
|
|
96
|
+
"""Resolve the compose file from user input.
|
|
97
|
+
|
|
98
|
+
Accepts three forms:
|
|
99
|
+
* ``None`` or empty — auto-detect in the current working directory.
|
|
100
|
+
* A **directory** path — search for compose candidates inside it.
|
|
101
|
+
* A **file** path — use it directly (returns ``None`` if missing).
|
|
102
|
+
"""
|
|
103
|
+
if not explicit:
|
|
104
|
+
return _find_compose_in(Path.cwd())
|
|
105
|
+
|
|
106
|
+
target = Path(explicit)
|
|
107
|
+
if target.is_dir():
|
|
108
|
+
return _find_compose_in(target)
|
|
109
|
+
if target.is_file():
|
|
110
|
+
return str(target)
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _find_compose_in(directory: Path) -> str | None:
|
|
115
|
+
"""Return the first matching compose file in *directory*, or ``None``."""
|
|
116
|
+
for name in _COMPOSE_CANDIDATES:
|
|
117
|
+
path = directory / name
|
|
118
|
+
if path.is_file():
|
|
119
|
+
return str(path)
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _resolve_engine(flag_value: str) -> str | None:
|
|
124
|
+
"""Turn the CLI flag value into an explicit engine, or None for auto-detect."""
|
|
125
|
+
if flag_value in ("docker", "podman"):
|
|
126
|
+
return flag_value
|
|
127
|
+
return None
|
mayhem/config.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Layered, versioned, strictly-validated configuration (ADR-0008).
|
|
2
|
+
|
|
3
|
+
Layering order (later wins):
|
|
4
|
+
built-in defaults → ``mayhem.yaml`` → profile overlay (``mayhem.{profile}.yaml``)
|
|
5
|
+
→ ``MAYHEM_*`` environment variables (limited allowlist) → CLI flags.
|
|
6
|
+
|
|
7
|
+
Every document carries ``apiVersion: mayhem/v1``; unknown versions and unknown
|
|
8
|
+
keys are rejected loudly. The effective merged config is snapshotted into the
|
|
9
|
+
store so every run records exactly what configuration produced it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Literal
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
22
|
+
|
|
23
|
+
from mayhem.domain.common import utc_now
|
|
24
|
+
from mayhem.domain.errors import SchemaValidationError
|
|
25
|
+
from mayhem.domain.experiments import BlastRadiusBudget, ManiacCfg
|
|
26
|
+
from mayhem.domain.risks import RiskLevel
|
|
27
|
+
|
|
28
|
+
API_VERSION: Literal["mayhem/v1"] = "mayhem/v1"
|
|
29
|
+
ENV_PREFIX = "MAYHEM_"
|
|
30
|
+
_ENV_ALLOWED = {
|
|
31
|
+
"STORAGE_PATH": "storage.path",
|
|
32
|
+
"ARTIFACTS_DIR": "storage.artifacts_dir",
|
|
33
|
+
"LOG_LEVEL": "log_level",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PolicyCfg(BaseModel):
|
|
38
|
+
"""G1 config-policy gate (ADR-0012 §4)."""
|
|
39
|
+
|
|
40
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
41
|
+
|
|
42
|
+
allow_faults: frozenset[str] | None = None # None ⇒ whole catalog
|
|
43
|
+
deny_faults: frozenset[str] = frozenset()
|
|
44
|
+
risk_ceiling: RiskLevel | None = None
|
|
45
|
+
allow_critical: bool = False # config-side half of the critical opt-in
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class StorageCfg(BaseModel):
|
|
49
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
50
|
+
|
|
51
|
+
path: str = "mayhem.db"
|
|
52
|
+
artifacts_dir: str = ".mayhem/artifacts"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ToolkitOverrides(BaseModel):
|
|
56
|
+
"""Pin binaries/versions per tool (architecture/toolkit.md §5)."""
|
|
57
|
+
|
|
58
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
59
|
+
|
|
60
|
+
binaries: dict[str, str] = Field(default_factory=dict)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TargetCfg(BaseModel):
|
|
64
|
+
"""Explicit container targets when running without a compose file."""
|
|
65
|
+
|
|
66
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
67
|
+
|
|
68
|
+
containers: list[str] = Field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MayhemConfigBase(BaseModel):
|
|
72
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
73
|
+
|
|
74
|
+
api_version: Literal["mayhem/v1"] = Field(default=API_VERSION, serialization_alias="apiVersion")
|
|
75
|
+
policy: PolicyCfg = Field(default_factory=PolicyCfg)
|
|
76
|
+
blast_radius: BlastRadiusBudget = Field(default_factory=BlastRadiusBudget)
|
|
77
|
+
storage: StorageCfg = Field(default_factory=StorageCfg)
|
|
78
|
+
toolkit: ToolkitOverrides = Field(default_factory=ToolkitOverrides)
|
|
79
|
+
runtime: Literal["docker", "podman"] = "docker"
|
|
80
|
+
target: TargetCfg = Field(default_factory=TargetCfg)
|
|
81
|
+
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
|
82
|
+
# ADR-M5-1 fallback for `mayhem maniac` when the drill spec's own
|
|
83
|
+
# `config.maniac` block is absent (spec-level settings win).
|
|
84
|
+
maniac: ManiacCfg = Field(default_factory=ManiacCfg)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Alias kept for readability at call sites.
|
|
88
|
+
MayhemConfig = MayhemConfigBase
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _deep_merge(dst: dict[str, Any], src: dict[str, Any]) -> None:
|
|
92
|
+
for key, value in src.items():
|
|
93
|
+
if isinstance(dst.get(key), dict) and isinstance(value, dict):
|
|
94
|
+
_deep_merge(dst[key], value)
|
|
95
|
+
else:
|
|
96
|
+
dst[key] = value
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _read_document(path: Path) -> dict[str, Any]:
|
|
100
|
+
if not path.exists():
|
|
101
|
+
raise SchemaValidationError("config", f"config file not found: {path}")
|
|
102
|
+
try:
|
|
103
|
+
data = yaml.safe_load(path.read_text()) or {}
|
|
104
|
+
except yaml.YAMLError as exc:
|
|
105
|
+
raise SchemaValidationError("config", f"invalid YAML in {path}: {exc}") from None
|
|
106
|
+
if not isinstance(data, dict):
|
|
107
|
+
raise SchemaValidationError("config", f"{path} must contain a mapping")
|
|
108
|
+
version = data.get("apiVersion")
|
|
109
|
+
if version != API_VERSION:
|
|
110
|
+
raise SchemaValidationError(
|
|
111
|
+
"config", f"apiVersion must be {API_VERSION!r}, got {version!r}"
|
|
112
|
+
)
|
|
113
|
+
return data
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _apply_env(data: dict[str, Any], env: dict[str, str]) -> dict[str, Any]:
|
|
117
|
+
merged: dict[str, Any] = json.loads(json.dumps(data)) # deep-copy defaults away
|
|
118
|
+
for suffix, dotted in _ENV_ALLOWED.items():
|
|
119
|
+
value = env.get(ENV_PREFIX + suffix)
|
|
120
|
+
if value is None:
|
|
121
|
+
continue
|
|
122
|
+
target = merged
|
|
123
|
+
keys = dotted.split(".")
|
|
124
|
+
for key in keys[:-1]:
|
|
125
|
+
target = target.setdefault(key, {})
|
|
126
|
+
target[keys[-1]] = value
|
|
127
|
+
return merged
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def load_config(
|
|
131
|
+
*,
|
|
132
|
+
config_path: str | Path | None = None,
|
|
133
|
+
profile: str | None = None,
|
|
134
|
+
cli_overrides: dict[str, Any] | None = None,
|
|
135
|
+
environ: dict[str, str] | None = None,
|
|
136
|
+
skip_default_file_if_spec: str | Path | None = None,
|
|
137
|
+
) -> tuple[MayhemConfig, dict[str, str]]:
|
|
138
|
+
"""Return ``(effective config, source map)``.
|
|
139
|
+
|
|
140
|
+
The source map records which layer last supplied each top-level section —
|
|
141
|
+
provenance is part of the snapshot.
|
|
142
|
+
|
|
143
|
+
``skip_default_file_if_spec`` guards against the default config file
|
|
144
|
+
(``mayhem.yaml``) doubling as the drill-spec being run: when a spec is
|
|
145
|
+
executing from ``mayhem.yaml`` and no explicit ``--config`` was given, the
|
|
146
|
+
file layer is skipped (pure defaults apply) instead of re-parsing the drill
|
|
147
|
+
spec as a strictly-forbidden config document.
|
|
148
|
+
"""
|
|
149
|
+
env = dict(os.environ if environ is None else environ)
|
|
150
|
+
sources: dict[str, str] = dict.fromkeys(
|
|
151
|
+
("policy", "blast_radius", "storage", "toolkit", "log_level"),
|
|
152
|
+
"defaults",
|
|
153
|
+
)
|
|
154
|
+
merged: dict[str, Any] = {"api_version": API_VERSION}
|
|
155
|
+
|
|
156
|
+
def absorb(layer_data: dict[str, Any], layer_name: str) -> None:
|
|
157
|
+
for key, value in layer_data.items():
|
|
158
|
+
if key == "apiVersion":
|
|
159
|
+
continue
|
|
160
|
+
field_name = "api_version" if key == "apiVersion" else key
|
|
161
|
+
existing = merged.get(field_name)
|
|
162
|
+
if isinstance(existing, dict) and isinstance(value, dict):
|
|
163
|
+
_deep_merge(existing, value)
|
|
164
|
+
else:
|
|
165
|
+
merged[field_name] = value
|
|
166
|
+
sources[field_name] = layer_name
|
|
167
|
+
|
|
168
|
+
base_path = Path(config_path) if config_path else Path("mayhem.yaml")
|
|
169
|
+
skip_file = (
|
|
170
|
+
config_path is None
|
|
171
|
+
and skip_default_file_if_spec is not None
|
|
172
|
+
and base_path.resolve() == Path(skip_default_file_if_spec).resolve()
|
|
173
|
+
)
|
|
174
|
+
if not skip_file and (config_path or base_path.exists()):
|
|
175
|
+
absorb(_read_document(base_path), "file")
|
|
176
|
+
if profile:
|
|
177
|
+
overlay = base_path.parent / f"mayhem.{profile}.yaml"
|
|
178
|
+
absorb(_read_document(overlay), f"profile:{profile}")
|
|
179
|
+
absorb(_apply_env({}, env), "env")
|
|
180
|
+
if cli_overrides:
|
|
181
|
+
absorb({k: v for k, v in cli_overrides.items() if v is not None}, "cli")
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
return MayhemConfig.model_validate(merged), sources
|
|
185
|
+
except ValidationError as exc:
|
|
186
|
+
raise SchemaValidationError("config", f"invalid configuration: {exc}") from None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def snapshot_id_for(config: MayhemConfig) -> str:
|
|
190
|
+
canonical = json.dumps(config.model_dump(mode="json"), sort_keys=True, separators=(",", ":"))
|
|
191
|
+
return "cfg-" + hashlib.sha256(canonical.encode()).hexdigest()[:12]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def save_snapshot(store: Any, config: MayhemConfig, sources: dict[str, str]) -> str:
|
|
195
|
+
"""Persist the effective config; idempotent per identical content."""
|
|
196
|
+
snapshot_id = snapshot_id_for(config)
|
|
197
|
+
with store.write() as conn:
|
|
198
|
+
conn.execute(
|
|
199
|
+
"INSERT OR IGNORE INTO config_snapshots (id, resolved_json, source_map, created_at)"
|
|
200
|
+
" VALUES (?, ?, ?, ?)",
|
|
201
|
+
(
|
|
202
|
+
snapshot_id,
|
|
203
|
+
config.model_dump_json(),
|
|
204
|
+
json.dumps(sources, sort_keys=True),
|
|
205
|
+
utc_now().isoformat(),
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
return snapshot_id
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Controller (Phase 3): planner, executor, janitor, watchdog, maniac."""
|