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,5 @@
1
+ """Dashboard client for ctrlrelay."""
2
+
3
+ from ctrlrelay.dashboard.client import DashboardClient, EventPayload, HeartbeatPayload
4
+
5
+ __all__ = ["DashboardClient", "EventPayload", "HeartbeatPayload"]
@@ -0,0 +1,159 @@
1
+ """Dashboard client for event push and heartbeat."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import httpx
12
+ from pydantic import BaseModel
13
+
14
+ from ctrlrelay import __version__
15
+
16
+
17
+ class HeartbeatPayload(BaseModel):
18
+ """Payload for heartbeat endpoint."""
19
+
20
+ node_id: str
21
+ timestamp: str = ""
22
+ version: str = __version__
23
+ uptime_seconds: int = 0
24
+ platform: str = ""
25
+ active_sessions: list[dict[str, Any]] = []
26
+ last_github_poll: str | None = None
27
+ last_github_poll_status: str = "ok"
28
+ repos_configured: int = 0
29
+ repos_active: int = 0
30
+
31
+ def model_post_init(self, __context: Any) -> None:
32
+ if not self.timestamp:
33
+ object.__setattr__(self, "timestamp", datetime.now(timezone.utc).isoformat())
34
+
35
+
36
+ class EventPayload(BaseModel):
37
+ """Payload for event endpoint."""
38
+
39
+ level: str # info, warning, error
40
+ pipeline: str # secops, dev
41
+ repo: str
42
+ message: str
43
+ session_id: str | None = None
44
+ timestamp: str = ""
45
+ details: dict[str, Any] = {}
46
+
47
+ def model_post_init(self, __context: Any) -> None:
48
+ if not self.timestamp:
49
+ object.__setattr__(
50
+ self, "timestamp", datetime.now(timezone.utc).isoformat()
51
+ )
52
+
53
+
54
+ @dataclass
55
+ class DashboardClient:
56
+ """Client for dashboard API with offline queue."""
57
+
58
+ url: str
59
+ auth_token: str
60
+ node_id: str
61
+ queue_dir: Path | None = None
62
+ timeout: int = 30
63
+ max_retries: int = 3
64
+ _queue: list[dict[str, Any]] = field(default_factory=list, repr=False)
65
+
66
+ def __post_init__(self) -> None:
67
+ if self.queue_dir:
68
+ self.queue_dir = Path(self.queue_dir)
69
+ self.queue_dir.mkdir(parents=True, exist_ok=True)
70
+ self._load_queue()
71
+
72
+ def _load_queue(self) -> None:
73
+ """Load queued events from disk."""
74
+ if not self.queue_dir:
75
+ return
76
+ queue_file = self.queue_dir / "event_queue.json"
77
+ if queue_file.exists():
78
+ try:
79
+ self._queue = json.loads(queue_file.read_text())
80
+ except json.JSONDecodeError:
81
+ self._queue = []
82
+
83
+ def _save_queue(self) -> None:
84
+ """Save queued events to disk."""
85
+ if not self.queue_dir:
86
+ return
87
+ queue_file = self.queue_dir / "event_queue.json"
88
+ queue_file.write_text(json.dumps(self._queue))
89
+
90
+ def _queue_event(self, event: EventPayload) -> None:
91
+ """Add event to offline queue."""
92
+ self._queue.append(event.model_dump())
93
+ self._save_queue()
94
+
95
+ async def push_event(self, event: EventPayload) -> bool:
96
+ """Push event to dashboard, queue on failure."""
97
+ payload = event.model_dump()
98
+ payload["node_id"] = self.node_id
99
+
100
+ try:
101
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
102
+ response = await client.post(
103
+ f"{self.url}/event",
104
+ json=payload,
105
+ headers={"Authorization": f"Bearer {self.auth_token}"},
106
+ )
107
+ response.raise_for_status()
108
+ return True
109
+ except (httpx.HTTPError, httpx.TimeoutException):
110
+ self._queue_event(event)
111
+ return False
112
+
113
+ async def heartbeat(self, payload: HeartbeatPayload) -> bool:
114
+ """Send heartbeat to dashboard."""
115
+ data = payload.model_dump()
116
+ data["node_id"] = self.node_id
117
+
118
+ try:
119
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
120
+ response = await client.post(
121
+ f"{self.url}/heartbeat",
122
+ json=data,
123
+ headers={"Authorization": f"Bearer {self.auth_token}"},
124
+ )
125
+ response.raise_for_status()
126
+ return True
127
+ except (httpx.HTTPError, httpx.TimeoutException):
128
+ return False
129
+
130
+ async def drain_queue(self) -> int:
131
+ """Attempt to send queued events. Returns count of successfully sent."""
132
+ if not self._queue:
133
+ return 0
134
+
135
+ sent = 0
136
+ remaining = []
137
+
138
+ for event_data in self._queue:
139
+ try:
140
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
141
+ event_data["node_id"] = self.node_id
142
+ response = await client.post(
143
+ f"{self.url}/event",
144
+ json=event_data,
145
+ headers={"Authorization": f"Bearer {self.auth_token}"},
146
+ )
147
+ response.raise_for_status()
148
+ sent += 1
149
+ except (httpx.HTTPError, httpx.TimeoutException):
150
+ remaining.append(event_data)
151
+
152
+ self._queue = remaining
153
+ self._save_queue()
154
+ return sent
155
+
156
+ @property
157
+ def queue_size(self) -> int:
158
+ """Number of events in offline queue."""
159
+ return len(self._queue)
@@ -0,0 +1,47 @@
1
+ """Detect the git transport protocol the operator has configured for
2
+ GitHub via the ``gh`` CLI.
3
+
4
+ ``ctrlrelay`` clones a handful of GitHub repos on the operator's
5
+ behalf (project repos, the personalization repo). Hardcoding HTTPS
6
+ for these clones breaks operators who've set ``gh config set
7
+ git_protocol ssh`` and have no HTTPS credential helper wired up —
8
+ ``git`` then fails with "could not read Username" instead of using
9
+ the SSH key ``gh`` already knows about.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import subprocess
15
+
16
+ _VALID_PROTOCOLS = ("ssh", "https")
17
+
18
+
19
+ def detect_git_protocol(*, default: str = "https") -> str:
20
+ """Return ``"ssh"`` or ``"https"``, per ``gh config get git_protocol``.
21
+
22
+ Falls back to ``default`` whenever ``gh`` isn't installed, isn't
23
+ configured, or returns something unexpected — this is a
24
+ convenience read, not a hard requirement, so any failure here
25
+ should degrade to the previous hardcoded-HTTPS behavior rather
26
+ than blocking a clone.
27
+ """
28
+ try:
29
+ proc = subprocess.run(
30
+ ["gh", "config", "get", "git_protocol"],
31
+ capture_output=True,
32
+ text=True,
33
+ timeout=5,
34
+ )
35
+ except (OSError, subprocess.SubprocessError):
36
+ return default
37
+ value = proc.stdout.strip().lower()
38
+ if proc.returncode == 0 and value in _VALID_PROTOCOLS:
39
+ return value
40
+ return default
41
+
42
+
43
+ def github_clone_url(repo: str, *, protocol: str) -> str:
44
+ """Build a clone URL for ``owner/repo`` under the given protocol."""
45
+ if protocol == "ssh":
46
+ return f"git@github.com:{repo}.git"
47
+ return f"https://github.com/{repo}.git"
ctrlrelay/install.py ADDED
@@ -0,0 +1,203 @@
1
+ """Render and install service unit files (launchd, systemd) from
2
+ in-package templates.
3
+
4
+ The bridge and poller are meant to run as supervised long-lived
5
+ services. Hand-writing platform unit files is error-prone (absolute
6
+ paths, label/filename consistency, exit-timeout values that prevent
7
+ mid-cleanup SIGKILL) and historically left operator boxes with
8
+ hard-coded ``/Users/$ME/...`` strings — fine on one machine, broken
9
+ on the next.
10
+
11
+ This module ships templates inside the package and exposes a small
12
+ substitution + write helper so ``ctrlrelay install launchd|systemd``
13
+ can produce ready-to-load files for whoever is running it. No
14
+ ``sudo``, no daemon-level paths — everything lives under the user's
15
+ home directory.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import getpass
21
+ import os
22
+ import re
23
+ import shutil
24
+ import string
25
+ from dataclasses import dataclass
26
+ from importlib import resources
27
+ from pathlib import Path
28
+
29
+ _TEMPLATE_VAR_RE = re.compile(r"\$\{([A-Z_][A-Z0-9_]*)\}")
30
+
31
+
32
+ def _read_template(platform: str, name: str) -> str:
33
+ pkg = f"ctrlrelay.templates.{platform}"
34
+ return resources.files(pkg).joinpath(name).read_text(encoding="utf-8")
35
+
36
+
37
+ def _substitute(text: str, values: dict[str, str]) -> str:
38
+ # We use Template.safe_substitute over plain str.format because the
39
+ # plist/systemd templates contain literal braces that .format would
40
+ # interpret. safe_substitute leaves unknown ${VAR} references intact
41
+ # rather than raising — useful for the bot-token placeholder when the
42
+ # operator hasn't exported the env var at install time.
43
+ return string.Template(text).safe_substitute(values)
44
+
45
+
46
+ def _missing_vars(rendered: str) -> list[str]:
47
+ return sorted(set(_TEMPLATE_VAR_RE.findall(rendered)))
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class RenderedUnit:
52
+ """A rendered unit file ready to write to disk."""
53
+
54
+ platform: str # "launchd" | "systemd"
55
+ service: str # "bridge" | "poller"
56
+ content: str
57
+ target_path: Path
58
+ # Variables that were left unsubstituted (e.g. CTRLRELAY_TELEGRAM_TOKEN
59
+ # when the env var wasn't set at render time). Caller decides whether
60
+ # to warn the operator or refuse to write.
61
+ unresolved: list[str]
62
+
63
+
64
+ def _build_substitution_values(
65
+ *,
66
+ workdir: Path,
67
+ label_prefix: str,
68
+ poller_interval: int,
69
+ ctrlrelay_bin: str | None = None,
70
+ ) -> dict[str, str]:
71
+ bin_path = ctrlrelay_bin or shutil.which("ctrlrelay") or "ctrlrelay"
72
+ home = Path.home()
73
+ values = {
74
+ "USER": getpass.getuser(),
75
+ "HOME": str(home),
76
+ "CTRLRELAY_BIN": bin_path,
77
+ "WORKDIR": str(workdir.expanduser()),
78
+ "LABEL_PREFIX": label_prefix,
79
+ "POLLER_INTERVAL": str(poller_interval),
80
+ }
81
+ # Pass through the bot token from the current environment if the
82
+ # operator has it exported at install time. Leaving it out means the
83
+ # placeholder survives in the rendered file and the operator must
84
+ # edit by hand — ``unresolved`` flags this.
85
+ token = os.environ.get("CTRLRELAY_TELEGRAM_TOKEN")
86
+ if token:
87
+ values["CTRLRELAY_TELEGRAM_TOKEN"] = token
88
+ return values
89
+
90
+
91
+ def render_launchd(
92
+ *,
93
+ workdir: Path,
94
+ label_prefix: str = "com.ctrlrelay",
95
+ poller_interval: int = 300,
96
+ ctrlrelay_bin: str | None = None,
97
+ target_dir: Path | None = None,
98
+ ) -> list[RenderedUnit]:
99
+ """Render the bridge and poller launchd plists.
100
+
101
+ ``target_dir`` defaults to ``~/Library/LaunchAgents`` — override
102
+ in tests. The filename uses ``{label_prefix}.ctrlrelay-{service}.plist``
103
+ so it lines up with the ``<Label>`` value inside the plist (launchd
104
+ won't load otherwise).
105
+ """
106
+ values = _build_substitution_values(
107
+ workdir=workdir,
108
+ label_prefix=label_prefix,
109
+ poller_interval=poller_interval,
110
+ ctrlrelay_bin=ctrlrelay_bin,
111
+ )
112
+ target_dir = target_dir or Path.home() / "Library" / "LaunchAgents"
113
+ units: list[RenderedUnit] = []
114
+ for service in ("bridge", "poller"):
115
+ raw = _read_template("launchd", f"{service}.plist.template")
116
+ rendered = _substitute(raw, values)
117
+ target = target_dir / f"{label_prefix}.ctrlrelay-{service}.plist"
118
+ units.append(
119
+ RenderedUnit(
120
+ platform="launchd",
121
+ service=service,
122
+ content=rendered,
123
+ target_path=target,
124
+ unresolved=_missing_vars(rendered),
125
+ )
126
+ )
127
+ return units
128
+
129
+
130
+ def render_systemd(
131
+ *,
132
+ workdir: Path,
133
+ poller_interval: int = 300,
134
+ ctrlrelay_bin: str | None = None,
135
+ target_dir: Path | None = None,
136
+ ) -> list[RenderedUnit]:
137
+ """Render the bridge and poller systemd user unit files.
138
+
139
+ ``target_dir`` defaults to ``~/.config/systemd/user`` — the canonical
140
+ location for user-scoped units (no root needed). systemd doesn't
141
+ require label_prefix-style namespacing the way launchd does, so the
142
+ filenames are fixed: ``ctrlrelay-{bridge,poller}.service``.
143
+ """
144
+ values = _build_substitution_values(
145
+ workdir=workdir,
146
+ # systemd templates don't reference LABEL_PREFIX, but the helper
147
+ # still needs the key for uniformity — pass an unused literal.
148
+ label_prefix="ctrlrelay",
149
+ poller_interval=poller_interval,
150
+ ctrlrelay_bin=ctrlrelay_bin,
151
+ )
152
+ target_dir = target_dir or Path.home() / ".config" / "systemd" / "user"
153
+ units: list[RenderedUnit] = []
154
+ for service in ("bridge", "poller"):
155
+ raw = _read_template("systemd", f"{service}.service.template")
156
+ rendered = _substitute(raw, values)
157
+ target = target_dir / f"ctrlrelay-{service}.service"
158
+ units.append(
159
+ RenderedUnit(
160
+ platform="systemd",
161
+ service=service,
162
+ content=rendered,
163
+ target_path=target,
164
+ unresolved=_missing_vars(rendered),
165
+ )
166
+ )
167
+ return units
168
+
169
+
170
+ def write_units(
171
+ units: list[RenderedUnit],
172
+ *,
173
+ overwrite: bool = False,
174
+ ) -> list[Path]:
175
+ """Write rendered units to their target paths.
176
+
177
+ Refuses to clobber an existing file unless ``overwrite=True`` —
178
+ silently overwriting an operator's customised plist would be a foot-gun.
179
+ Returns the list of paths actually written. Raises ``FileExistsError``
180
+ if any target exists and overwrite is False.
181
+
182
+ Also ensures ``~/.ctrlrelay/logs/`` exists — both the launchd and
183
+ systemd templates set StandardOutput/StandardError paths under it,
184
+ and systemd refuses to start a unit at all (EXIT_STDOUT, code 209)
185
+ when the directory is missing; combined with ``Restart=always``
186
+ that's an immediate crash-loop (#137).
187
+
188
+ Unit files are chmod'd ``0600`` after writing since they embed
189
+ ``CTRLRELAY_TELEGRAM_TOKEN`` as plaintext.
190
+ """
191
+ (Path.home() / ".ctrlrelay" / "logs").mkdir(parents=True, exist_ok=True)
192
+ written: list[Path] = []
193
+ for unit in units:
194
+ unit.target_path.parent.mkdir(parents=True, exist_ok=True)
195
+ if unit.target_path.exists() and not overwrite:
196
+ raise FileExistsError(
197
+ f"refusing to overwrite {unit.target_path}; "
198
+ "pass --force to replace it"
199
+ )
200
+ unit.target_path.write_text(unit.content, encoding="utf-8")
201
+ unit.target_path.chmod(0o600)
202
+ written.append(unit.target_path)
203
+ return written
@@ -0,0 +1,26 @@
1
+ """Personalization sync — operator state shared across machines.
2
+
3
+ A separate (typically private) GitHub repo holds context that survives
4
+ across sessions and computers but doesn't belong inside any project's
5
+ own source tree: global Claude config, per-project memory, spec docs,
6
+ workspace planning notes. ctrlrelay clones this repo once per machine
7
+ and wires symlinks per the ``personalization.paths`` config so that
8
+ agent and operator writes flow straight into the working tree of the
9
+ checkout, ready to commit.
10
+
11
+ See ``manager.PersonalizationManager`` for the lifecycle.
12
+ """
13
+
14
+ from ctrlrelay.personalization.manager import PersonalizationManager
15
+ from ctrlrelay.personalization.paths import (
16
+ TemplateContext,
17
+ encode_project_path,
18
+ resolve_template,
19
+ )
20
+
21
+ __all__ = [
22
+ "PersonalizationManager",
23
+ "TemplateContext",
24
+ "encode_project_path",
25
+ "resolve_template",
26
+ ]