ctrlrelay 0.1.5__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.
@@ -0,0 +1,291 @@
1
+ """Configuration loading and validation for ctrlrelay."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+ from pydantic import BaseModel, Field, field_validator, model_validator
11
+
12
+
13
+ class ConfigError(Exception):
14
+ """Raised when configuration loading or validation fails."""
15
+
16
+
17
+ class TransportType(str, Enum):
18
+ TELEGRAM = "telegram"
19
+ FILE_MOCK = "file_mock"
20
+
21
+
22
+ class AutomationPolicy(str, Enum):
23
+ AUTO = "auto"
24
+ ASK = "ask"
25
+ NEVER = "never"
26
+
27
+
28
+ class PathsConfig(BaseModel):
29
+ """File system paths configuration."""
30
+
31
+ state_db: Path
32
+ worktrees: Path
33
+ bare_repos: Path
34
+ contexts: Path
35
+ skills: Path
36
+
37
+ @field_validator("*", mode="before")
38
+ @classmethod
39
+ def expand_path(cls, v: Any) -> Any:
40
+ if isinstance(v, str):
41
+ return Path(v).expanduser()
42
+ return v
43
+
44
+
45
+ class AgentConfig(BaseModel):
46
+ """Headless coding-agent configuration.
47
+
48
+ ``type`` selects which adapter the dispatcher uses to talk to the
49
+ agent CLI. Today only ``claude`` is implemented; the field exists
50
+ so future adapters (e.g. ``codex``, ``opencode``, ``hermes``) can
51
+ be plugged in without a config schema change. The other fields
52
+ (``binary``, ``default_timeout_seconds``, ``output_format``) are
53
+ common across most CLI-driven agents; adapters that need agent-
54
+ specific knobs can add a nested sub-model later.
55
+ """
56
+
57
+ type: str = "claude"
58
+ binary: str = "claude"
59
+ default_timeout_seconds: int = 1800
60
+ output_format: str = "json"
61
+
62
+
63
+ # Backwards-compat alias. Older code and docs reference ``ClaudeConfig``;
64
+ # keep the name importable but have it resolve to the renamed class so
65
+ # `isinstance(cfg, ClaudeConfig)` and `ClaudeConfig(...)` still work.
66
+ # Scheduled for removal once downstream repos migrate.
67
+ ClaudeConfig = AgentConfig
68
+
69
+
70
+ class TelegramConfig(BaseModel):
71
+ """Telegram transport configuration."""
72
+
73
+ bot_token_env: str = "CTRLRELAY_TELEGRAM_TOKEN"
74
+ chat_id: int = 0
75
+ socket_path: Path = Field(
76
+ default_factory=lambda: Path("~/.ctrlrelay/ctrlrelay.sock").expanduser()
77
+ )
78
+
79
+ @field_validator("socket_path", mode="before")
80
+ @classmethod
81
+ def expand_socket_path(cls, v: Any) -> Any:
82
+ if isinstance(v, str):
83
+ return Path(v).expanduser()
84
+ return v
85
+
86
+
87
+ class FileMockConfig(BaseModel):
88
+ """File mock transport configuration for testing."""
89
+
90
+ inbox: Path
91
+ outbox: Path
92
+
93
+ @field_validator("*", mode="before")
94
+ @classmethod
95
+ def expand_path(cls, v: Any) -> Any:
96
+ if isinstance(v, str):
97
+ return Path(v).expanduser()
98
+ return v
99
+
100
+
101
+ class TransportConfig(BaseModel):
102
+ """Transport configuration (Telegram or file mock)."""
103
+
104
+ type: TransportType = TransportType.FILE_MOCK
105
+ telegram: TelegramConfig | None = None
106
+ file_mock: FileMockConfig | None = None
107
+
108
+ @model_validator(mode="after")
109
+ def validate_transport_config(self) -> "TransportConfig":
110
+ if self.type == TransportType.TELEGRAM and self.telegram is None:
111
+ raise ValueError("telegram config required when type is 'telegram'")
112
+ if self.type == TransportType.FILE_MOCK and self.file_mock is None:
113
+ raise ValueError("file_mock config required when type is 'file_mock'")
114
+ return self
115
+
116
+
117
+ class DashboardConfig(BaseModel):
118
+ """Dashboard client configuration."""
119
+
120
+ enabled: bool = True
121
+ url: str = ""
122
+ auth_token_env: str = "CTRLRELAY_DASHBOARD_TOKEN"
123
+ sync_config_on_heartbeat: bool = False
124
+
125
+
126
+ class DeployConfig(BaseModel):
127
+ """Deployment configuration for a repo."""
128
+
129
+ provider: str = "digitalocean"
130
+ app_id: str = ""
131
+
132
+
133
+ class CodeReviewConfig(BaseModel):
134
+ """Code review configuration for a repo."""
135
+
136
+ method: str = "mcp_then_cli"
137
+ mcp_tool: str = "mcp__codex-reviewer__codex_review"
138
+ cli_command: str = "codex review"
139
+
140
+
141
+ class AutomationConfig(BaseModel):
142
+ """Automation policies for a repo."""
143
+
144
+ dependabot_patch: AutomationPolicy = AutomationPolicy.AUTO
145
+ dependabot_minor: AutomationPolicy = AutomationPolicy.ASK
146
+ dependabot_major: AutomationPolicy = AutomationPolicy.NEVER
147
+ codeql_dismiss: AutomationPolicy = AutomationPolicy.ASK
148
+ secret_alerts: AutomationPolicy = AutomationPolicy.NEVER
149
+ deploy_after_merge: AutomationPolicy = AutomationPolicy.AUTO
150
+
151
+
152
+ class RepoConfig(BaseModel):
153
+ """Configuration for a single repository."""
154
+
155
+ name: str
156
+ local_path: Path
157
+ automation: AutomationConfig = Field(default_factory=AutomationConfig)
158
+ deploy: DeployConfig | None = None
159
+ code_review: CodeReviewConfig = Field(default_factory=CodeReviewConfig)
160
+ dev_branch_template: str = "fix/issue-{n}"
161
+
162
+ @field_validator("local_path", mode="before")
163
+ @classmethod
164
+ def expand_local_path(cls, v: Any) -> Any:
165
+ if isinstance(v, str):
166
+ return Path(v).expanduser()
167
+ return v
168
+
169
+
170
+ class SchedulesConfig(BaseModel):
171
+ """Cron schedules for background jobs run by the poller daemon.
172
+
173
+ Values are standard 5-field cron expressions (minute hour dom month dow),
174
+ evaluated in the top-level ``timezone``. Each schedule is validated at
175
+ config load time so an unparseable expression fails fast rather than
176
+ silently disabling the job.
177
+ """
178
+
179
+ secops_cron: str = "0 6 * * *"
180
+
181
+ @field_validator("secops_cron")
182
+ @classmethod
183
+ def validate_cron(cls, v: str) -> str:
184
+ from ctrlrelay.core.scheduler import _build_vixie_trigger
185
+
186
+ try:
187
+ # Build through the same helper the scheduler uses so
188
+ # (a) DOW normalization and (b) Vixie DOM/DOW-OR splitting
189
+ # are both exercised at load time. Bad expressions surface
190
+ # synchronously instead of at daemon start.
191
+ _build_vixie_trigger(v, timezone=None)
192
+ except Exception as e:
193
+ raise ValueError(
194
+ f"invalid cron expression {v!r}: {e}"
195
+ ) from e
196
+ return v
197
+
198
+
199
+ class Config(BaseModel):
200
+ """Root configuration model for ctrlrelay orchestrator."""
201
+
202
+ version: str = "1"
203
+ node_id: str
204
+ timezone: str = "UTC"
205
+ paths: PathsConfig
206
+ agent: AgentConfig = Field(default_factory=AgentConfig)
207
+ transport: TransportConfig
208
+ dashboard: DashboardConfig = Field(default_factory=DashboardConfig)
209
+ schedules: SchedulesConfig = Field(default_factory=SchedulesConfig)
210
+ repos: list[RepoConfig] = Field(default_factory=list)
211
+
212
+ @model_validator(mode="before")
213
+ @classmethod
214
+ def migrate_claude_to_agent(cls, data: Any) -> Any:
215
+ """Accept the legacy ``claude:`` top-level key as an alias for
216
+ ``agent:``. Emits a ``DeprecationWarning`` so operators see the
217
+ migration hint in logs. Removed in a future release.
218
+
219
+ If both keys are present, ``agent`` wins and ``claude`` is
220
+ ignored (fail-loud would break supervised daemons; we prefer
221
+ silent win-on-new-name during the migration window)."""
222
+ if isinstance(data, dict) and "claude" in data and "agent" not in data:
223
+ import warnings
224
+ warnings.warn(
225
+ "config key 'claude:' is deprecated; rename to 'agent:'. "
226
+ "See https://github.com/AInvirion/ctrlrelay/blob/main/"
227
+ "CHANGELOG.md for the migration.",
228
+ DeprecationWarning,
229
+ stacklevel=2,
230
+ )
231
+ data["agent"] = data.pop("claude")
232
+ return data
233
+
234
+ @property
235
+ def claude(self) -> AgentConfig:
236
+ """Legacy attribute alias so callers still writing
237
+ ``config.claude.binary`` keep working. New code should prefer
238
+ ``config.agent.*``."""
239
+ return self.agent
240
+
241
+ @field_validator("timezone")
242
+ @classmethod
243
+ def validate_timezone(cls, v: str) -> str:
244
+ """Reject unparseable IANA zones at load time.
245
+
246
+ Since the scheduler feeds ``timezone`` directly into APScheduler's
247
+ CronTrigger, a typo like ``America/Santiagoo`` would only surface
248
+ as a ``ZoneInfoNotFoundError`` when the poller daemon starts —
249
+ much worse than a synchronous config error at load.
250
+ """
251
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
252
+
253
+ try:
254
+ ZoneInfo(v)
255
+ except ZoneInfoNotFoundError as e:
256
+ raise ValueError(f"unknown timezone {v!r}: {e}") from e
257
+ return v
258
+
259
+
260
+ def load_config(path: Path | str) -> Config:
261
+ """Load and validate configuration from a YAML file.
262
+
263
+ Args:
264
+ path: Path to the orchestrator.yaml file.
265
+
266
+ Returns:
267
+ Validated Config object.
268
+
269
+ Raises:
270
+ ConfigError: If the file cannot be loaded or validation fails.
271
+ """
272
+ path = Path(path)
273
+
274
+ if not path.exists():
275
+ raise ConfigError(f"Config file not found: {path}")
276
+
277
+ try:
278
+ with open(path) as f:
279
+ data = yaml.safe_load(f)
280
+ except OSError as e:
281
+ raise ConfigError(f"Failed to read config file: {e}") from e
282
+ except yaml.YAMLError as e:
283
+ raise ConfigError(f"Failed to parse YAML: {e}") from e
284
+
285
+ if data is None:
286
+ raise ConfigError("Config file is empty")
287
+
288
+ try:
289
+ return Config.model_validate(data)
290
+ except Exception as e:
291
+ raise ConfigError(f"Config validation failed: {e}") from e
@@ -0,0 +1,202 @@
1
+ """Headless coding-agent subprocess dispatcher for ctrlrelay.
2
+
3
+ Today the only concrete adapter is :class:`ClaudeDispatcher` (wraps
4
+ ``claude -p``). The :func:`make_agent_dispatcher` factory is the seam
5
+ where additional backends (Codex, OpenCode, Hermes, Kiro, …) will plug
6
+ in: each adapter must expose the same async ``spawn_session`` shape
7
+ so pipelines can stay agent-agnostic.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import os
14
+ import shutil
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Protocol
18
+
19
+ from ctrlrelay.core.checkpoint import CheckpointState, CheckpointStatus, read_checkpoint
20
+
21
+ if TYPE_CHECKING:
22
+ from ctrlrelay.core.config import AgentConfig
23
+
24
+
25
+ def _find_claude() -> str:
26
+ """Find claude binary, checking common paths if not in PATH."""
27
+ claude = shutil.which("claude")
28
+ if claude:
29
+ return claude
30
+ for path in [
31
+ os.path.expanduser("~/.local/bin/claude"),
32
+ "/usr/local/bin/claude",
33
+ "/opt/homebrew/bin/claude",
34
+ ]:
35
+ if os.path.isfile(path) and os.access(path, os.X_OK):
36
+ return path
37
+ return "claude"
38
+
39
+
40
+ @dataclass
41
+ class SessionResult:
42
+ """Result of a Claude session."""
43
+
44
+ session_id: str
45
+ exit_code: int
46
+ state: CheckpointState | None
47
+ stdout: str = ""
48
+ stderr: str = ""
49
+
50
+ @property
51
+ def success(self) -> bool:
52
+ return self.state is not None and self.state.status == CheckpointStatus.DONE
53
+
54
+ @property
55
+ def blocked(self) -> bool:
56
+ return (
57
+ self.state is not None
58
+ and self.state.status == CheckpointStatus.BLOCKED_NEEDS_INPUT
59
+ )
60
+
61
+ @property
62
+ def failed(self) -> bool:
63
+ return self.state is None or self.state.status == CheckpointStatus.FAILED
64
+
65
+
66
+ @dataclass
67
+ class ClaudeDispatcher:
68
+ """Spawns and manages Claude subprocess sessions."""
69
+
70
+ claude_binary: str = field(default_factory=_find_claude)
71
+ default_timeout: int = 1800
72
+ extra_env: dict[str, str] = field(default_factory=dict)
73
+
74
+ def __post_init__(self) -> None:
75
+ # Only auto-resolve the bare default. Absolute paths, relative paths
76
+ # (resolved vs. working_dir by the child), and custom bare names pass
77
+ # through so explicit config is never silently overridden.
78
+ if self.claude_binary == "claude":
79
+ resolved = shutil.which("claude")
80
+ self.claude_binary = resolved or _find_claude()
81
+
82
+ async def spawn_session(
83
+ self,
84
+ session_id: str,
85
+ prompt: str,
86
+ working_dir: Path,
87
+ state_file: Path,
88
+ timeout: int | None = None,
89
+ resume_session_id: str | None = None,
90
+ ) -> SessionResult:
91
+ """Spawn a Claude session and wait for completion."""
92
+ timeout = timeout or self.default_timeout
93
+
94
+ env = os.environ.copy()
95
+ env.update(self.extra_env)
96
+ env["CTRLRELAY_SESSION_ID"] = session_id
97
+ env["CTRLRELAY_STATE_FILE"] = str(state_file)
98
+
99
+ cmd = [
100
+ self.claude_binary,
101
+ "-p", prompt,
102
+ "--output-format", "json",
103
+ "--dangerously-skip-permissions",
104
+ ]
105
+ if resume_session_id:
106
+ cmd.extend(["--resume", resume_session_id])
107
+
108
+ proc = await asyncio.create_subprocess_exec(
109
+ *cmd,
110
+ cwd=working_dir,
111
+ env=env,
112
+ stdout=asyncio.subprocess.PIPE,
113
+ stderr=asyncio.subprocess.PIPE,
114
+ )
115
+
116
+ try:
117
+ stdout, stderr = await asyncio.wait_for(
118
+ proc.communicate(), timeout=timeout
119
+ )
120
+ except asyncio.TimeoutError:
121
+ proc.kill()
122
+ await proc.wait()
123
+ return SessionResult(
124
+ session_id=session_id,
125
+ exit_code=-1,
126
+ state=None,
127
+ stderr="Session timed out",
128
+ )
129
+ except asyncio.CancelledError:
130
+ # Scheduler/shutdown cancel: kill the child BEFORE re-raising
131
+ # so `claude` isn't left running against the worktree after
132
+ # the daemon exits. Shield the wait so further cancels don't
133
+ # orphan the process between `kill()` and the reaping.
134
+ if proc.returncode is None:
135
+ proc.kill()
136
+ try:
137
+ await asyncio.shield(proc.wait())
138
+ except asyncio.CancelledError:
139
+ pass
140
+ raise
141
+
142
+ state = None
143
+ if state_file.exists():
144
+ try:
145
+ state = read_checkpoint(state_file, delete_after=True)
146
+ except Exception:
147
+ pass
148
+
149
+ return SessionResult(
150
+ session_id=session_id,
151
+ exit_code=proc.returncode or 0,
152
+ state=state,
153
+ stdout=stdout.decode(),
154
+ stderr=stderr.decode(),
155
+ )
156
+
157
+
158
+ class AgentAdapter(Protocol):
159
+ """Protocol every coding-agent adapter must satisfy.
160
+
161
+ An adapter is a thin wrapper over an agent CLI that handles:
162
+ spawning the subprocess, passing the prompt and state-file path,
163
+ enforcing the timeout, and translating the checkpoint JSON the
164
+ agent writes into a :class:`SessionResult`.
165
+
166
+ Pipelines (dev, secops) only ever interact with this protocol —
167
+ they never import concrete adapters — so adding a new backend
168
+ means implementing this and registering it in
169
+ :func:`make_agent_dispatcher`.
170
+ """
171
+
172
+ async def spawn_session(
173
+ self,
174
+ session_id: str,
175
+ prompt: str,
176
+ working_dir: Path,
177
+ state_file: Path,
178
+ timeout: int | None = None,
179
+ resume_session_id: str | None = None,
180
+ ) -> SessionResult:
181
+ ...
182
+
183
+
184
+ def make_agent_dispatcher(agent_config: "AgentConfig") -> AgentAdapter:
185
+ """Build an adapter for the configured ``agent.type``.
186
+
187
+ Raises :class:`NotImplementedError` with a clear error message if
188
+ the configured type has no adapter — makes a typo surface loudly
189
+ at daemon startup instead of silently falling back to Claude.
190
+ """
191
+ t = agent_config.type
192
+ if t == "claude":
193
+ return ClaudeDispatcher(
194
+ claude_binary=agent_config.binary,
195
+ default_timeout=agent_config.default_timeout_seconds,
196
+ )
197
+ raise NotImplementedError(
198
+ f"agent.type={t!r} has no adapter yet. Implement one that "
199
+ "satisfies the AgentAdapter protocol in "
200
+ "src/ctrlrelay/core/dispatcher.py and register it here, then "
201
+ "open a PR. Supported today: 'claude'."
202
+ )