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.
- ctrlrelay/__init__.py +33 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +89 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +740 -0
- ctrlrelay/bridge/telegram_handler.py +136 -0
- ctrlrelay/cli.py +3138 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +778 -0
- ctrlrelay/core/dispatcher.py +372 -0
- ctrlrelay/core/github.py +432 -0
- ctrlrelay/core/network.py +277 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +920 -0
- ctrlrelay/core/pr_verifier.py +230 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/question_expiry.py +193 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +642 -0
- ctrlrelay/core/worktree.py +893 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/gh_protocol.py +47 -0
- ctrlrelay/install.py +203 -0
- ctrlrelay/personalization/__init__.py +26 -0
- ctrlrelay/personalization/manager.py +1183 -0
- ctrlrelay/personalization/paths.py +143 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +1367 -0
- ctrlrelay/pipelines/post_merge.py +411 -0
- ctrlrelay/pipelines/secops.py +1018 -0
- ctrlrelay/pipelines/task.py +713 -0
- ctrlrelay/setup.py +617 -0
- ctrlrelay/templates/__init__.py +0 -0
- ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
- ctrlrelay/templates/launchd/__init__.py +0 -0
- ctrlrelay/templates/launchd/bridge.plist.template +37 -0
- ctrlrelay/templates/launchd/poller.plist.template +44 -0
- ctrlrelay/templates/systemd/__init__.py +0 -0
- ctrlrelay/templates/systemd/bridge.service.template +20 -0
- ctrlrelay/templates/systemd/poller.service.template +25 -0
- ctrlrelay/transports/__init__.py +34 -0
- ctrlrelay/transports/base.py +74 -0
- ctrlrelay/transports/file_mock.py +103 -0
- ctrlrelay/transports/socket_client.py +298 -0
- ctrlrelay-0.10.0.dist-info/METADATA +254 -0
- ctrlrelay-0.10.0.dist-info/RECORD +53 -0
- ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
- ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
- ctrlrelay-0.10.0.dist-info/licenses/LICENSE +201 -0
ctrlrelay/core/config.py
ADDED
|
@@ -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
|