ctrlrelay 0.10.0__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 (53) hide show
  1. ctrlrelay/__init__.py +33 -0
  2. ctrlrelay/bridge/__init__.py +21 -0
  3. ctrlrelay/bridge/__main__.py +89 -0
  4. ctrlrelay/bridge/protocol.py +75 -0
  5. ctrlrelay/bridge/server.py +740 -0
  6. ctrlrelay/bridge/telegram_handler.py +136 -0
  7. ctrlrelay/cli.py +3138 -0
  8. ctrlrelay/core/__init__.py +54 -0
  9. ctrlrelay/core/audit.py +257 -0
  10. ctrlrelay/core/checkpoint.py +155 -0
  11. ctrlrelay/core/config.py +778 -0
  12. ctrlrelay/core/dispatcher.py +372 -0
  13. ctrlrelay/core/github.py +432 -0
  14. ctrlrelay/core/network.py +277 -0
  15. ctrlrelay/core/obs.py +118 -0
  16. ctrlrelay/core/poller.py +920 -0
  17. ctrlrelay/core/pr_verifier.py +230 -0
  18. ctrlrelay/core/pr_watcher.py +121 -0
  19. ctrlrelay/core/question_expiry.py +193 -0
  20. ctrlrelay/core/scheduler.py +337 -0
  21. ctrlrelay/core/state.py +642 -0
  22. ctrlrelay/core/worktree.py +893 -0
  23. ctrlrelay/dashboard/__init__.py +5 -0
  24. ctrlrelay/dashboard/client.py +159 -0
  25. ctrlrelay/gh_protocol.py +47 -0
  26. ctrlrelay/install.py +203 -0
  27. ctrlrelay/personalization/__init__.py +26 -0
  28. ctrlrelay/personalization/manager.py +1183 -0
  29. ctrlrelay/personalization/paths.py +143 -0
  30. ctrlrelay/pipelines/__init__.py +15 -0
  31. ctrlrelay/pipelines/base.py +50 -0
  32. ctrlrelay/pipelines/dev.py +1367 -0
  33. ctrlrelay/pipelines/post_merge.py +411 -0
  34. ctrlrelay/pipelines/secops.py +1018 -0
  35. ctrlrelay/pipelines/task.py +713 -0
  36. ctrlrelay/setup.py +617 -0
  37. ctrlrelay/templates/__init__.py +0 -0
  38. ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
  39. ctrlrelay/templates/launchd/__init__.py +0 -0
  40. ctrlrelay/templates/launchd/bridge.plist.template +37 -0
  41. ctrlrelay/templates/launchd/poller.plist.template +44 -0
  42. ctrlrelay/templates/systemd/__init__.py +0 -0
  43. ctrlrelay/templates/systemd/bridge.service.template +20 -0
  44. ctrlrelay/templates/systemd/poller.service.template +25 -0
  45. ctrlrelay/transports/__init__.py +34 -0
  46. ctrlrelay/transports/base.py +74 -0
  47. ctrlrelay/transports/file_mock.py +103 -0
  48. ctrlrelay/transports/socket_client.py +298 -0
  49. ctrlrelay-0.10.0.dist-info/METADATA +254 -0
  50. ctrlrelay-0.10.0.dist-info/RECORD +53 -0
  51. ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
  52. ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
  53. ctrlrelay-0.10.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,778 @@
1
+ """Configuration loading and validation for ctrlrelay."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import socket
8
+ from enum import Enum
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import yaml
13
+ from pydantic import BaseModel, Field, field_validator, model_validator
14
+
15
+ CONFIG_FILENAME = "orchestrator.yaml"
16
+ CONFIG_SUBDIR = "config"
17
+ CONFIG_ENV_VAR = "CTRLRELAY_CONFIG"
18
+
19
+
20
+ class ConfigError(Exception):
21
+ """Raised when configuration loading or validation fails."""
22
+
23
+
24
+ class TransportType(str, Enum):
25
+ TELEGRAM = "telegram"
26
+ FILE_MOCK = "file_mock"
27
+
28
+
29
+ class AutomationPolicy(str, Enum):
30
+ AUTO = "auto"
31
+ ASK = "ask"
32
+ NEVER = "never"
33
+
34
+
35
+ class PathsConfig(BaseModel):
36
+ """File system paths configuration."""
37
+
38
+ state_db: Path
39
+ worktrees: Path
40
+ bare_repos: Path
41
+ contexts: Path
42
+ skills: Path
43
+ # Optional default root for repo clones. When set,
44
+ # ``RepoConfig.local_path`` is derived as
45
+ # ``${repo_root}/${owner.lower()}/${repo_name}`` instead of being
46
+ # hand-written for every repo. Per-repo ``local_path`` still wins
47
+ # as an explicit override. When unset, every repo entry must
48
+ # declare its own ``local_path`` (legacy behaviour).
49
+ repo_root: Path | None = None
50
+ # DEPRECATED (since 0.4.0): GitHub-owner-to-folder mapping. The
51
+ # path resolver now always uses ``owner.lower()`` as the folder
52
+ # component, so aliases are no longer consulted. Parsing is
53
+ # retained so 0.3.x configs don't error on load; a deprecation
54
+ # warning is emitted when a non-empty mapping is supplied. To
55
+ # silence: drop the ``owner_aliases`` block from your config and
56
+ # rename the on-disk folder to match ``owner.lower()`` (or set a
57
+ # per-repo ``local_path`` override for any that genuinely deviate).
58
+ owner_aliases: dict[str, str] = Field(default_factory=dict)
59
+
60
+ @field_validator("state_db", "worktrees", "bare_repos", "contexts", "skills",
61
+ "repo_root", mode="before")
62
+ @classmethod
63
+ def expand_path(cls, v: Any) -> Any:
64
+ if isinstance(v, str):
65
+ return Path(v).expanduser()
66
+ return v
67
+
68
+
69
+ class AgentConfig(BaseModel):
70
+ """Headless coding-agent configuration.
71
+
72
+ ``type`` selects which adapter the dispatcher uses to talk to the
73
+ agent CLI. Today only ``claude`` is implemented; the field exists
74
+ so future adapters (e.g. ``codex``, ``opencode``, ``hermes``) can
75
+ be plugged in without a config schema change. The other fields
76
+ (``binary``, ``default_timeout_seconds``, ``output_format``) are
77
+ common across most CLI-driven agents; adapters that need agent-
78
+ specific knobs can add a nested sub-model later.
79
+ """
80
+
81
+ type: str = "claude"
82
+ binary: str = "claude"
83
+ default_timeout_seconds: int = 1800
84
+ output_format: str = "json"
85
+
86
+
87
+ # Backwards-compat alias. Older code and docs reference ``ClaudeConfig``;
88
+ # keep the name importable but have it resolve to the renamed class so
89
+ # `isinstance(cfg, ClaudeConfig)` and `ClaudeConfig(...)` still work.
90
+ # Scheduled for removal once downstream repos migrate.
91
+ ClaudeConfig = AgentConfig
92
+
93
+
94
+ class TelegramConfig(BaseModel):
95
+ """Telegram transport configuration."""
96
+
97
+ bot_token_env: str = "CTRLRELAY_TELEGRAM_TOKEN"
98
+ chat_id: int = 0
99
+ socket_path: Path = Field(
100
+ default_factory=lambda: Path("~/.ctrlrelay/ctrlrelay.sock").expanduser()
101
+ )
102
+ # How long a pipeline waits on the operator's Telegram reply before it
103
+ # gives up and persists the session as BLOCKED.
104
+ #
105
+ # This is deliberately NOT sized to cover "operator is asleep". A secops
106
+ # sweep runs its repos sequentially on one transport, so every second
107
+ # spent waiting here holds that repo's lock and worktree and delays every
108
+ # later repo — and the poller awaits its issue handler inline, so a
109
+ # blocked dev question stalls new-issue dispatch across all repos for the
110
+ # same duration. Sizing this for an overnight gap would let a dozen
111
+ # unanswered repos turn a daily sweep into a multi-day one.
112
+ #
113
+ # A late reply does not need this window: the bridge records which
114
+ # session each posted question belongs to, so an answer arriving after
115
+ # the wait has lapsed attaches to the session's pending_resumes row and
116
+ # the every-minute sweeper drives the resume. This value only buys the
117
+ # in-session fast path for an operator who happens to be at their desk.
118
+ #
119
+ # Lower bound of 60s: shorter is a misconfiguration, not a preference.
120
+ ask_timeout_seconds: int = Field(default=900, ge=60)
121
+ # How long an UNANSWERED question stays routable before it is
122
+ # retired. `ask_timeout_seconds` only bounds the in-session wait;
123
+ # the pending_resumes row outlives it so a later reply can still
124
+ # drive the resume. Without a ceiling those rows never die: a
125
+ # question whose PR was merged by hand weeks ago stays a live
126
+ # target for an orphan Telegram reply, and each sweep re-posts it.
127
+ # 48h spans a weekend-adjacent gap without letting a stale decision
128
+ # act on a PR whose state has since moved on. Lower bound of 3600s:
129
+ # anything shorter races the operator's own working day.
130
+ question_ttl_seconds: int = Field(default=172800, ge=3600)
131
+
132
+ @field_validator("socket_path", mode="before")
133
+ @classmethod
134
+ def expand_socket_path(cls, v: Any) -> Any:
135
+ if isinstance(v, str):
136
+ return Path(v).expanduser()
137
+ return v
138
+
139
+
140
+ class FileMockConfig(BaseModel):
141
+ """File mock transport configuration for testing."""
142
+
143
+ inbox: Path
144
+ outbox: Path
145
+
146
+ @field_validator("*", mode="before")
147
+ @classmethod
148
+ def expand_path(cls, v: Any) -> Any:
149
+ if isinstance(v, str):
150
+ return Path(v).expanduser()
151
+ return v
152
+
153
+
154
+ class TransportConfig(BaseModel):
155
+ """Transport configuration (Telegram or file mock)."""
156
+
157
+ type: TransportType = TransportType.FILE_MOCK
158
+ telegram: TelegramConfig | None = None
159
+ file_mock: FileMockConfig | None = None
160
+
161
+ @model_validator(mode="after")
162
+ def validate_transport_config(self) -> "TransportConfig":
163
+ if self.type == TransportType.TELEGRAM and self.telegram is None:
164
+ raise ValueError("telegram config required when type is 'telegram'")
165
+ if self.type == TransportType.FILE_MOCK and self.file_mock is None:
166
+ raise ValueError("file_mock config required when type is 'file_mock'")
167
+ return self
168
+
169
+
170
+ class DashboardConfig(BaseModel):
171
+ """Dashboard client configuration."""
172
+
173
+ enabled: bool = True
174
+ url: str = ""
175
+ auth_token_env: str = "CTRLRELAY_DASHBOARD_TOKEN"
176
+ sync_config_on_heartbeat: bool = False
177
+
178
+
179
+ class DeployConfig(BaseModel):
180
+ """Deployment configuration for a repo."""
181
+
182
+ provider: str = "digitalocean"
183
+ app_id: str = ""
184
+
185
+
186
+ class CodeReviewConfig(BaseModel):
187
+ """Code review configuration for a repo."""
188
+
189
+ method: str = "mcp_then_cli"
190
+ mcp_tool: str = "mcp__codex-reviewer__codex_review"
191
+ cli_command: str = "codex review"
192
+
193
+
194
+ class AutomationConfig(BaseModel):
195
+ """Automation policies for a repo."""
196
+
197
+ dependabot_patch: AutomationPolicy = AutomationPolicy.AUTO
198
+ dependabot_minor: AutomationPolicy = AutomationPolicy.ASK
199
+ dependabot_major: AutomationPolicy = AutomationPolicy.NEVER
200
+ codeql_dismiss: AutomationPolicy = AutomationPolicy.ASK
201
+ secret_alerts: AutomationPolicy = AutomationPolicy.NEVER
202
+ deploy_after_merge: AutomationPolicy = AutomationPolicy.AUTO
203
+ accept_foreign_assignments: bool = False
204
+ exclude_labels: list[str] = Field(
205
+ default_factory=lambda: ["manual", "operator", "instruction"]
206
+ )
207
+ # Labels that opt an issue INTO the dev pipeline regardless of
208
+ # assignment. This is the team-coordination knob: a teammate can
209
+ # label an issue (e.g. ``ctrlrelay:auto``) and the bot picks it up
210
+ # on the next poll without needing the operator to self-assign.
211
+ # Default ``[]`` preserves today's behavior (assignment-only).
212
+ # Matching is case-insensitive. An issue that is both labeled AND
213
+ # assigned is processed exactly once — not duplicated. See #80.
214
+ include_labels: list[str] = Field(default_factory=list)
215
+ # Labels that gate the plain-assignment trigger. Default ``[]``
216
+ # preserves today's behavior (assignment alone is sufficient).
217
+ # When non-empty, bare assignment is no longer enough on its own:
218
+ # an issue must be assigned to the operator AND carry at least one
219
+ # of these labels to be picked up. This only tightens the
220
+ # assignment path — ``include_labels`` keeps triggering on label
221
+ # match alone regardless of ``require_labels``, and
222
+ # ``exclude_labels`` is still checked first. Matching is
223
+ # case-insensitive. See #139.
224
+ require_labels: list[str] = Field(default_factory=list)
225
+ # Labels that route an issue to the task pipeline (run a command /
226
+ # investigate / report findings via issue comment) instead of the
227
+ # dev pipeline (branch + PR). Matching is case-insensitive; the
228
+ # first matching label wins. If both exclude_labels and
229
+ # task_labels match the same issue, exclude_labels takes
230
+ # precedence so "not for the agent at all" overrides "agent does
231
+ # this but differently".
232
+ task_labels: list[str] = Field(default_factory=lambda: ["task"])
233
+ # Hard cap, in seconds, on a single `ctrlrelay ci wait` call the dev
234
+ # pipeline prompt tells Claude to run before signaling DONE. A repo
235
+ # with slow CI can eat most of a session's wall-clock time sitting
236
+ # in this one blocking call generating zero tokens; a fast-CI repo
237
+ # doesn't need anywhere near the 600s default. Lower this per-repo
238
+ # to match how long that repo's CI actually takes.
239
+ ci_wait_timeout_seconds: int = 600
240
+
241
+
242
+ _REPO_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
243
+
244
+
245
+ class RepoConfig(BaseModel):
246
+ """Configuration for a single repository."""
247
+
248
+ name: str
249
+ # Optional. When unset, the parent ``Config`` derives a default from
250
+ # ``paths.repo_root`` + ``paths.owner_aliases`` + the repo name. An
251
+ # explicit ``local_path`` here always wins as an override for repos
252
+ # that don't follow the convention (nested clones, renamed dirs).
253
+ local_path: Path | None = None
254
+ automation: AutomationConfig = Field(default_factory=AutomationConfig)
255
+ deploy: DeployConfig | None = None
256
+ code_review: CodeReviewConfig = Field(default_factory=CodeReviewConfig)
257
+ dev_branch_template: str = "fix/issue-{n}"
258
+
259
+ @field_validator("name")
260
+ @classmethod
261
+ def validate_name(cls, v: str) -> str:
262
+ # GitHub-shaped owner/repo. Rejects "..", empty segments, extra slashes,
263
+ # and shell metacharacters — `name` ends up in derived filesystem paths
264
+ # and remote URLs, so a malicious manifest must not escape DEST.
265
+ if not _REPO_NAME_RE.match(v):
266
+ raise ValueError(
267
+ f"repo name {v!r} must match 'owner/repo' "
268
+ f"(letters, digits, '.', '_', '-')"
269
+ )
270
+ return v
271
+
272
+ @field_validator("local_path", mode="before")
273
+ @classmethod
274
+ def expand_local_path(cls, v: Any) -> Any:
275
+ if isinstance(v, str):
276
+ return Path(v).expanduser()
277
+ return v
278
+
279
+
280
+ _GIT_BRANCH_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
281
+
282
+
283
+ def _is_safe_git_ref_component(value: str) -> bool:
284
+ """Return True iff ``value`` is safe as part of a git branch/ref name.
285
+
286
+ Allow-list: ``letters, digits, '.', '_', '-'`` (Codex pass 7).
287
+ Plus the structural rules ``git check-ref-format --branch``
288
+ enforces — no leading ``-``/``.``, no ``..``, no trailing ``.``,
289
+ no ``.lock`` suffix (Codex pass 8 caught the trailing-``.``
290
+ case slipping through). Doing this as an explicit allow-list
291
+ keeps the config error synchronous with load and avoids late git
292
+ failures during ``init``/``push``.
293
+ """
294
+ if not value:
295
+ return False
296
+ if value.startswith("-") or value.startswith("."):
297
+ return False
298
+ if value.endswith(".") or value.endswith(".lock"):
299
+ return False
300
+ if ".." in value:
301
+ return False
302
+ return bool(_GIT_BRANCH_NAME_RE.match(value))
303
+
304
+
305
+ _ALLOWED_PATH_PLACEHOLDERS = frozenset({
306
+ "HOME",
307
+ "PROJECT",
308
+ "PROJECT_ENCODED",
309
+ "PROJECT_LOCAL",
310
+ "PROJECT_PARENT",
311
+ })
312
+
313
+ _PROJECT_PLACEHOLDERS = frozenset({
314
+ "PROJECT",
315
+ "PROJECT_ENCODED",
316
+ "PROJECT_LOCAL",
317
+ "PROJECT_PARENT",
318
+ })
319
+
320
+ _PLACEHOLDER_RE = re.compile(r"\$\{([A-Z_]+)\}")
321
+
322
+
323
+ def _extract_placeholders(text: str) -> set[str]:
324
+ return set(_PLACEHOLDER_RE.findall(text))
325
+
326
+
327
+ class PersonalizationPath(BaseModel):
328
+ """One source-to-target sync entry inside the personalization repo.
329
+
330
+ ``source`` is a path inside the personalization repo working tree
331
+ (e.g. ``global/CLAUDE.md`` or ``claude-memory/${PROJECT}/``).
332
+ ``target`` is the on-disk symlink destination outside the repo
333
+ (e.g. ``~/.claude/CLAUDE.md`` or ``${PROJECT_PARENT}/specs/${PROJECT}/``).
334
+
335
+ A trailing ``/`` on either side denotes a directory; a missing slash
336
+ denotes a single file. The two sides must agree — a directory source
337
+ cannot map to a file target. Validation enforces this at load time.
338
+
339
+ ``project_scoped: true`` makes the entry iterate over ``config.repos``
340
+ at wire-time. A non-project-scoped entry that references project
341
+ placeholders is rejected: it would resolve to garbage.
342
+ """
343
+
344
+ source: str
345
+ target: str
346
+ project_scoped: bool = False
347
+
348
+ @model_validator(mode="after")
349
+ def validate_placeholders_and_shape(self) -> "PersonalizationPath":
350
+ for side, value in (("source", self.source), ("target", self.target)):
351
+ if not value:
352
+ raise ValueError(f"personalization path {side} must not be empty")
353
+ placeholders = _extract_placeholders(value)
354
+ unknown = placeholders - _ALLOWED_PATH_PLACEHOLDERS
355
+ if unknown:
356
+ raise ValueError(
357
+ f"personalization path {side} {value!r} references unknown "
358
+ f"placeholders: {sorted(unknown)} "
359
+ f"(allowed: {sorted(_ALLOWED_PATH_PLACEHOLDERS)})"
360
+ )
361
+ if not self.project_scoped and (placeholders & _PROJECT_PLACEHOLDERS):
362
+ raise ValueError(
363
+ f"personalization path {side} {value!r} uses project "
364
+ "placeholders but project_scoped is false; either set "
365
+ "project_scoped: true or remove the placeholders"
366
+ )
367
+
368
+ # ``source`` is documented as a path inside the personalization
369
+ # repo (joined to ``checkout_path`` at wire time). Reject ``..``
370
+ # segments and absolute paths so an entry can't escape the
371
+ # checkout, e.g. ``source: ../secret`` (Codex pass 8). Also
372
+ # reject ``${HOME}`` in source: it's a valid placeholder for
373
+ # targets, but in source it's nonsensical (would resolve to a
374
+ # subdir literally named after $HOME, which the wire code
375
+ # would silently mark as source-missing — Codex pass 11).
376
+ # Reject ``..`` in target too for auditability.
377
+ if self.source.startswith("/"):
378
+ raise ValueError(
379
+ f"personalization path source {self.source!r} must be "
380
+ "relative to the personalization checkout, not absolute"
381
+ )
382
+ if "${HOME}" in self.source:
383
+ raise ValueError(
384
+ f"personalization path source {self.source!r} uses "
385
+ "${HOME}; sources are paths inside the personalization "
386
+ "checkout and cannot reference the user's home directory"
387
+ )
388
+ # Reject git pathspec magic prefixes/characters (Codex pass
389
+ # 17). ``:(top)`` / ``:!`` / leading ``:`` would otherwise
390
+ # let ``git add -- <rel>`` interpret the config value as
391
+ # a pathspec rather than a literal filename and reach
392
+ # outside the allowlist. ``GIT_LITERAL_PATHSPECS=1`` in the
393
+ # subprocess env is the defense-in-depth, but failing config
394
+ # load surfaces operator typos earlier.
395
+ if ":" in self.source:
396
+ raise ValueError(
397
+ f"personalization path source {self.source!r} contains "
398
+ "':'; ':' is reserved by git for pathspec magic and "
399
+ "cannot appear in a configured source path"
400
+ )
401
+ for side, value in (("source", self.source), ("target", self.target)):
402
+ for segment in value.split("/"):
403
+ if segment == "..":
404
+ raise ValueError(
405
+ f"personalization path {side} {value!r} contains "
406
+ "'..' segment; paths must not escape their root"
407
+ )
408
+
409
+ # File-vs-dir agreement: if one side ends with '/', the other must too.
410
+ src_is_dir = self.source.endswith("/")
411
+ tgt_is_dir = self.target.endswith("/")
412
+ if src_is_dir != tgt_is_dir:
413
+ raise ValueError(
414
+ f"personalization path source {self.source!r} and target "
415
+ f"{self.target!r} disagree on directory vs file (trailing "
416
+ "slash must match on both)"
417
+ )
418
+ return self
419
+
420
+
421
+ class Personalization(BaseModel):
422
+ """Personalization sync configuration.
423
+
424
+ A separate (typically private) GitHub repo holds the operator's
425
+ cross-machine context: global Claude config, per-project memory,
426
+ spec/superpower outputs, workspace planning docs. ctrlrelay clones
427
+ it once per machine and wires symlinks per the ``paths`` list. Per-
428
+ machine work happens on a ``personalization/<node_id>`` branch that
429
+ auto-rebases onto ``main_branch`` on push, so concurrent edits across
430
+ machines never force-push each other's deltas.
431
+ """
432
+
433
+ repo: str
434
+ checkout_path: Path = Field(
435
+ default_factory=lambda: Path("~/.ctrlrelay/personalization").expanduser()
436
+ )
437
+ # Branch name on the remote that holds the canonical state. ``push``
438
+ # rebases the per-node working branch onto this; ``pull`` resets the
439
+ # local copy of this branch to match origin so a fresh wire reflects
440
+ # what the rest of the fleet has agreed on.
441
+ main_branch: str = "main"
442
+ # Per-machine working branch is ``personalization/<node_id>``. When
443
+ # unset, the parent ``Config`` substitutes its top-level ``node_id``
444
+ # (which itself defaults to ``socket.gethostname()``).
445
+ node_id: str | None = None
446
+ paths: list[PersonalizationPath] = Field(default_factory=list)
447
+
448
+ @field_validator("repo")
449
+ @classmethod
450
+ def validate_repo(cls, v: str) -> str:
451
+ if not _REPO_NAME_RE.match(v):
452
+ raise ValueError(
453
+ f"personalization repo {v!r} must match 'owner/repo' "
454
+ f"(letters, digits, '.', '_', '-')"
455
+ )
456
+ return v
457
+
458
+ @field_validator("checkout_path", mode="before")
459
+ @classmethod
460
+ def expand_checkout_path(cls, v: Any) -> Any:
461
+ if isinstance(v, str):
462
+ return Path(v).expanduser()
463
+ return v
464
+
465
+ @field_validator("main_branch")
466
+ @classmethod
467
+ def validate_main_branch(cls, v: str) -> str:
468
+ if not _is_safe_git_ref_component(v):
469
+ raise ValueError(
470
+ f"invalid main_branch {v!r} "
471
+ "(allowed: letters, digits, '.', '_', '-'; no leading "
472
+ "'-' or '.', no '..')"
473
+ )
474
+ return v
475
+
476
+ @field_validator("node_id")
477
+ @classmethod
478
+ def validate_node_id(cls, v: str | None) -> str | None:
479
+ if v is None:
480
+ return None
481
+ if not _is_safe_git_ref_component(v):
482
+ raise ValueError(
483
+ f"invalid personalization node_id {v!r} "
484
+ "(used as a git branch component; allowed: letters, "
485
+ "digits, '.', '_', '-'; no leading '-' or '.', no '..')"
486
+ )
487
+ return v
488
+
489
+
490
+ class SchedulesConfig(BaseModel):
491
+ """Cron schedules for background jobs run by the poller daemon.
492
+
493
+ Values are standard 5-field cron expressions (minute hour dom month dow),
494
+ evaluated in the top-level ``timezone``. Each schedule is validated at
495
+ config load time so an unparseable expression fails fast rather than
496
+ silently disabling the job.
497
+
498
+ ``personalization_cron`` is optional: when ``None`` (the default), no
499
+ auto-pull job is registered. Set it to e.g. ``"*/15 * * * *"`` to
500
+ have the daemon pull the personalization repo every 15 minutes so
501
+ machines converge without manual ``ctrlrelay personalization pull``.
502
+ Skip-on-dirty: auto-pull never rebases under uncommitted operator
503
+ edits.
504
+ """
505
+
506
+ secops_cron: str = "0 6 * * *"
507
+ personalization_cron: str | None = None
508
+
509
+ @field_validator("secops_cron")
510
+ @classmethod
511
+ def validate_secops_cron(cls, v: str) -> str:
512
+ return _validate_cron_expression(v, "secops_cron")
513
+
514
+ @field_validator("personalization_cron")
515
+ @classmethod
516
+ def validate_personalization_cron(cls, v: str | None) -> str | None:
517
+ if v is None:
518
+ return None
519
+ return _validate_cron_expression(v, "personalization_cron")
520
+
521
+
522
+ def _validate_cron_expression(v: str, field: str) -> str:
523
+ from ctrlrelay.core.scheduler import _build_vixie_trigger
524
+
525
+ try:
526
+ # Build through the same helper the scheduler uses so
527
+ # (a) DOW normalization and (b) Vixie DOM/DOW-OR splitting
528
+ # are both exercised at load time. Bad expressions surface
529
+ # synchronously instead of at daemon start.
530
+ _build_vixie_trigger(v, timezone=None)
531
+ except Exception as e:
532
+ raise ValueError(f"invalid cron expression for {field} {v!r}: {e}") from e
533
+ return v
534
+
535
+
536
+ class Config(BaseModel):
537
+ """Root configuration model for ctrlrelay orchestrator."""
538
+
539
+ version: str = "1"
540
+ node_id: str
541
+ timezone: str = "UTC"
542
+ paths: PathsConfig
543
+ agent: AgentConfig = Field(default_factory=AgentConfig)
544
+ transport: TransportConfig
545
+ dashboard: DashboardConfig = Field(default_factory=DashboardConfig)
546
+ schedules: SchedulesConfig = Field(default_factory=SchedulesConfig)
547
+ personalization: Personalization | None = None
548
+ repos: list[RepoConfig] = Field(default_factory=list)
549
+
550
+ @model_validator(mode="before")
551
+ @classmethod
552
+ def default_node_id_to_hostname(cls, data: Any) -> Any:
553
+ # When ``node_id`` is missing, null, or blank, default to the
554
+ # machine's hostname so a stock orchestrator.yaml is portable
555
+ # across machines without an explicit per-node edit. Done in a
556
+ # before-validator (rather than a Field default_factory) so
557
+ # explicit empty strings also fall back, not just omitted keys.
558
+ if isinstance(data, dict):
559
+ v = data.get("node_id")
560
+ if v is None or (isinstance(v, str) and not v.strip()):
561
+ data["node_id"] = socket.gethostname()
562
+ return data
563
+
564
+ @model_validator(mode="before")
565
+ @classmethod
566
+ def migrate_claude_to_agent(cls, data: Any) -> Any:
567
+ """Accept the legacy ``claude:`` top-level key as an alias for
568
+ ``agent:``. Emits a ``DeprecationWarning`` so operators see the
569
+ migration hint in logs. Removed in a future release.
570
+
571
+ If both keys are present, ``agent`` wins and ``claude`` is
572
+ ignored (fail-loud would break supervised daemons; we prefer
573
+ silent win-on-new-name during the migration window)."""
574
+ if isinstance(data, dict) and "claude" in data and "agent" not in data:
575
+ import warnings
576
+ warnings.warn(
577
+ "config key 'claude:' is deprecated; rename to 'agent:'. "
578
+ "See https://github.com/AInvirion/ctrlrelay/blob/main/"
579
+ "CHANGELOG.md for the migration.",
580
+ DeprecationWarning,
581
+ stacklevel=2,
582
+ )
583
+ data["agent"] = data.pop("claude")
584
+ return data
585
+
586
+ @property
587
+ def claude(self) -> AgentConfig:
588
+ """Legacy attribute alias so callers still writing
589
+ ``config.claude.binary`` keep working. New code should prefer
590
+ ``config.agent.*``."""
591
+ return self.agent
592
+
593
+ def personalization_branch(self) -> str | None:
594
+ """Per-machine working branch name for personalization sync.
595
+
596
+ Falls back to the top-level ``node_id`` when ``personalization.node_id``
597
+ is unset. Returns ``None`` if personalization isn't configured.
598
+
599
+ ``validate_effective_personalization_node_id`` ensures the
600
+ effective node id is git-branch-safe at load time, so this
601
+ property never returns a name git would reject.
602
+ """
603
+ if self.personalization is None:
604
+ return None
605
+ node = self.personalization.node_id or self.node_id
606
+ return f"personalization/{node}"
607
+
608
+ @model_validator(mode="after")
609
+ def validate_effective_personalization_node_id(self) -> "Config":
610
+ """Reject configs where the EFFECTIVE personalization node_id
611
+ (after fallback to top-level ``node_id`` / hostname) is not
612
+ safe as part of a git branch name.
613
+
614
+ ``Personalization.node_id`` is already validated when set
615
+ explicitly, but the fallback path takes the top-level
616
+ ``node_id`` whose constraints are looser (it's used for
617
+ non-git purposes too — dashboard identity, etc.). Without
618
+ this check, a host whose ``socket.gethostname()`` returns
619
+ ``"Oscar's MacBook"`` would load fine and only fail later
620
+ when ``ctrlrelay personalization init`` shells out to
621
+ ``git checkout -b personalization/Oscar's MacBook``.
622
+ """
623
+ if self.personalization is None:
624
+ return self
625
+ if self.personalization.node_id is not None:
626
+ # Already validated by Personalization.validate_node_id.
627
+ return self
628
+ if not _is_safe_git_ref_component(self.node_id):
629
+ raise ValueError(
630
+ f"effective personalization node_id {self.node_id!r} (from "
631
+ "top-level node_id) is not safe as part of a git branch "
632
+ "name; either set personalization.node_id explicitly to a "
633
+ "branch-safe value (letters, digits, '.', '_', '-'; no "
634
+ "leading '-' or '.', no '..') or change the top-level "
635
+ "node_id"
636
+ )
637
+ return self
638
+
639
+ @model_validator(mode="after")
640
+ def derive_repo_local_paths(self) -> "Config":
641
+ # Fill in ``RepoConfig.local_path`` for any repo that omitted it,
642
+ # using ``paths.repo_root``. The folder component is always
643
+ # ``owner.lower()`` since 0.4.0 — see the ``owner_aliases``
644
+ # deprecation note on PathsConfig and the migration warning
645
+ # below. This keeps ``orchestrator.yaml`` short on machines
646
+ # whose local layout follows the convention, while still
647
+ # requiring an explicit value when no convention is configured.
648
+ if self.paths.owner_aliases:
649
+ import warnings
650
+
651
+ warnings.warn(
652
+ "paths.owner_aliases is deprecated since 0.4.0 and will "
653
+ "be removed in a future release. The path resolver now "
654
+ "always uses owner.lower() as the folder component. "
655
+ "Drop the owner_aliases block from orchestrator.yaml and "
656
+ "rename your on-disk folders to match owner.lower(), or "
657
+ "set a per-repo local_path override for any repos that "
658
+ "genuinely deviate from the convention.",
659
+ DeprecationWarning,
660
+ stacklevel=2,
661
+ )
662
+ for repo in self.repos:
663
+ if repo.local_path is not None:
664
+ continue
665
+ if self.paths.repo_root is None:
666
+ raise ValueError(
667
+ f"repo {repo.name!r} has no local_path and "
668
+ "paths.repo_root is unset; either declare a "
669
+ "local_path on the repo entry or set paths.repo_root "
670
+ "to enable convention-based defaults"
671
+ )
672
+ owner, _, repo_name = repo.name.partition("/")
673
+ repo.local_path = self.paths.repo_root / owner.lower() / repo_name
674
+ return self
675
+
676
+ @field_validator("timezone")
677
+ @classmethod
678
+ def validate_timezone(cls, v: str) -> str:
679
+ """Reject unparseable IANA zones at load time.
680
+
681
+ Since the scheduler feeds ``timezone`` directly into APScheduler's
682
+ CronTrigger, a typo like ``America/Santiagoo`` would only surface
683
+ as a ``ZoneInfoNotFoundError`` when the poller daemon starts —
684
+ much worse than a synchronous config error at load.
685
+ """
686
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
687
+
688
+ try:
689
+ ZoneInfo(v)
690
+ except ZoneInfoNotFoundError as e:
691
+ raise ValueError(f"unknown timezone {v!r}: {e}") from e
692
+ return v
693
+
694
+
695
+ def _xdg_config_home() -> Path:
696
+ xdg = os.environ.get("XDG_CONFIG_HOME")
697
+ if xdg:
698
+ return Path(xdg).expanduser()
699
+ return Path.home() / ".config"
700
+
701
+
702
+ def default_config_search_paths() -> list[Path]:
703
+ """Ordered list of locations searched when no --config is supplied.
704
+
705
+ Order: $CTRLRELAY_CONFIG, then ./config/orchestrator.yaml walking up from
706
+ cwd to filesystem root, then $XDG_CONFIG_HOME/ctrlrelay/orchestrator.yaml
707
+ (defaulting to ~/.config/ctrlrelay/orchestrator.yaml).
708
+ """
709
+ paths: list[Path] = []
710
+
711
+ env = os.environ.get(CONFIG_ENV_VAR)
712
+ if env:
713
+ paths.append(Path(env).expanduser())
714
+
715
+ cwd = Path.cwd().resolve()
716
+ for parent in [cwd, *cwd.parents]:
717
+ paths.append(parent / CONFIG_SUBDIR / CONFIG_FILENAME)
718
+
719
+ paths.append(_xdg_config_home() / "ctrlrelay" / CONFIG_FILENAME)
720
+
721
+ return paths
722
+
723
+
724
+ def resolve_config_path(explicit: Path | str | None = None) -> Path:
725
+ """Resolve the orchestrator.yaml path.
726
+
727
+ If ``explicit`` is given, it is returned as-is (the caller decides how to
728
+ handle a missing file). Otherwise, the first existing path among the
729
+ defaults is returned. If nothing exists, raises ConfigError listing the
730
+ locations searched so users know where to put the file.
731
+ """
732
+ if explicit is not None:
733
+ return Path(explicit).expanduser()
734
+
735
+ candidates = default_config_search_paths()
736
+ for candidate in candidates:
737
+ if candidate.is_file():
738
+ return candidate
739
+
740
+ searched = "\n ".join(str(p) for p in candidates)
741
+ raise ConfigError(
742
+ "No orchestrator.yaml found. Set $CTRLRELAY_CONFIG, pass --config, "
743
+ "or place the file at one of:\n " + searched
744
+ )
745
+
746
+
747
+ def load_config(path: Path | str) -> Config:
748
+ """Load and validate configuration from a YAML file.
749
+
750
+ Args:
751
+ path: Path to the orchestrator.yaml file.
752
+
753
+ Returns:
754
+ Validated Config object.
755
+
756
+ Raises:
757
+ ConfigError: If the file cannot be loaded or validation fails.
758
+ """
759
+ path = Path(path)
760
+
761
+ if not path.exists():
762
+ raise ConfigError(f"Config file not found: {path}")
763
+
764
+ try:
765
+ with open(path) as f:
766
+ data = yaml.safe_load(f)
767
+ except OSError as e:
768
+ raise ConfigError(f"Failed to read config file: {e}") from e
769
+ except yaml.YAMLError as e:
770
+ raise ConfigError(f"Failed to parse YAML: {e}") from e
771
+
772
+ if data is None:
773
+ raise ConfigError("Config file is empty")
774
+
775
+ try:
776
+ return Config.model_validate(data)
777
+ except Exception as e:
778
+ raise ConfigError(f"Config validation failed: {e}") from e