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/cli.py
ADDED
|
@@ -0,0 +1,3138 @@
|
|
|
1
|
+
"""CLI entry point for ctrlrelay."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.markup import escape as rich_escape
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from ctrlrelay import __version__
|
|
12
|
+
from ctrlrelay.core.config import ConfigError, load_config, resolve_config_path
|
|
13
|
+
from ctrlrelay.core.github import GitHubCLI, GitHubError
|
|
14
|
+
from ctrlrelay.core.pr_verifier import PRVerifier
|
|
15
|
+
from ctrlrelay.core.question_expiry import expire_stale_questions
|
|
16
|
+
|
|
17
|
+
# Startup probe cap. `GitHubCLI` uses 60s for ordinary calls; this one
|
|
18
|
+
# gates daemon startup, so a hung gh must not wedge it indefinitely.
|
|
19
|
+
_GH_PROBE_TIMEOUT_SECONDS = 60
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="ctrlrelay",
|
|
23
|
+
help="Local-first orchestrator for Claude Code across multiple GitHub repos.",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
)
|
|
26
|
+
console = Console()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def version_callback(value: bool) -> None:
|
|
30
|
+
if value:
|
|
31
|
+
console.print(f"ctrlrelay version {__version__}")
|
|
32
|
+
raise typer.Exit()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.callback()
|
|
36
|
+
def main(
|
|
37
|
+
version: bool = typer.Option(
|
|
38
|
+
False,
|
|
39
|
+
"--version",
|
|
40
|
+
"-v",
|
|
41
|
+
callback=version_callback,
|
|
42
|
+
is_eager=True,
|
|
43
|
+
help="Show version and exit.",
|
|
44
|
+
),
|
|
45
|
+
) -> None:
|
|
46
|
+
"""ctrlrelay orchestrator CLI."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _resolve_config_or_exit(config_path: str | None) -> Path:
|
|
50
|
+
"""Resolve --config (or auto-discover) and exit with a friendly error if missing."""
|
|
51
|
+
try:
|
|
52
|
+
return resolve_config_path(config_path)
|
|
53
|
+
except ConfigError as e:
|
|
54
|
+
console.print(f"[red]Error loading config:[/red] {e}")
|
|
55
|
+
raise typer.Exit(1)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Subcommand groups
|
|
59
|
+
config_app = typer.Typer(help="Configuration commands.")
|
|
60
|
+
app.add_typer(config_app, name="config")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@config_app.command("validate")
|
|
64
|
+
def config_validate(
|
|
65
|
+
config_path: str | None = typer.Option(
|
|
66
|
+
None,
|
|
67
|
+
"--config",
|
|
68
|
+
"-c",
|
|
69
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
70
|
+
),
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Validate orchestrator.yaml configuration."""
|
|
73
|
+
path = _resolve_config_or_exit(config_path)
|
|
74
|
+
|
|
75
|
+
if not path.exists():
|
|
76
|
+
console.print(f"[red]Error:[/red] Config file not found: {path}")
|
|
77
|
+
raise typer.Exit(1)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
config = load_config(path)
|
|
81
|
+
except ConfigError as e:
|
|
82
|
+
console.print(f"[red]Validation failed:[/red] {e}")
|
|
83
|
+
raise typer.Exit(1)
|
|
84
|
+
|
|
85
|
+
console.print(f"[green]✓[/green] Config valid: {path}")
|
|
86
|
+
console.print(f" Node ID: {config.node_id}")
|
|
87
|
+
console.print(f" Timezone: {config.timezone}")
|
|
88
|
+
console.print(f" Transport: {config.transport.type.value}")
|
|
89
|
+
console.print(f" Repos: {len(config.repos)}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@config_app.command("repos")
|
|
93
|
+
def config_repos(
|
|
94
|
+
config_path: str | None = typer.Option(
|
|
95
|
+
None,
|
|
96
|
+
"--config",
|
|
97
|
+
"-c",
|
|
98
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
99
|
+
),
|
|
100
|
+
) -> None:
|
|
101
|
+
"""List configured repositories."""
|
|
102
|
+
path = _resolve_config_or_exit(config_path)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
config = load_config(path)
|
|
106
|
+
except ConfigError as e:
|
|
107
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
108
|
+
raise typer.Exit(1)
|
|
109
|
+
|
|
110
|
+
if not config.repos:
|
|
111
|
+
console.print("[yellow]No repositories configured.[/yellow]")
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
table = Table(title="Configured Repositories")
|
|
115
|
+
table.add_column("Name", style="cyan")
|
|
116
|
+
table.add_column("Path", style="dim")
|
|
117
|
+
table.add_column("Deploy", style="green")
|
|
118
|
+
|
|
119
|
+
for repo in config.repos:
|
|
120
|
+
deploy = repo.deploy.provider if repo.deploy else "-"
|
|
121
|
+
table.add_row(repo.name, str(repo.local_path), deploy)
|
|
122
|
+
|
|
123
|
+
console.print(table)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Skills subcommand group
|
|
127
|
+
skills_app = typer.Typer(help="Skill management commands.")
|
|
128
|
+
app.add_typer(skills_app, name="skills")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _resolve_skills_dir(skills_path: str | None, config_path: str | None) -> Path:
|
|
132
|
+
"""Resolve skills directory from flag or config."""
|
|
133
|
+
if skills_path is not None:
|
|
134
|
+
skills_dir = Path(skills_path).expanduser().resolve()
|
|
135
|
+
else:
|
|
136
|
+
try:
|
|
137
|
+
config = load_config(resolve_config_path(config_path))
|
|
138
|
+
skills_dir = config.paths.skills.expanduser().resolve()
|
|
139
|
+
except ConfigError as e:
|
|
140
|
+
console.print(f"[red]Error loading config:[/red] {e}")
|
|
141
|
+
console.print("Use --path to specify skills directory directly.")
|
|
142
|
+
raise typer.Exit(1)
|
|
143
|
+
|
|
144
|
+
if not skills_dir.exists():
|
|
145
|
+
console.print(f"[red]Skills directory not found:[/red] {skills_dir}")
|
|
146
|
+
raise typer.Exit(1)
|
|
147
|
+
|
|
148
|
+
return skills_dir
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@skills_app.command("audit")
|
|
152
|
+
def skills_audit(
|
|
153
|
+
skills_path: str = typer.Option(
|
|
154
|
+
None,
|
|
155
|
+
"--path",
|
|
156
|
+
"-p",
|
|
157
|
+
help="Path to skills directory (default: from config)",
|
|
158
|
+
),
|
|
159
|
+
config_path: str | None = typer.Option(
|
|
160
|
+
None,
|
|
161
|
+
"--config",
|
|
162
|
+
"-c",
|
|
163
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
164
|
+
),
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Audit skills for orchestrator readiness."""
|
|
167
|
+
from ctrlrelay.core.audit import audit_all, format_report
|
|
168
|
+
|
|
169
|
+
skills_dir = _resolve_skills_dir(skills_path, config_path)
|
|
170
|
+
|
|
171
|
+
console.print(f"Auditing skills in: {skills_dir}\n")
|
|
172
|
+
|
|
173
|
+
audits = audit_all(skills_dir)
|
|
174
|
+
|
|
175
|
+
if not audits:
|
|
176
|
+
console.print("[yellow]No skills found.[/yellow]")
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
report = format_report(audits)
|
|
180
|
+
console.print(report)
|
|
181
|
+
|
|
182
|
+
# Exit with error if any skills not ready
|
|
183
|
+
if not all(a.passed for a in audits):
|
|
184
|
+
raise typer.Exit(1)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@skills_app.command("list")
|
|
188
|
+
def skills_list(
|
|
189
|
+
skills_path: str = typer.Option(
|
|
190
|
+
None,
|
|
191
|
+
"--path",
|
|
192
|
+
"-p",
|
|
193
|
+
help="Path to skills directory (default: from config)",
|
|
194
|
+
),
|
|
195
|
+
config_path: str | None = typer.Option(
|
|
196
|
+
None,
|
|
197
|
+
"--config",
|
|
198
|
+
"-c",
|
|
199
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
200
|
+
),
|
|
201
|
+
) -> None:
|
|
202
|
+
"""List available skills."""
|
|
203
|
+
from ctrlrelay.core.audit import discover_skills
|
|
204
|
+
|
|
205
|
+
skills_dir = _resolve_skills_dir(skills_path, config_path)
|
|
206
|
+
|
|
207
|
+
skills = discover_skills(skills_dir)
|
|
208
|
+
|
|
209
|
+
if not skills:
|
|
210
|
+
console.print("[yellow]No skills found.[/yellow]")
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
table = Table(title="Available Skills")
|
|
214
|
+
table.add_column("Name", style="cyan")
|
|
215
|
+
table.add_column("Path", style="dim")
|
|
216
|
+
|
|
217
|
+
for skill in skills:
|
|
218
|
+
table.add_row(skill.name, str(skill.path))
|
|
219
|
+
|
|
220
|
+
console.print(table)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# Bridge subcommand group
|
|
224
|
+
bridge_app = typer.Typer(help="Telegram bridge commands.")
|
|
225
|
+
app.add_typer(bridge_app, name="bridge")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _get_socket_path(config_path: str | None) -> Path:
|
|
229
|
+
"""Get socket path from config."""
|
|
230
|
+
try:
|
|
231
|
+
config = load_config(resolve_config_path(config_path))
|
|
232
|
+
if config.transport.telegram:
|
|
233
|
+
return config.transport.telegram.socket_path.expanduser().resolve()
|
|
234
|
+
except ConfigError:
|
|
235
|
+
pass
|
|
236
|
+
return Path("~/.ctrlrelay/ctrlrelay.sock").expanduser().resolve()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _get_bridge_pid_file(socket_path: Path) -> Path:
|
|
240
|
+
"""Get PID file path for bridge process."""
|
|
241
|
+
return socket_path.with_suffix(".pid")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@bridge_app.command("start")
|
|
245
|
+
def bridge_start(
|
|
246
|
+
config_path: str | None = typer.Option(
|
|
247
|
+
None,
|
|
248
|
+
"--config",
|
|
249
|
+
"-c",
|
|
250
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
251
|
+
),
|
|
252
|
+
foreground: bool = typer.Option(
|
|
253
|
+
False,
|
|
254
|
+
"--foreground",
|
|
255
|
+
"-F",
|
|
256
|
+
help="Run in the foreground (for launchd/systemd/debugging). Default is to daemonize.",
|
|
257
|
+
),
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Start the Telegram bridge.
|
|
260
|
+
|
|
261
|
+
Daemonizes by default so the terminal returns to you. Pass --foreground
|
|
262
|
+
under a process supervisor (launchd Type=simple, systemd Type=simple) or
|
|
263
|
+
when debugging interactively.
|
|
264
|
+
"""
|
|
265
|
+
import os
|
|
266
|
+
import subprocess
|
|
267
|
+
import sys
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
config = load_config(resolve_config_path(config_path))
|
|
271
|
+
except ConfigError as e:
|
|
272
|
+
console.print(f"[red]Error loading config:[/red] {e}")
|
|
273
|
+
raise typer.Exit(1)
|
|
274
|
+
|
|
275
|
+
if config.transport.type.value != "telegram":
|
|
276
|
+
console.print("[yellow]Transport is not set to 'telegram' in config.[/yellow]")
|
|
277
|
+
console.print("Set transport.type: telegram to use the bridge.")
|
|
278
|
+
raise typer.Exit(1)
|
|
279
|
+
|
|
280
|
+
telegram_config = config.transport.telegram
|
|
281
|
+
if not telegram_config:
|
|
282
|
+
console.print("[red]Telegram config not found.[/red]")
|
|
283
|
+
raise typer.Exit(1)
|
|
284
|
+
|
|
285
|
+
socket_path = telegram_config.socket_path.expanduser().resolve()
|
|
286
|
+
pid_file = _get_bridge_pid_file(socket_path)
|
|
287
|
+
|
|
288
|
+
if pid_file.exists():
|
|
289
|
+
try:
|
|
290
|
+
pid = int(pid_file.read_text().strip())
|
|
291
|
+
os.kill(pid, 0)
|
|
292
|
+
console.print(f"[yellow]Bridge already running (PID {pid})[/yellow]")
|
|
293
|
+
raise typer.Exit(1)
|
|
294
|
+
except (ProcessLookupError, ValueError):
|
|
295
|
+
pid_file.unlink(missing_ok=True)
|
|
296
|
+
|
|
297
|
+
bot_token = os.environ.get(telegram_config.bot_token_env)
|
|
298
|
+
if not bot_token:
|
|
299
|
+
env_var = telegram_config.bot_token_env
|
|
300
|
+
console.print(f"[red]Bot token not found.[/red] Set {env_var} environment variable.")
|
|
301
|
+
raise typer.Exit(1)
|
|
302
|
+
|
|
303
|
+
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
|
304
|
+
|
|
305
|
+
if foreground:
|
|
306
|
+
import asyncio
|
|
307
|
+
import signal
|
|
308
|
+
|
|
309
|
+
from ctrlrelay.bridge import BridgeServer
|
|
310
|
+
|
|
311
|
+
# Install early SIGTERM/SIGINT handlers so a supervisor stop between
|
|
312
|
+
# now and loop.add_signal_handler below still runs the `finally`
|
|
313
|
+
# that unlinks the PID file. loop.add_signal_handler replaces them
|
|
314
|
+
# once the asyncio loop is running.
|
|
315
|
+
def _raise_systemexit_on_signal(sig: int, _frame: object) -> None:
|
|
316
|
+
raise SystemExit(0)
|
|
317
|
+
|
|
318
|
+
for _sig in (signal.SIGTERM, signal.SIGINT):
|
|
319
|
+
signal.signal(_sig, _raise_systemexit_on_signal)
|
|
320
|
+
|
|
321
|
+
pid_file.write_text(str(os.getpid()))
|
|
322
|
+
console.print(f"Starting bridge on {socket_path}")
|
|
323
|
+
console.print("Press Ctrl+C to stop")
|
|
324
|
+
|
|
325
|
+
# Open the state DB so the bridge can route orphan Telegram replies
|
|
326
|
+
# to persisted BLOCKED sessions in pending_resumes. Both daemons
|
|
327
|
+
# share ~/.ctrlrelay/state.db — SQLite's WAL mode handles concurrent
|
|
328
|
+
# readers/writers for the low contention we see here.
|
|
329
|
+
from ctrlrelay.core.state import StateDB
|
|
330
|
+
state_db = StateDB(config.paths.state_db)
|
|
331
|
+
|
|
332
|
+
server = BridgeServer(
|
|
333
|
+
socket_path=socket_path,
|
|
334
|
+
bot_token=bot_token,
|
|
335
|
+
chat_id=telegram_config.chat_id,
|
|
336
|
+
state_db=state_db,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
loop = asyncio.new_event_loop()
|
|
340
|
+
try:
|
|
341
|
+
asyncio.set_event_loop(loop)
|
|
342
|
+
|
|
343
|
+
async def _run_server() -> None:
|
|
344
|
+
# Wrap start() in a finally that awaits stop() so the loop
|
|
345
|
+
# can't close before _telegram.close() and the socket unlink
|
|
346
|
+
# have actually completed. Scheduling stop() as a bare task
|
|
347
|
+
# in the signal handler would not guarantee that ordering.
|
|
348
|
+
try:
|
|
349
|
+
await server.start()
|
|
350
|
+
finally:
|
|
351
|
+
await server.stop()
|
|
352
|
+
|
|
353
|
+
main_task = loop.create_task(_run_server())
|
|
354
|
+
|
|
355
|
+
def _handle_stop(sig: int) -> None:
|
|
356
|
+
main_task.cancel()
|
|
357
|
+
|
|
358
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
359
|
+
loop.add_signal_handler(sig, _handle_stop, sig)
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
loop.run_until_complete(main_task)
|
|
363
|
+
except asyncio.CancelledError:
|
|
364
|
+
pass
|
|
365
|
+
finally:
|
|
366
|
+
loop.close()
|
|
367
|
+
try:
|
|
368
|
+
state_db.close()
|
|
369
|
+
except Exception:
|
|
370
|
+
pass
|
|
371
|
+
pid_file.unlink(missing_ok=True)
|
|
372
|
+
else:
|
|
373
|
+
# Pass the token via environment, never argv. Putting it on the command
|
|
374
|
+
# line would expose it to anyone who can read `ps` / /proc/*/cmdline.
|
|
375
|
+
cmd = [
|
|
376
|
+
sys.executable,
|
|
377
|
+
"-m",
|
|
378
|
+
"ctrlrelay.bridge",
|
|
379
|
+
"--socket-path",
|
|
380
|
+
str(socket_path),
|
|
381
|
+
"--bot-token-env",
|
|
382
|
+
telegram_config.bot_token_env,
|
|
383
|
+
"--chat-id",
|
|
384
|
+
str(telegram_config.chat_id),
|
|
385
|
+
"--state-db",
|
|
386
|
+
str(config.paths.state_db),
|
|
387
|
+
]
|
|
388
|
+
proc = subprocess.Popen(
|
|
389
|
+
cmd,
|
|
390
|
+
stdout=subprocess.DEVNULL,
|
|
391
|
+
stderr=subprocess.DEVNULL,
|
|
392
|
+
start_new_session=True,
|
|
393
|
+
)
|
|
394
|
+
# Claim the PID file BEFORE the liveness probe; a second concurrent
|
|
395
|
+
# `bridge start` in the 1-second window would otherwise see no PID
|
|
396
|
+
# file, spawn its own child, and both would rebind the shared socket.
|
|
397
|
+
pid_file.write_text(str(proc.pid))
|
|
398
|
+
try:
|
|
399
|
+
proc.wait(timeout=1.0)
|
|
400
|
+
except subprocess.TimeoutExpired:
|
|
401
|
+
console.print(f"[green]Bridge started (PID {proc.pid})[/green]")
|
|
402
|
+
return
|
|
403
|
+
# Child exited within 1s. Zero = clean no-op; non-zero = crash.
|
|
404
|
+
pid_file.unlink(missing_ok=True)
|
|
405
|
+
if proc.returncode == 0:
|
|
406
|
+
console.print(
|
|
407
|
+
"[yellow]Bridge exited immediately with no work to do.[/yellow]"
|
|
408
|
+
)
|
|
409
|
+
return
|
|
410
|
+
console.print(
|
|
411
|
+
f"[red]Bridge failed to start[/red] "
|
|
412
|
+
f"(child exited with code {proc.returncode})"
|
|
413
|
+
)
|
|
414
|
+
raise typer.Exit(1)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@bridge_app.command("stop")
|
|
418
|
+
def bridge_stop(
|
|
419
|
+
config_path: str | None = typer.Option(
|
|
420
|
+
None,
|
|
421
|
+
"--config",
|
|
422
|
+
"-c",
|
|
423
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
424
|
+
),
|
|
425
|
+
) -> None:
|
|
426
|
+
"""Stop the Telegram bridge."""
|
|
427
|
+
import os
|
|
428
|
+
import signal
|
|
429
|
+
|
|
430
|
+
socket_path = _get_socket_path(config_path)
|
|
431
|
+
pid_file = _get_bridge_pid_file(socket_path)
|
|
432
|
+
|
|
433
|
+
if not pid_file.exists():
|
|
434
|
+
console.print("[yellow]Bridge not running (no PID file)[/yellow]")
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
pid = int(pid_file.read_text().strip())
|
|
439
|
+
os.kill(pid, signal.SIGTERM)
|
|
440
|
+
console.print(f"[green]Stopped bridge (PID {pid})[/green]")
|
|
441
|
+
pid_file.unlink(missing_ok=True)
|
|
442
|
+
except ProcessLookupError:
|
|
443
|
+
console.print("[yellow]Bridge process not found[/yellow]")
|
|
444
|
+
pid_file.unlink(missing_ok=True)
|
|
445
|
+
except ValueError:
|
|
446
|
+
console.print("[red]Invalid PID file[/red]")
|
|
447
|
+
pid_file.unlink(missing_ok=True)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@bridge_app.command("status")
|
|
451
|
+
def bridge_status(
|
|
452
|
+
config_path: str | None = typer.Option(
|
|
453
|
+
None,
|
|
454
|
+
"--config",
|
|
455
|
+
"-c",
|
|
456
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
457
|
+
),
|
|
458
|
+
) -> None:
|
|
459
|
+
"""Check bridge status."""
|
|
460
|
+
import os
|
|
461
|
+
|
|
462
|
+
socket_path = _get_socket_path(config_path)
|
|
463
|
+
pid_file = _get_bridge_pid_file(socket_path)
|
|
464
|
+
|
|
465
|
+
if pid_file.exists():
|
|
466
|
+
try:
|
|
467
|
+
pid = int(pid_file.read_text().strip())
|
|
468
|
+
os.kill(pid, 0)
|
|
469
|
+
console.print(f"[green]Bridge running (PID {pid})[/green]")
|
|
470
|
+
console.print(f"Socket: {socket_path}")
|
|
471
|
+
return
|
|
472
|
+
except (ProcessLookupError, ValueError):
|
|
473
|
+
pass
|
|
474
|
+
|
|
475
|
+
if socket_path.exists():
|
|
476
|
+
console.print("[yellow]Socket exists but no PID file[/yellow]")
|
|
477
|
+
console.print(
|
|
478
|
+
"[dim]The bridge may be running under a supervisor that pre-dates "
|
|
479
|
+
"the PID-file change — restart it to refresh state.[/dim]"
|
|
480
|
+
)
|
|
481
|
+
console.print(f"Socket: {socket_path}")
|
|
482
|
+
raise typer.Exit(1)
|
|
483
|
+
|
|
484
|
+
console.print("[dim]Bridge not running[/dim]")
|
|
485
|
+
raise typer.Exit(1)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@bridge_app.command("test")
|
|
489
|
+
def bridge_test(
|
|
490
|
+
message: str = typer.Option(
|
|
491
|
+
"Test message from ctrlrelay bridge",
|
|
492
|
+
"--message",
|
|
493
|
+
"-m",
|
|
494
|
+
help="Message to send",
|
|
495
|
+
),
|
|
496
|
+
config_path: str | None = typer.Option(
|
|
497
|
+
None,
|
|
498
|
+
"--config",
|
|
499
|
+
"-c",
|
|
500
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
501
|
+
),
|
|
502
|
+
) -> None:
|
|
503
|
+
"""Send a test message to verify bridge is working."""
|
|
504
|
+
import asyncio
|
|
505
|
+
|
|
506
|
+
socket_path = _get_socket_path(config_path)
|
|
507
|
+
|
|
508
|
+
if not socket_path.exists():
|
|
509
|
+
console.print("[red]Bridge not running.[/red] Start it with: ctrlrelay bridge start")
|
|
510
|
+
raise typer.Exit(1)
|
|
511
|
+
|
|
512
|
+
async def send_test():
|
|
513
|
+
from ctrlrelay.transports import SocketTransport
|
|
514
|
+
|
|
515
|
+
# Send-only helper: never calls ask(), so the operator's
|
|
516
|
+
# ask_timeout_seconds is irrelevant here.
|
|
517
|
+
transport = SocketTransport(socket_path)
|
|
518
|
+
try:
|
|
519
|
+
await transport.connect()
|
|
520
|
+
await transport.send(message)
|
|
521
|
+
console.print("[green]Message sent successfully![/green]")
|
|
522
|
+
finally:
|
|
523
|
+
await transport.close()
|
|
524
|
+
|
|
525
|
+
try:
|
|
526
|
+
asyncio.run(send_test())
|
|
527
|
+
except Exception as e:
|
|
528
|
+
console.print(f"[red]Failed to send message:[/red] {e}")
|
|
529
|
+
raise typer.Exit(1)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# Run subcommand group
|
|
533
|
+
run_app = typer.Typer(help="Pipeline execution commands.")
|
|
534
|
+
app.add_typer(run_app, name="run")
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
@run_app.command("secops")
|
|
538
|
+
def run_secops(
|
|
539
|
+
config_path: str | None = typer.Option(
|
|
540
|
+
None,
|
|
541
|
+
"--config",
|
|
542
|
+
"-c",
|
|
543
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
544
|
+
),
|
|
545
|
+
repo: str = typer.Option(
|
|
546
|
+
None,
|
|
547
|
+
"--repo",
|
|
548
|
+
"-r",
|
|
549
|
+
help="Run on specific repo only",
|
|
550
|
+
),
|
|
551
|
+
) -> None:
|
|
552
|
+
"""Run secops pipeline on configured repos."""
|
|
553
|
+
import asyncio
|
|
554
|
+
|
|
555
|
+
from ctrlrelay.core.dispatcher import make_agent_dispatcher
|
|
556
|
+
from ctrlrelay.core.github import GitHubCLI
|
|
557
|
+
from ctrlrelay.core.state import StateDB
|
|
558
|
+
from ctrlrelay.core.worktree import WorktreeManager
|
|
559
|
+
from ctrlrelay.dashboard.client import DashboardClient
|
|
560
|
+
from ctrlrelay.pipelines.secops import run_secops_all
|
|
561
|
+
|
|
562
|
+
path = _resolve_config_or_exit(config_path)
|
|
563
|
+
|
|
564
|
+
try:
|
|
565
|
+
config = load_config(path)
|
|
566
|
+
except ConfigError as e:
|
|
567
|
+
console.print(f"[red]Error loading config:[/red] {e}")
|
|
568
|
+
raise typer.Exit(1)
|
|
569
|
+
|
|
570
|
+
repos = config.repos
|
|
571
|
+
if repo:
|
|
572
|
+
repos = [r for r in repos if r.name == repo]
|
|
573
|
+
if not repos:
|
|
574
|
+
console.print(f"[red]Repo not found:[/red] {repo}")
|
|
575
|
+
raise typer.Exit(1)
|
|
576
|
+
|
|
577
|
+
if not repos:
|
|
578
|
+
console.print("[yellow]No repos configured.[/yellow]")
|
|
579
|
+
return
|
|
580
|
+
|
|
581
|
+
db = StateDB(config.paths.state_db)
|
|
582
|
+
dispatcher = make_agent_dispatcher(config.agent)
|
|
583
|
+
github = GitHubCLI()
|
|
584
|
+
worktree = WorktreeManager(
|
|
585
|
+
worktrees_dir=config.paths.worktrees,
|
|
586
|
+
bare_repos_dir=config.paths.bare_repos,
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
dashboard = None
|
|
590
|
+
if config.dashboard.enabled and config.dashboard.url:
|
|
591
|
+
import os
|
|
592
|
+
token = os.environ.get(config.dashboard.auth_token_env, "")
|
|
593
|
+
if token:
|
|
594
|
+
dashboard = DashboardClient(
|
|
595
|
+
url=config.dashboard.url,
|
|
596
|
+
auth_token=token,
|
|
597
|
+
node_id=config.node_id,
|
|
598
|
+
queue_dir=config.paths.state_db.parent / "event_queue",
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
console.print(f"Running secops on {len(repos)} repo(s)...")
|
|
602
|
+
|
|
603
|
+
async def _run():
|
|
604
|
+
# Build a transport so secops blocked questions reach Telegram.
|
|
605
|
+
# Without this, agents that end BLOCKED_NEEDS_INPUT silently land
|
|
606
|
+
# in the DB+pending_resumes and the operator never sees the
|
|
607
|
+
# question (mirror of the wiring in the scheduled path).
|
|
608
|
+
transport = None
|
|
609
|
+
if (
|
|
610
|
+
config.transport.type.value == "telegram"
|
|
611
|
+
and config.transport.telegram
|
|
612
|
+
):
|
|
613
|
+
from ctrlrelay.transports import SocketTransport
|
|
614
|
+
sock = config.transport.telegram.socket_path.expanduser().resolve()
|
|
615
|
+
if sock.exists():
|
|
616
|
+
try:
|
|
617
|
+
candidate = SocketTransport(
|
|
618
|
+
sock,
|
|
619
|
+
ask_timeout_seconds=(
|
|
620
|
+
config.transport.telegram.ask_timeout_seconds
|
|
621
|
+
),
|
|
622
|
+
)
|
|
623
|
+
await candidate.connect()
|
|
624
|
+
transport = candidate
|
|
625
|
+
except Exception as e:
|
|
626
|
+
console.print(
|
|
627
|
+
f"[yellow]Bridge transport connect failed ({e}) — "
|
|
628
|
+
f"running without notifications. Blocked sessions "
|
|
629
|
+
f"will land in pending_resumes for later resume.[/yellow]"
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
return await run_secops_all(
|
|
633
|
+
repos=repos,
|
|
634
|
+
dispatcher=dispatcher,
|
|
635
|
+
github=github,
|
|
636
|
+
worktree=worktree,
|
|
637
|
+
dashboard=dashboard,
|
|
638
|
+
state_db=db,
|
|
639
|
+
transport=transport,
|
|
640
|
+
contexts_dir=config.paths.contexts,
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
try:
|
|
644
|
+
results = asyncio.run(_run())
|
|
645
|
+
except Exception as e:
|
|
646
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
647
|
+
raise typer.Exit(1)
|
|
648
|
+
finally:
|
|
649
|
+
db.close()
|
|
650
|
+
|
|
651
|
+
success_count = sum(1 for r in results if r.success)
|
|
652
|
+
console.print(f"\n[bold]Results:[/bold] {success_count}/{len(results)} succeeded")
|
|
653
|
+
|
|
654
|
+
for result in results:
|
|
655
|
+
status = "[green]OK[/green]" if result.success else "[red]FAIL[/red]"
|
|
656
|
+
console.print(f" {status} {result.summary}")
|
|
657
|
+
# Surface the blocking question / error text so an operator
|
|
658
|
+
# running secops at the CLI doesn't have to dig into state.db
|
|
659
|
+
# to see what the agent was asking. The scheduler closure
|
|
660
|
+
# already relays these via Telegram; this is the manual path.
|
|
661
|
+
if result.blocked and result.question:
|
|
662
|
+
console.print(f" [yellow]Question:[/yellow] {result.question}")
|
|
663
|
+
elif not result.success and result.error:
|
|
664
|
+
console.print(f" [red]Error:[/red] {result.error}")
|
|
665
|
+
|
|
666
|
+
if not all(r.success for r in results):
|
|
667
|
+
raise typer.Exit(1)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
@run_app.command("dev")
|
|
671
|
+
def run_dev(
|
|
672
|
+
issue: int = typer.Option(
|
|
673
|
+
...,
|
|
674
|
+
"--issue",
|
|
675
|
+
"-i",
|
|
676
|
+
help="GitHub issue number to implement",
|
|
677
|
+
),
|
|
678
|
+
repo: str = typer.Option(
|
|
679
|
+
None,
|
|
680
|
+
"--repo",
|
|
681
|
+
"-r",
|
|
682
|
+
help="Run on specific repo only",
|
|
683
|
+
),
|
|
684
|
+
config_path: str | None = typer.Option(
|
|
685
|
+
None,
|
|
686
|
+
"--config",
|
|
687
|
+
"-c",
|
|
688
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
689
|
+
),
|
|
690
|
+
) -> None:
|
|
691
|
+
"""Run dev pipeline for a GitHub issue."""
|
|
692
|
+
import asyncio
|
|
693
|
+
|
|
694
|
+
from ctrlrelay.core.dispatcher import make_agent_dispatcher
|
|
695
|
+
from ctrlrelay.core.github import GitHubCLI
|
|
696
|
+
from ctrlrelay.core.state import StateDB
|
|
697
|
+
from ctrlrelay.core.worktree import WorktreeManager
|
|
698
|
+
from ctrlrelay.pipelines.dev import run_dev_issue
|
|
699
|
+
|
|
700
|
+
path = _resolve_config_or_exit(config_path)
|
|
701
|
+
|
|
702
|
+
try:
|
|
703
|
+
config = load_config(path)
|
|
704
|
+
except ConfigError as e:
|
|
705
|
+
console.print(f"[red]Error loading config:[/red] {e}")
|
|
706
|
+
raise typer.Exit(1)
|
|
707
|
+
|
|
708
|
+
repos = config.repos
|
|
709
|
+
if repo:
|
|
710
|
+
repos = [r for r in repos if r.name == repo]
|
|
711
|
+
if not repos:
|
|
712
|
+
console.print(f"[red]Repo not found:[/red] {repo}")
|
|
713
|
+
raise typer.Exit(1)
|
|
714
|
+
|
|
715
|
+
if not repos:
|
|
716
|
+
console.print("[yellow]No repos configured.[/yellow]")
|
|
717
|
+
return
|
|
718
|
+
|
|
719
|
+
if len(repos) > 1 and not repo:
|
|
720
|
+
console.print(
|
|
721
|
+
"[red]Error:[/red] Multiple repos configured. "
|
|
722
|
+
"Use --repo to specify which one."
|
|
723
|
+
)
|
|
724
|
+
raise typer.Exit(1)
|
|
725
|
+
|
|
726
|
+
repo_config = repos[0]
|
|
727
|
+
branch_template = repo_config.dev_branch_template
|
|
728
|
+
|
|
729
|
+
db = StateDB(config.paths.state_db)
|
|
730
|
+
dispatcher = make_agent_dispatcher(config.agent)
|
|
731
|
+
github = GitHubCLI()
|
|
732
|
+
worktree = WorktreeManager(
|
|
733
|
+
worktrees_dir=config.paths.worktrees,
|
|
734
|
+
bare_repos_dir=config.paths.bare_repos,
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
dashboard = None
|
|
738
|
+
if config.dashboard.enabled and config.dashboard.url:
|
|
739
|
+
import os
|
|
740
|
+
token = os.environ.get(config.dashboard.auth_token_env, "")
|
|
741
|
+
if token:
|
|
742
|
+
from ctrlrelay.dashboard.client import DashboardClient
|
|
743
|
+
dashboard = DashboardClient(
|
|
744
|
+
url=config.dashboard.url,
|
|
745
|
+
auth_token=token,
|
|
746
|
+
node_id=config.node_id,
|
|
747
|
+
queue_dir=config.paths.state_db.parent / "event_queue",
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
console.print(f"Running dev pipeline for issue #{issue} on {repo_config.name}...")
|
|
751
|
+
|
|
752
|
+
async def _run():
|
|
753
|
+
return await run_dev_issue(
|
|
754
|
+
repo=repo_config.name,
|
|
755
|
+
issue_number=issue,
|
|
756
|
+
branch_template=branch_template,
|
|
757
|
+
dispatcher=dispatcher,
|
|
758
|
+
github=github,
|
|
759
|
+
worktree=worktree,
|
|
760
|
+
dashboard=dashboard,
|
|
761
|
+
state_db=db,
|
|
762
|
+
transport=None,
|
|
763
|
+
contexts_dir=config.paths.contexts,
|
|
764
|
+
ci_wait_timeout_seconds=repo_config.automation.ci_wait_timeout_seconds,
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
try:
|
|
768
|
+
result = asyncio.run(_run())
|
|
769
|
+
except Exception as e:
|
|
770
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
771
|
+
raise typer.Exit(1)
|
|
772
|
+
finally:
|
|
773
|
+
db.close()
|
|
774
|
+
|
|
775
|
+
if result.success:
|
|
776
|
+
pr_url = result.outputs.get("pr_url", "") if result.outputs else ""
|
|
777
|
+
console.print(f"[green]Success:[/green] {result.summary}")
|
|
778
|
+
if pr_url:
|
|
779
|
+
console.print(f" PR: {pr_url}")
|
|
780
|
+
elif result.blocked:
|
|
781
|
+
console.print(f"[blue]Blocked:[/blue] {result.summary}")
|
|
782
|
+
if result.question:
|
|
783
|
+
console.print(f" Question: {result.question}")
|
|
784
|
+
raise typer.Exit(1)
|
|
785
|
+
else:
|
|
786
|
+
console.print(f"[red]Failed:[/red] {result.summary}")
|
|
787
|
+
if result.error:
|
|
788
|
+
console.print(f" Error: {result.error}")
|
|
789
|
+
raise typer.Exit(1)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
# CI subcommand group
|
|
793
|
+
# Exists so the dev pipeline has a correct, exit-code-driven helper to wait
|
|
794
|
+
# on PR checks. Claude used to improvise bash `until gh pr checks` loops that
|
|
795
|
+
# were inverted-semantics and swallowed exit codes (see issue #85), burning
|
|
796
|
+
# the whole session timeout on PRs that had already gone green.
|
|
797
|
+
ci_app = typer.Typer(help="CI helpers.")
|
|
798
|
+
app.add_typer(ci_app, name="ci")
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
@ci_app.command("wait")
|
|
802
|
+
def ci_wait(
|
|
803
|
+
pr: int = typer.Option(..., "--pr", "-p", help="PR number to wait on"),
|
|
804
|
+
repo: str = typer.Option(..., "--repo", "-r", help="Repository as owner/name"),
|
|
805
|
+
timeout: int = typer.Option(
|
|
806
|
+
600,
|
|
807
|
+
"--timeout",
|
|
808
|
+
"-t",
|
|
809
|
+
help="Hard timeout in seconds before giving up (exit 2).",
|
|
810
|
+
),
|
|
811
|
+
interval: int = typer.Option(
|
|
812
|
+
15,
|
|
813
|
+
"--interval",
|
|
814
|
+
"-i",
|
|
815
|
+
help="Poll interval in seconds.",
|
|
816
|
+
),
|
|
817
|
+
) -> None:
|
|
818
|
+
"""Wait for a PR's CI checks to finish.
|
|
819
|
+
|
|
820
|
+
Exit codes:
|
|
821
|
+
0 — all checks passed (or no CI configured)
|
|
822
|
+
1 — at least one check failed / cancelled / timed_out
|
|
823
|
+
2 — hard timeout hit while checks were still pending
|
|
824
|
+
"""
|
|
825
|
+
import asyncio
|
|
826
|
+
|
|
827
|
+
github = GitHubCLI()
|
|
828
|
+
verifier = PRVerifier(
|
|
829
|
+
github=github,
|
|
830
|
+
poll_interval=interval,
|
|
831
|
+
check_timeout=timeout,
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
try:
|
|
835
|
+
checks = asyncio.run(
|
|
836
|
+
verifier.wait_for_checks(repo, pr, timeout=timeout)
|
|
837
|
+
)
|
|
838
|
+
except (GitHubError, asyncio.TimeoutError) as e:
|
|
839
|
+
# wait_for_checks already retries transient errors internally;
|
|
840
|
+
# this except is defense-in-depth for any leak past the retry
|
|
841
|
+
# window (e.g. repeated timeouts that exhaust the deadline
|
|
842
|
+
# mid-call). Surface a clean message instead of a traceback.
|
|
843
|
+
console.print(
|
|
844
|
+
f"[red]gh error ({type(e).__name__}):[/red] {e}"
|
|
845
|
+
)
|
|
846
|
+
raise typer.Exit(1) from e
|
|
847
|
+
|
|
848
|
+
pending = [c for c in checks if c.get("bucket") == "pending"]
|
|
849
|
+
failing = [
|
|
850
|
+
c for c in checks
|
|
851
|
+
if c.get("bucket") not in ("pending", "pass", "skipping")
|
|
852
|
+
]
|
|
853
|
+
|
|
854
|
+
if failing:
|
|
855
|
+
names = ", ".join(
|
|
856
|
+
f"{c.get('name', '?')}={c.get('state') or c.get('bucket')}"
|
|
857
|
+
for c in failing
|
|
858
|
+
)
|
|
859
|
+
console.print(f"[red]CI failing:[/red] {names}")
|
|
860
|
+
raise typer.Exit(1)
|
|
861
|
+
|
|
862
|
+
if pending:
|
|
863
|
+
names = ", ".join(c.get("name", "?") for c in pending)
|
|
864
|
+
console.print(
|
|
865
|
+
f"[yellow]CI still running after {timeout}s:[/yellow] {names}"
|
|
866
|
+
)
|
|
867
|
+
raise typer.Exit(2)
|
|
868
|
+
|
|
869
|
+
if checks:
|
|
870
|
+
console.print(f"[green]All {len(checks)} check(s) passed.[/green]")
|
|
871
|
+
else:
|
|
872
|
+
console.print("[green]No CI checks configured — treating as pass.[/green]")
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
# Poller subcommand group
|
|
876
|
+
poller_app = typer.Typer(help="Issue poller commands.")
|
|
877
|
+
app.add_typer(poller_app, name="poller")
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _get_poller_pid_file(config_path: str | None) -> Path:
|
|
881
|
+
"""Get PID file path for poller process."""
|
|
882
|
+
try:
|
|
883
|
+
config = load_config(resolve_config_path(config_path))
|
|
884
|
+
return config.paths.state_db.parent / "poller.pid"
|
|
885
|
+
except ConfigError:
|
|
886
|
+
pass
|
|
887
|
+
return Path("~/.ctrlrelay/poller.pid").expanduser().resolve()
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
@poller_app.command("start")
|
|
891
|
+
def poller_start(
|
|
892
|
+
config_path: str | None = typer.Option(
|
|
893
|
+
None,
|
|
894
|
+
"--config",
|
|
895
|
+
"-c",
|
|
896
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
897
|
+
),
|
|
898
|
+
foreground: bool = typer.Option(
|
|
899
|
+
False,
|
|
900
|
+
"--foreground",
|
|
901
|
+
"-F",
|
|
902
|
+
help="Run in the foreground (for launchd/systemd/debugging). Default is to daemonize.",
|
|
903
|
+
),
|
|
904
|
+
interval: int = typer.Option(
|
|
905
|
+
300,
|
|
906
|
+
"--interval",
|
|
907
|
+
"-i",
|
|
908
|
+
help="Polling interval in seconds",
|
|
909
|
+
),
|
|
910
|
+
) -> None:
|
|
911
|
+
"""Start the issue poller.
|
|
912
|
+
|
|
913
|
+
Daemonizes by default so the terminal returns to you. Pass --foreground
|
|
914
|
+
under a process supervisor (launchd Type=simple, systemd Type=simple) or
|
|
915
|
+
when debugging interactively.
|
|
916
|
+
"""
|
|
917
|
+
import asyncio
|
|
918
|
+
import os
|
|
919
|
+
import signal
|
|
920
|
+
import subprocess
|
|
921
|
+
import sys
|
|
922
|
+
|
|
923
|
+
resolved_config = _resolve_config_or_exit(config_path)
|
|
924
|
+
|
|
925
|
+
try:
|
|
926
|
+
config = load_config(resolved_config)
|
|
927
|
+
except ConfigError as e:
|
|
928
|
+
console.print(f"[red]Error loading config:[/red] {e}")
|
|
929
|
+
raise typer.Exit(1)
|
|
930
|
+
|
|
931
|
+
pid_file = _get_poller_pid_file(str(resolved_config))
|
|
932
|
+
if pid_file.exists():
|
|
933
|
+
try:
|
|
934
|
+
pid = int(pid_file.read_text().strip())
|
|
935
|
+
if pid == os.getpid():
|
|
936
|
+
# The daemon parent wrote our own PID here before spawning us
|
|
937
|
+
# as the `--foreground` child. Don't treat it as a conflict.
|
|
938
|
+
pass
|
|
939
|
+
else:
|
|
940
|
+
os.kill(pid, 0)
|
|
941
|
+
console.print(f"[yellow]Poller already running (PID {pid})[/yellow]")
|
|
942
|
+
raise typer.Exit(1)
|
|
943
|
+
except (ProcessLookupError, ValueError):
|
|
944
|
+
pid_file.unlink(missing_ok=True)
|
|
945
|
+
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
|
946
|
+
|
|
947
|
+
if not foreground:
|
|
948
|
+
cmd = [
|
|
949
|
+
sys.executable,
|
|
950
|
+
"-m",
|
|
951
|
+
"ctrlrelay.cli",
|
|
952
|
+
"poller",
|
|
953
|
+
"start",
|
|
954
|
+
"--config",
|
|
955
|
+
str(resolved_config),
|
|
956
|
+
"--interval",
|
|
957
|
+
str(interval),
|
|
958
|
+
"--foreground",
|
|
959
|
+
]
|
|
960
|
+
proc = subprocess.Popen(
|
|
961
|
+
cmd,
|
|
962
|
+
stdout=subprocess.DEVNULL,
|
|
963
|
+
stderr=subprocess.DEVNULL,
|
|
964
|
+
start_new_session=True,
|
|
965
|
+
)
|
|
966
|
+
# Claim the PID file BEFORE the liveness probe; otherwise a second
|
|
967
|
+
# concurrent `start` in the 1-second window would see no PID file and
|
|
968
|
+
# spawn a duplicate poller.
|
|
969
|
+
pid_file.write_text(str(proc.pid))
|
|
970
|
+
try:
|
|
971
|
+
proc.wait(timeout=1.0)
|
|
972
|
+
except subprocess.TimeoutExpired:
|
|
973
|
+
console.print(f"[green]Poller started (PID {proc.pid})[/green]")
|
|
974
|
+
return
|
|
975
|
+
# Child exited. Zero = clean no-op (e.g. `repos: []`); non-zero = crash.
|
|
976
|
+
pid_file.unlink(missing_ok=True)
|
|
977
|
+
if proc.returncode == 0:
|
|
978
|
+
console.print(
|
|
979
|
+
"[yellow]Poller exited immediately with no work to do "
|
|
980
|
+
"(check `repos:` in your config).[/yellow]"
|
|
981
|
+
)
|
|
982
|
+
return
|
|
983
|
+
console.print(
|
|
984
|
+
f"[red]Poller failed to start[/red] "
|
|
985
|
+
f"(child exited with code {proc.returncode})"
|
|
986
|
+
)
|
|
987
|
+
raise typer.Exit(1)
|
|
988
|
+
|
|
989
|
+
# Install SIGTERM/SIGINT handlers BEFORE any startup work so a supervisor
|
|
990
|
+
# stop during `gh api user` / `seed_current()` still unwinds through the
|
|
991
|
+
# `finally` that unlinks poller.pid. Converting the signal into SystemExit
|
|
992
|
+
# lets Python's normal unwind run `finally` blocks. Once the asyncio loop
|
|
993
|
+
# is up below, `loop.add_signal_handler` overrides these to drive a
|
|
994
|
+
# graceful cancel of the poll loop.
|
|
995
|
+
def _raise_systemexit_on_signal(sig: int, _frame: object) -> None:
|
|
996
|
+
raise SystemExit(0)
|
|
997
|
+
|
|
998
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
999
|
+
signal.signal(sig, _raise_systemexit_on_signal)
|
|
1000
|
+
|
|
1001
|
+
pid_file.write_text(str(os.getpid()))
|
|
1002
|
+
try:
|
|
1003
|
+
from ctrlrelay.core.dispatcher import make_agent_dispatcher
|
|
1004
|
+
from ctrlrelay.core.github import GitHubCLI
|
|
1005
|
+
from ctrlrelay.core.poller import IssuePoller, run_poll_loop
|
|
1006
|
+
from ctrlrelay.core.scheduler import make_scheduler
|
|
1007
|
+
from ctrlrelay.core.state import StateDB
|
|
1008
|
+
from ctrlrelay.core.worktree import WorktreeManager
|
|
1009
|
+
from ctrlrelay.pipelines.dev import run_dev_issue
|
|
1010
|
+
from ctrlrelay.pipelines.post_merge import (
|
|
1011
|
+
DEFAULT_PR_WATCH_TIMEOUT,
|
|
1012
|
+
pr_watch_task,
|
|
1013
|
+
)
|
|
1014
|
+
from ctrlrelay.pipelines.secops import run_secops_all
|
|
1015
|
+
|
|
1016
|
+
# Build a DashboardClient if configured BEFORE the gh probe runs,
|
|
1017
|
+
# so even if gh fails the user gets clear failure ordering and so
|
|
1018
|
+
# tests can short-circuit at gh while still observing this wiring.
|
|
1019
|
+
# Mirrors what `ctrlrelay run secops` does for the manual path.
|
|
1020
|
+
scheduled_dashboard = None
|
|
1021
|
+
if config.dashboard.enabled and config.dashboard.url:
|
|
1022
|
+
token = os.environ.get(config.dashboard.auth_token_env, "")
|
|
1023
|
+
if token:
|
|
1024
|
+
from ctrlrelay.dashboard.client import DashboardClient
|
|
1025
|
+
scheduled_dashboard = DashboardClient(
|
|
1026
|
+
url=config.dashboard.url,
|
|
1027
|
+
auth_token=token,
|
|
1028
|
+
node_id=config.node_id,
|
|
1029
|
+
queue_dir=config.paths.state_db.parent / "event_queue",
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
github = GitHubCLI()
|
|
1033
|
+
|
|
1034
|
+
# Get GitHub username
|
|
1035
|
+
try:
|
|
1036
|
+
from ctrlrelay.core.github import _find_gh
|
|
1037
|
+
gh_bin = _find_gh()
|
|
1038
|
+
result = subprocess.run(
|
|
1039
|
+
[gh_bin, "api", "user", "--jq", ".login"],
|
|
1040
|
+
capture_output=True,
|
|
1041
|
+
text=True,
|
|
1042
|
+
check=True,
|
|
1043
|
+
# Without this a hung gh hangs `poller start` forever, and
|
|
1044
|
+
# the TimeoutExpired branch of the classifier — the one
|
|
1045
|
+
# case that really does mean connectivity — can never fire.
|
|
1046
|
+
timeout=_GH_PROBE_TIMEOUT_SECONDS,
|
|
1047
|
+
)
|
|
1048
|
+
username = result.stdout.strip()
|
|
1049
|
+
except (subprocess.SubprocessError, OSError) as e:
|
|
1050
|
+
# `gh` exits 1 for offline, expired-token and API-error alike,
|
|
1051
|
+
# so classify from its stderr — otherwise operators debug auth
|
|
1052
|
+
# when the laptop is simply offline (#32).
|
|
1053
|
+
from ctrlrelay.core.network import gh_failure_from_exception
|
|
1054
|
+
|
|
1055
|
+
failure = gh_failure_from_exception(e)
|
|
1056
|
+
color = "yellow" if failure.is_transient else "red"
|
|
1057
|
+
console.print(
|
|
1058
|
+
f"[{color}]Could not determine GitHub username:[/{color}] "
|
|
1059
|
+
f"{failure.message}"
|
|
1060
|
+
)
|
|
1061
|
+
if failure.stderr:
|
|
1062
|
+
# gh's stderr is arbitrary text and Rich parses [...] as
|
|
1063
|
+
# markup: a stderr containing something like "[/docs]"
|
|
1064
|
+
# raises MarkupError, which replaces the diagnostic with a
|
|
1065
|
+
# traceback and skips the exit — losing exactly the detail
|
|
1066
|
+
# this line exists to show.
|
|
1067
|
+
console.print(
|
|
1068
|
+
f"[dim]gh: {rich_escape(failure.stderr)}[/dim]"
|
|
1069
|
+
)
|
|
1070
|
+
raise typer.Exit(1)
|
|
1071
|
+
|
|
1072
|
+
if not username:
|
|
1073
|
+
console.print("[red]Could not determine GitHub username.[/red]")
|
|
1074
|
+
raise typer.Exit(1)
|
|
1075
|
+
|
|
1076
|
+
repo_names = [r.name for r in config.repos]
|
|
1077
|
+
if not repo_names:
|
|
1078
|
+
console.print("[yellow]No repos configured.[/yellow]")
|
|
1079
|
+
return
|
|
1080
|
+
|
|
1081
|
+
accept_foreign = {
|
|
1082
|
+
r.name for r in config.repos if r.automation.accept_foreign_assignments
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
state_file = config.paths.state_db.parent / "poller_state.json"
|
|
1086
|
+
|
|
1087
|
+
first_run = not state_file.exists()
|
|
1088
|
+
|
|
1089
|
+
exclude_labels_by_repo = {
|
|
1090
|
+
r.name: list(r.automation.exclude_labels) for r in config.repos
|
|
1091
|
+
}
|
|
1092
|
+
include_labels_by_repo = {
|
|
1093
|
+
r.name: list(r.automation.include_labels) for r in config.repos
|
|
1094
|
+
}
|
|
1095
|
+
require_labels_by_repo = {
|
|
1096
|
+
r.name: list(r.automation.require_labels) for r in config.repos
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
poller = IssuePoller(
|
|
1100
|
+
github=github,
|
|
1101
|
+
username=username,
|
|
1102
|
+
repos=repo_names,
|
|
1103
|
+
state_file=state_file,
|
|
1104
|
+
accept_foreign_assignments=accept_foreign,
|
|
1105
|
+
exclude_labels_by_repo=exclude_labels_by_repo,
|
|
1106
|
+
include_labels_by_repo=include_labels_by_repo,
|
|
1107
|
+
require_labels_by_repo=require_labels_by_repo,
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
# NOTE: first-run seeding moved into `_main()` so the APScheduler
|
|
1111
|
+
# cron is registered + running BEFORE the slow seed_current() pass
|
|
1112
|
+
# (one GitHub API call per repo) takes place. Otherwise the 6am
|
|
1113
|
+
# scheduled fire can pass during startup and APScheduler's misfire
|
|
1114
|
+
# grace only catches up on fires that happened AFTER registration.
|
|
1115
|
+
|
|
1116
|
+
state_db = StateDB(config.paths.state_db)
|
|
1117
|
+
dispatcher = make_agent_dispatcher(config.agent)
|
|
1118
|
+
worktree = WorktreeManager(
|
|
1119
|
+
worktrees_dir=config.paths.worktrees,
|
|
1120
|
+
bare_repos_dir=config.paths.bare_repos,
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
# Set up transport for notifications
|
|
1124
|
+
transport = None
|
|
1125
|
+
if config.transport.type.value == "telegram" and config.transport.telegram:
|
|
1126
|
+
from ctrlrelay.transports import SocketTransport
|
|
1127
|
+
socket_path = config.transport.telegram.socket_path.expanduser().resolve()
|
|
1128
|
+
if socket_path.exists():
|
|
1129
|
+
transport = SocketTransport(
|
|
1130
|
+
socket_path,
|
|
1131
|
+
ask_timeout_seconds=(
|
|
1132
|
+
config.transport.telegram.ask_timeout_seconds
|
|
1133
|
+
),
|
|
1134
|
+
)
|
|
1135
|
+
console.print(f"[dim]Telegram transport enabled via {socket_path}[/dim]")
|
|
1136
|
+
else:
|
|
1137
|
+
console.print(f"[yellow]Telegram socket not found at {socket_path}[/yellow]")
|
|
1138
|
+
console.print(
|
|
1139
|
+
"[yellow]Run 'ctrlrelay bridge start' to enable notifications[/yellow]"
|
|
1140
|
+
)
|
|
1141
|
+
|
|
1142
|
+
# Track in-flight PR-watch background tasks so they outlive
|
|
1143
|
+
# handle_issue and aren't garbage-collected. Cleared via
|
|
1144
|
+
# done_callback as each task terminates.
|
|
1145
|
+
pr_watch_tasks: set[asyncio.Task] = set()
|
|
1146
|
+
# Dedup key: only ONE watcher may run for a given (repo, pr_number)
|
|
1147
|
+
# at a time. Without this, two spawn paths (rehydration + a
|
|
1148
|
+
# handle_issue re-run after corrupted poller state) can race
|
|
1149
|
+
# through _handle_merge_with_retry and duplicate the close
|
|
1150
|
+
# comment / notification. Entries removed via done_callback
|
|
1151
|
+
# below, paired with the task's removal from pr_watch_tasks.
|
|
1152
|
+
active_pr_watches: set[tuple[str, int]] = set()
|
|
1153
|
+
|
|
1154
|
+
def _spawn_pr_watch(
|
|
1155
|
+
*,
|
|
1156
|
+
repo: str,
|
|
1157
|
+
issue_number: int,
|
|
1158
|
+
pr_url: str,
|
|
1159
|
+
pr_number: int,
|
|
1160
|
+
session_id: str | None,
|
|
1161
|
+
timeout: int | None = None,
|
|
1162
|
+
skip_initial_persist: bool = False,
|
|
1163
|
+
) -> asyncio.Task | None:
|
|
1164
|
+
"""Start a pr_watch_task for (repo, pr_number) unless one is
|
|
1165
|
+
already running for the same key. Returns the task on spawn,
|
|
1166
|
+
None if a duplicate was suppressed.
|
|
1167
|
+
|
|
1168
|
+
The pr_watches row is persisted SYNCHRONOUSLY before the
|
|
1169
|
+
coroutine is scheduled. asyncio.create_task only queues the
|
|
1170
|
+
coroutine — its body doesn't execute until the loop next
|
|
1171
|
+
yields, so a shutdown between create_task and the first
|
|
1172
|
+
await inside pr_watch_task would otherwise lose the row
|
|
1173
|
+
entirely (no rehydrate after restart). Writing first closes
|
|
1174
|
+
that gap.
|
|
1175
|
+
|
|
1176
|
+
``skip_initial_persist`` is for rehydration: the row
|
|
1177
|
+
already exists in pr_watches (that's how we know to spawn),
|
|
1178
|
+
and re-writing it would be harmless but wasteful.
|
|
1179
|
+
"""
|
|
1180
|
+
key = (repo, pr_number)
|
|
1181
|
+
if key in active_pr_watches:
|
|
1182
|
+
return None
|
|
1183
|
+
if state_db is not None and not skip_initial_persist:
|
|
1184
|
+
try:
|
|
1185
|
+
state_db.add_pr_watch(
|
|
1186
|
+
repo=repo, pr_number=pr_number,
|
|
1187
|
+
issue_number=issue_number,
|
|
1188
|
+
session_id=session_id,
|
|
1189
|
+
pr_url=pr_url,
|
|
1190
|
+
)
|
|
1191
|
+
except Exception:
|
|
1192
|
+
# Persist failure is logged inside pr_watch_task on
|
|
1193
|
+
# its own attempt; don't abort the spawn here, the
|
|
1194
|
+
# watcher can still run in-memory like the legacy
|
|
1195
|
+
# pre-persistence code did.
|
|
1196
|
+
pass
|
|
1197
|
+
active_pr_watches.add(key)
|
|
1198
|
+
kwargs: dict = dict(
|
|
1199
|
+
repo=repo,
|
|
1200
|
+
issue_number=issue_number,
|
|
1201
|
+
pr_url=pr_url,
|
|
1202
|
+
pr_number=pr_number,
|
|
1203
|
+
session_id=session_id,
|
|
1204
|
+
github=github,
|
|
1205
|
+
transport_factory=_watch_transport_factory,
|
|
1206
|
+
state_db=state_db,
|
|
1207
|
+
)
|
|
1208
|
+
if timeout is not None:
|
|
1209
|
+
kwargs["timeout"] = timeout
|
|
1210
|
+
task = asyncio.create_task(pr_watch_task(**kwargs))
|
|
1211
|
+
pr_watch_tasks.add(task)
|
|
1212
|
+
|
|
1213
|
+
def _on_done(t: asyncio.Task) -> None:
|
|
1214
|
+
pr_watch_tasks.discard(t)
|
|
1215
|
+
active_pr_watches.discard(key)
|
|
1216
|
+
|
|
1217
|
+
task.add_done_callback(_on_done)
|
|
1218
|
+
return task
|
|
1219
|
+
|
|
1220
|
+
async def _watch_transport_factory():
|
|
1221
|
+
"""Build a fresh connected SocketTransport for a single watcher,
|
|
1222
|
+
independent of the transport used in handle_issue (which is
|
|
1223
|
+
closed on exit).
|
|
1224
|
+
|
|
1225
|
+
Return None ONLY when Telegram notifications aren't
|
|
1226
|
+
configured at all — that's a legitimate "no channel" signal
|
|
1227
|
+
and the retry loop treats it as clean success. A configured-
|
|
1228
|
+
but-currently-missing socket is a transient outage (bridge
|
|
1229
|
+
restart, daemon crash mid-merge), so RAISE to trigger the
|
|
1230
|
+
retry path; the next attempt will reach the socket once the
|
|
1231
|
+
bridge is back.
|
|
1232
|
+
"""
|
|
1233
|
+
if config.transport.type.value != "telegram" or not config.transport.telegram:
|
|
1234
|
+
return None
|
|
1235
|
+
from ctrlrelay.transports import SocketTransport
|
|
1236
|
+
socket_path = config.transport.telegram.socket_path.expanduser().resolve()
|
|
1237
|
+
if not socket_path.exists():
|
|
1238
|
+
raise FileNotFoundError(
|
|
1239
|
+
f"Telegram bridge socket missing at {socket_path}; "
|
|
1240
|
+
"retryable — bridge may be restarting"
|
|
1241
|
+
)
|
|
1242
|
+
watch_transport = SocketTransport(
|
|
1243
|
+
socket_path,
|
|
1244
|
+
ask_timeout_seconds=(
|
|
1245
|
+
config.transport.telegram.ask_timeout_seconds
|
|
1246
|
+
),
|
|
1247
|
+
)
|
|
1248
|
+
await watch_transport.connect()
|
|
1249
|
+
return watch_transport
|
|
1250
|
+
|
|
1251
|
+
async def handle_issue(repo: str, issue: dict) -> None:
|
|
1252
|
+
issue_number = issue["number"]
|
|
1253
|
+
title = issue.get("title", "")
|
|
1254
|
+
console.print(
|
|
1255
|
+
f"[green]New issue detected:[/green] #{issue_number} in {repo} — {title}"
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
# Connect transport for notifications
|
|
1259
|
+
connected_transport = None
|
|
1260
|
+
if transport:
|
|
1261
|
+
try:
|
|
1262
|
+
await transport.connect()
|
|
1263
|
+
connected_transport = transport
|
|
1264
|
+
await transport.send(f"🔔 New issue #{issue_number} in {repo}: {title}")
|
|
1265
|
+
except Exception as e:
|
|
1266
|
+
console.print(f"[yellow]Transport error: {e}[/yellow]")
|
|
1267
|
+
|
|
1268
|
+
# Find matching repo config
|
|
1269
|
+
repo_configs = [r for r in config.repos if r.name == repo]
|
|
1270
|
+
if not repo_configs:
|
|
1271
|
+
console.print(f"[yellow]No config found for repo {repo}, skipping.[/yellow]")
|
|
1272
|
+
if connected_transport:
|
|
1273
|
+
await transport.close()
|
|
1274
|
+
return
|
|
1275
|
+
|
|
1276
|
+
repo_config = repo_configs[0]
|
|
1277
|
+
|
|
1278
|
+
# Route by label: task_labels → task pipeline (report-only,
|
|
1279
|
+
# no PR), otherwise dev pipeline. Case-insensitive match on
|
|
1280
|
+
# the issue's label names. exclude_labels was already
|
|
1281
|
+
# applied in poller.poll() so any issue reaching
|
|
1282
|
+
# handle_issue is a candidate.
|
|
1283
|
+
issue_labels = {
|
|
1284
|
+
(lbl.get("name") or "").lower()
|
|
1285
|
+
for lbl in (issue.get("labels") or [])
|
|
1286
|
+
}
|
|
1287
|
+
task_labels_lower = {
|
|
1288
|
+
lbl.lower() for lbl in repo_config.automation.task_labels
|
|
1289
|
+
}
|
|
1290
|
+
is_task = bool(issue_labels & task_labels_lower)
|
|
1291
|
+
|
|
1292
|
+
try:
|
|
1293
|
+
if is_task:
|
|
1294
|
+
from ctrlrelay.pipelines.task import run_task_issue
|
|
1295
|
+
console.print(
|
|
1296
|
+
f"[dim]#{issue_number} routed to task "
|
|
1297
|
+
f"pipeline (matched labels: "
|
|
1298
|
+
f"{sorted(issue_labels & task_labels_lower)})[/dim]"
|
|
1299
|
+
)
|
|
1300
|
+
result = await run_task_issue(
|
|
1301
|
+
repo=repo,
|
|
1302
|
+
issue_number=issue_number,
|
|
1303
|
+
dispatcher=dispatcher,
|
|
1304
|
+
github=github,
|
|
1305
|
+
worktree=worktree,
|
|
1306
|
+
dashboard=None,
|
|
1307
|
+
state_db=state_db,
|
|
1308
|
+
transport=connected_transport,
|
|
1309
|
+
contexts_dir=config.paths.contexts,
|
|
1310
|
+
)
|
|
1311
|
+
else:
|
|
1312
|
+
result = await run_dev_issue(
|
|
1313
|
+
repo=repo,
|
|
1314
|
+
issue_number=issue_number,
|
|
1315
|
+
branch_template=repo_config.dev_branch_template,
|
|
1316
|
+
dispatcher=dispatcher,
|
|
1317
|
+
github=github,
|
|
1318
|
+
worktree=worktree,
|
|
1319
|
+
dashboard=None,
|
|
1320
|
+
state_db=state_db,
|
|
1321
|
+
transport=connected_transport,
|
|
1322
|
+
contexts_dir=config.paths.contexts,
|
|
1323
|
+
ci_wait_timeout_seconds=repo_config.automation.ci_wait_timeout_seconds,
|
|
1324
|
+
)
|
|
1325
|
+
|
|
1326
|
+
# Lock-conflict retry hook. The poller marks issues seen
|
|
1327
|
+
# BEFORE handle_issue runs, so a failed attempt would
|
|
1328
|
+
# permanently drop the issue. If run_dev_issue couldn't
|
|
1329
|
+
# acquire the per-repo lock up front (common when a
|
|
1330
|
+
# scheduled secops sweep is mid-run on the same repo),
|
|
1331
|
+
# un-mark the issue so the next poll picks it up. Any
|
|
1332
|
+
# other failure still stays seen — those aren't transient.
|
|
1333
|
+
if (
|
|
1334
|
+
not result.success
|
|
1335
|
+
and not result.blocked
|
|
1336
|
+
and result.error
|
|
1337
|
+
and "locked by another session" in result.error.lower()
|
|
1338
|
+
):
|
|
1339
|
+
poller.unmark_seen(repo, issue_number)
|
|
1340
|
+
console.print(
|
|
1341
|
+
f"[yellow]#{issue_number} in {repo}: repo locked "
|
|
1342
|
+
"(secops running?) — un-marked for retry next "
|
|
1343
|
+
"poll.[/yellow]"
|
|
1344
|
+
)
|
|
1345
|
+
if connected_transport:
|
|
1346
|
+
try:
|
|
1347
|
+
await transport.send(
|
|
1348
|
+
f"⏳ #{issue_number} in {repo} "
|
|
1349
|
+
"deferred (repo busy); will retry."
|
|
1350
|
+
)
|
|
1351
|
+
except Exception:
|
|
1352
|
+
pass
|
|
1353
|
+
return
|
|
1354
|
+
|
|
1355
|
+
# Lock-contended DURING PR verification (issue #29): the
|
|
1356
|
+
# PR already exists AND verification decided it wasn't
|
|
1357
|
+
# ready (_verify_and_fix_pr only returns this error from
|
|
1358
|
+
# the "need to run request_fix but couldn't reacquire"
|
|
1359
|
+
# path). Two sub-cases based on whether run_dev_issue's
|
|
1360
|
+
# cleanup reacquire succeeded:
|
|
1361
|
+
#
|
|
1362
|
+
# (a) Cleanup clean (lock reclaimed, worktree torn down):
|
|
1363
|
+
# un-mark the issue so the next poll retries the fix.
|
|
1364
|
+
# run_dev_issue's branch-reuse path handles
|
|
1365
|
+
# "issue already has a branch / PR" (#51, #52).
|
|
1366
|
+
#
|
|
1367
|
+
# (b) Cleanup deferred (peer still holds lock; worktree
|
|
1368
|
+
# NOT removed): un-marking would make the next poll
|
|
1369
|
+
# call create_worktree_with_new_branch and fail
|
|
1370
|
+
# with "already checked out" because the abandoned
|
|
1371
|
+
# worktree metadata is still registered. Leave the
|
|
1372
|
+
# issue seen and log an operator-actionable warning;
|
|
1373
|
+
# a poller restart (or `git worktree prune`) will
|
|
1374
|
+
# clear the stale entry.
|
|
1375
|
+
#
|
|
1376
|
+
# Either way the merge watcher still spawns below so
|
|
1377
|
+
# the existing PR's auto-close works if a human merges.
|
|
1378
|
+
if (
|
|
1379
|
+
not result.success
|
|
1380
|
+
and not result.blocked
|
|
1381
|
+
and result.error
|
|
1382
|
+
and "reacquire contended during pr verification"
|
|
1383
|
+
in result.error.lower()
|
|
1384
|
+
):
|
|
1385
|
+
pr_num_str = result.outputs.get("pr_number", "?")
|
|
1386
|
+
cleanup_deferred = bool(
|
|
1387
|
+
result.outputs.get("cleanup_deferred")
|
|
1388
|
+
)
|
|
1389
|
+
if cleanup_deferred:
|
|
1390
|
+
console.print(
|
|
1391
|
+
f"[red]#{issue_number} in {repo}: lock "
|
|
1392
|
+
"contention during PR verification AND "
|
|
1393
|
+
"cleanup could not reclaim the lock after "
|
|
1394
|
+
"the full fix budget (~1 hour). A peer "
|
|
1395
|
+
"session is wedged. Operator action: "
|
|
1396
|
+
"identify the peer holding the lock on "
|
|
1397
|
+
f"repo_locks for {repo} (via `sqlite3 "
|
|
1398
|
+
"state.db 'SELECT * FROM repo_locks'`), "
|
|
1399
|
+
"investigate/kill it, then `git worktree "
|
|
1400
|
+
"remove --force` on the session's "
|
|
1401
|
+
"worktree directory before re-assigning "
|
|
1402
|
+
f"issue #{issue_number}. "
|
|
1403
|
+
f"PR #{pr_num_str} already exists on "
|
|
1404
|
+
"GitHub and is being watched for merge."
|
|
1405
|
+
"[/red]"
|
|
1406
|
+
)
|
|
1407
|
+
else:
|
|
1408
|
+
poller.unmark_seen(repo, issue_number)
|
|
1409
|
+
console.print(
|
|
1410
|
+
f"[yellow]#{issue_number} in {repo}: lock "
|
|
1411
|
+
"contention during PR verification — "
|
|
1412
|
+
f"un-marking for retry (PR #{pr_num_str} "
|
|
1413
|
+
"needs another fix round). Merge watcher "
|
|
1414
|
+
"still spawns.[/yellow]"
|
|
1415
|
+
)
|
|
1416
|
+
|
|
1417
|
+
# Spawn the PR watcher FIRST, before any best-effort
|
|
1418
|
+
# notification. The poller has already marked this issue
|
|
1419
|
+
# as seen in poller_state.json, so if a transient
|
|
1420
|
+
# transport.send failure below raised through the outer
|
|
1421
|
+
# finally, we'd permanently lose the watcher and the
|
|
1422
|
+
# issue would never auto-close on merge.
|
|
1423
|
+
pr_number_raw = result.outputs.get("pr_number")
|
|
1424
|
+
pr_url_str = result.outputs.get("pr_url", "")
|
|
1425
|
+
if pr_number_raw is not None:
|
|
1426
|
+
try:
|
|
1427
|
+
pr_number = int(pr_number_raw)
|
|
1428
|
+
except (TypeError, ValueError):
|
|
1429
|
+
pr_number = None
|
|
1430
|
+
if pr_number is not None:
|
|
1431
|
+
_spawn_pr_watch(
|
|
1432
|
+
repo=repo,
|
|
1433
|
+
issue_number=issue_number,
|
|
1434
|
+
pr_url=pr_url_str,
|
|
1435
|
+
pr_number=pr_number,
|
|
1436
|
+
session_id=result.session_id,
|
|
1437
|
+
)
|
|
1438
|
+
|
|
1439
|
+
# Send result notification — best-effort. A failed send
|
|
1440
|
+
# must NOT prevent the merge watcher (spawned above)
|
|
1441
|
+
# from running, so swallow transport errors here.
|
|
1442
|
+
if connected_transport:
|
|
1443
|
+
try:
|
|
1444
|
+
if result.success:
|
|
1445
|
+
if is_task:
|
|
1446
|
+
await transport.send(
|
|
1447
|
+
f"✅ Task done on #{issue_number} "
|
|
1448
|
+
f"({repo}): {result.summary}"
|
|
1449
|
+
)
|
|
1450
|
+
else:
|
|
1451
|
+
pr_url = result.outputs.get("pr_url", "")
|
|
1452
|
+
await transport.send(
|
|
1453
|
+
f"✅ PR ready: {pr_url}"
|
|
1454
|
+
)
|
|
1455
|
+
elif result.blocked:
|
|
1456
|
+
await transport.send(
|
|
1457
|
+
f"⏸️ Blocked on #{issue_number}: {result.question}"
|
|
1458
|
+
)
|
|
1459
|
+
else:
|
|
1460
|
+
await transport.send(
|
|
1461
|
+
f"❌ Failed on #{issue_number}: "
|
|
1462
|
+
f"{result.error or result.summary}"
|
|
1463
|
+
)
|
|
1464
|
+
except Exception as e:
|
|
1465
|
+
console.print(
|
|
1466
|
+
f"[yellow]Transport error sending result: {e}[/yellow]"
|
|
1467
|
+
)
|
|
1468
|
+
finally:
|
|
1469
|
+
if connected_transport:
|
|
1470
|
+
await transport.close()
|
|
1471
|
+
|
|
1472
|
+
console.print(f"[green]Starting poller[/green] for {len(repo_names)} repo(s) as {username}")
|
|
1473
|
+
console.print(f" Interval: {interval}s | Press Ctrl+C to stop")
|
|
1474
|
+
|
|
1475
|
+
async def _run_scheduled_secops() -> None:
|
|
1476
|
+
"""Scheduler callback: run the secops sweep across all repos.
|
|
1477
|
+
|
|
1478
|
+
Shares the poller's open state_db, github, dispatcher, and
|
|
1479
|
+
worktree. Per-repo locks in the state DB prevent collisions
|
|
1480
|
+
with an in-flight dev pipeline (and the dev handler now
|
|
1481
|
+
retries on lock-conflict so issues aren't silently dropped).
|
|
1482
|
+
|
|
1483
|
+
Builds a fresh SocketTransport per run so blocked/failed
|
|
1484
|
+
results notify Telegram — the dashboard only pushes for
|
|
1485
|
+
successful runs, so without a transport here operators would
|
|
1486
|
+
lose visibility into scheduled failures.
|
|
1487
|
+
"""
|
|
1488
|
+
if not config.repos:
|
|
1489
|
+
return
|
|
1490
|
+
n_repos = len(config.repos)
|
|
1491
|
+
console.print(
|
|
1492
|
+
f"[dim]Scheduled secops: starting across {n_repos} repo(s)[/dim]"
|
|
1493
|
+
)
|
|
1494
|
+
|
|
1495
|
+
secops_transport = None
|
|
1496
|
+
if config.transport.type.value == "telegram" and config.transport.telegram:
|
|
1497
|
+
from ctrlrelay.transports import SocketTransport
|
|
1498
|
+
sock = config.transport.telegram.socket_path.expanduser().resolve()
|
|
1499
|
+
if sock.exists():
|
|
1500
|
+
try:
|
|
1501
|
+
candidate = SocketTransport(
|
|
1502
|
+
sock,
|
|
1503
|
+
ask_timeout_seconds=(
|
|
1504
|
+
config.transport.telegram
|
|
1505
|
+
.ask_timeout_seconds
|
|
1506
|
+
),
|
|
1507
|
+
)
|
|
1508
|
+
await candidate.connect()
|
|
1509
|
+
secops_transport = candidate
|
|
1510
|
+
except Exception as e:
|
|
1511
|
+
console.print(
|
|
1512
|
+
f"[yellow]Scheduled secops: transport connect "
|
|
1513
|
+
f"failed ({e}) — running without notifications[/yellow]"
|
|
1514
|
+
)
|
|
1515
|
+
|
|
1516
|
+
# Tell the operator the sweep started so a long run isn't
|
|
1517
|
+
# silent. Without this, a 10-min sweep that ends with a
|
|
1518
|
+
# blocked-on-input result looks like "out of nowhere" pings.
|
|
1519
|
+
if secops_transport:
|
|
1520
|
+
try:
|
|
1521
|
+
await secops_transport.send(
|
|
1522
|
+
f"🔄 Scheduled secops: starting sweep across "
|
|
1523
|
+
f"{n_repos} repo(s)"
|
|
1524
|
+
)
|
|
1525
|
+
except Exception as e:
|
|
1526
|
+
console.print(
|
|
1527
|
+
f"[yellow]Scheduled secops: start-notify failed: "
|
|
1528
|
+
f"{e}[/yellow]"
|
|
1529
|
+
)
|
|
1530
|
+
|
|
1531
|
+
try:
|
|
1532
|
+
results = await run_secops_all(
|
|
1533
|
+
repos=config.repos,
|
|
1534
|
+
dispatcher=dispatcher,
|
|
1535
|
+
github=github,
|
|
1536
|
+
worktree=worktree,
|
|
1537
|
+
dashboard=scheduled_dashboard,
|
|
1538
|
+
state_db=state_db,
|
|
1539
|
+
transport=secops_transport,
|
|
1540
|
+
contexts_dir=config.paths.contexts,
|
|
1541
|
+
)
|
|
1542
|
+
ok = sum(1 for r in results if r.success)
|
|
1543
|
+
console.print(
|
|
1544
|
+
f"[dim]Scheduled secops: {ok}/{len(results)} succeeded[/dim]"
|
|
1545
|
+
)
|
|
1546
|
+
|
|
1547
|
+
# Per-repo notifications for the cases an operator must
|
|
1548
|
+
# act on. The aggregate "N blocked" message we used to
|
|
1549
|
+
# send was useless on its own — it didn't say which
|
|
1550
|
+
# repos blocked or what the question was. Fan out one
|
|
1551
|
+
# message per blocked or failed result with the actual
|
|
1552
|
+
# question/error and session id so the operator can
|
|
1553
|
+
# respond directly via the bridge.
|
|
1554
|
+
#
|
|
1555
|
+
# `run_secops_all` returns results in the same order as
|
|
1556
|
+
# the input `repos` list, so zip is safe — only repos
|
|
1557
|
+
# with successful lock-acquisition produce results.
|
|
1558
|
+
if secops_transport:
|
|
1559
|
+
try:
|
|
1560
|
+
for repo_cfg, result in zip(
|
|
1561
|
+
config.repos, results, strict=False
|
|
1562
|
+
):
|
|
1563
|
+
if result.blocked:
|
|
1564
|
+
question = (
|
|
1565
|
+
result.question or "(no question text)"
|
|
1566
|
+
)
|
|
1567
|
+
# session_id makes this message
|
|
1568
|
+
# reply-to-able: the bridge records it, so
|
|
1569
|
+
# answering the fan-out resolves the same
|
|
1570
|
+
# pending_resumes row as answering the
|
|
1571
|
+
# original question would have.
|
|
1572
|
+
await secops_transport.send(
|
|
1573
|
+
f"⏸️ Scheduled secops blocked on "
|
|
1574
|
+
f"{repo_cfg.name}\n"
|
|
1575
|
+
f"Session: {result.session_id}\n"
|
|
1576
|
+
f"\nReply to this message to answer."
|
|
1577
|
+
f"\n\n{question}",
|
|
1578
|
+
session_id=result.session_id,
|
|
1579
|
+
repo=repo_cfg.name,
|
|
1580
|
+
)
|
|
1581
|
+
elif not result.success:
|
|
1582
|
+
err = result.error or result.summary
|
|
1583
|
+
await secops_transport.send(
|
|
1584
|
+
f"❌ Scheduled secops failed on "
|
|
1585
|
+
f"{repo_cfg.name}\n"
|
|
1586
|
+
f"Session: `{result.session_id}`\n"
|
|
1587
|
+
f"\n{err}"
|
|
1588
|
+
)
|
|
1589
|
+
# Final at-a-glance summary — kept because it's
|
|
1590
|
+
# the single message the operator scans first.
|
|
1591
|
+
blocked_n = sum(1 for r in results if r.blocked)
|
|
1592
|
+
failed_n = sum(
|
|
1593
|
+
1 for r in results
|
|
1594
|
+
if not r.success and not r.blocked
|
|
1595
|
+
)
|
|
1596
|
+
if blocked_n or failed_n:
|
|
1597
|
+
parts = []
|
|
1598
|
+
if blocked_n:
|
|
1599
|
+
parts.append(f"{blocked_n} blocked")
|
|
1600
|
+
if failed_n:
|
|
1601
|
+
parts.append(f"{failed_n} failed")
|
|
1602
|
+
await secops_transport.send(
|
|
1603
|
+
f"📋 Scheduled secops sweep done: "
|
|
1604
|
+
f"{ok}/{len(results)} ok, "
|
|
1605
|
+
f"{', '.join(parts)}"
|
|
1606
|
+
)
|
|
1607
|
+
else:
|
|
1608
|
+
await secops_transport.send(
|
|
1609
|
+
f"✅ Scheduled secops sweep done: "
|
|
1610
|
+
f"{ok}/{len(results)} ok"
|
|
1611
|
+
)
|
|
1612
|
+
except Exception as e:
|
|
1613
|
+
console.print(
|
|
1614
|
+
f"[yellow]Scheduled secops: notify failed: {e}[/yellow]"
|
|
1615
|
+
)
|
|
1616
|
+
finally:
|
|
1617
|
+
if secops_transport:
|
|
1618
|
+
try:
|
|
1619
|
+
await secops_transport.close()
|
|
1620
|
+
except Exception:
|
|
1621
|
+
pass
|
|
1622
|
+
|
|
1623
|
+
async def _run_question_expiry_sweeper() -> None:
|
|
1624
|
+
"""Retire BLOCKED questions the operator can no longer
|
|
1625
|
+
usefully answer: aged past the TTL, or whose cited PRs and
|
|
1626
|
+
issues have all been closed or merged outside ctrlrelay.
|
|
1627
|
+
|
|
1628
|
+
Hourly, not per-minute: expiry is never urgent, and the
|
|
1629
|
+
resolved-elsewhere pass costs one `gh api` read per cited
|
|
1630
|
+
number. TTL runs first and needs no network, so a GitHub
|
|
1631
|
+
outage still lets the age-based sweep make progress.
|
|
1632
|
+
"""
|
|
1633
|
+
ttl = getattr(
|
|
1634
|
+
getattr(config.transport, "telegram", None),
|
|
1635
|
+
"question_ttl_seconds",
|
|
1636
|
+
None,
|
|
1637
|
+
)
|
|
1638
|
+
if not ttl:
|
|
1639
|
+
return
|
|
1640
|
+
try:
|
|
1641
|
+
expired = await expire_stale_questions(
|
|
1642
|
+
state_db, github, ttl_seconds=ttl,
|
|
1643
|
+
)
|
|
1644
|
+
except Exception as e:
|
|
1645
|
+
console.print(
|
|
1646
|
+
f"[yellow]question_expiry_sweeper: failed ({e})[/yellow]"
|
|
1647
|
+
)
|
|
1648
|
+
return
|
|
1649
|
+
for row in expired:
|
|
1650
|
+
console.print(
|
|
1651
|
+
f"[dim]question_expiry: retired "
|
|
1652
|
+
f"{row['session_id']} ({row['expired_reason']})[/dim]"
|
|
1653
|
+
)
|
|
1654
|
+
|
|
1655
|
+
async def _run_pending_resume_sweeper() -> None:
|
|
1656
|
+
"""Drain pending_resumes rows that an operator answered via
|
|
1657
|
+
Telegram while the original BLOCKED session had already torn
|
|
1658
|
+
down.
|
|
1659
|
+
|
|
1660
|
+
Runs every minute inside the poller. For each answered row
|
|
1661
|
+
it acquires the repo lock, re-creates a worktree, calls
|
|
1662
|
+
``SecopsPipeline.resume(ctx, answer)`` via the shared
|
|
1663
|
+
``resume_secops_from_pending`` helper, then marks the row
|
|
1664
|
+
resumed and fans out a Telegram notification with the result
|
|
1665
|
+
so the operator knows whether the resume landed.
|
|
1666
|
+
"""
|
|
1667
|
+
try:
|
|
1668
|
+
pending = state_db.list_pending_resumes_to_execute()
|
|
1669
|
+
except Exception as e:
|
|
1670
|
+
console.print(
|
|
1671
|
+
f"[yellow]pending_resume_sweeper: list failed ({e})"
|
|
1672
|
+
f"[/yellow]"
|
|
1673
|
+
)
|
|
1674
|
+
return
|
|
1675
|
+
if not pending:
|
|
1676
|
+
return
|
|
1677
|
+
|
|
1678
|
+
from ctrlrelay.pipelines.dev import resume_dev_from_pending
|
|
1679
|
+
from ctrlrelay.pipelines.secops import resume_secops_from_pending
|
|
1680
|
+
from ctrlrelay.pipelines.task import resume_task_from_pending
|
|
1681
|
+
|
|
1682
|
+
sweeper_transport = None
|
|
1683
|
+
if config.transport.type.value == "telegram" and config.transport.telegram:
|
|
1684
|
+
from ctrlrelay.transports import SocketTransport
|
|
1685
|
+
sock = config.transport.telegram.socket_path.expanduser().resolve()
|
|
1686
|
+
if sock.exists():
|
|
1687
|
+
try:
|
|
1688
|
+
candidate = SocketTransport(
|
|
1689
|
+
sock,
|
|
1690
|
+
ask_timeout_seconds=(
|
|
1691
|
+
config.transport.telegram
|
|
1692
|
+
.ask_timeout_seconds
|
|
1693
|
+
),
|
|
1694
|
+
)
|
|
1695
|
+
await candidate.connect()
|
|
1696
|
+
sweeper_transport = candidate
|
|
1697
|
+
except Exception:
|
|
1698
|
+
sweeper_transport = None
|
|
1699
|
+
|
|
1700
|
+
repo_cfg_by_name = {r.name: r for r in config.repos}
|
|
1701
|
+
|
|
1702
|
+
try:
|
|
1703
|
+
for row in pending:
|
|
1704
|
+
session_id = row["session_id"]
|
|
1705
|
+
repo = row["repo"]
|
|
1706
|
+
pipeline_name = row["pipeline"]
|
|
1707
|
+
answer = row["answer"] or ""
|
|
1708
|
+
|
|
1709
|
+
if sweeper_transport:
|
|
1710
|
+
try:
|
|
1711
|
+
await sweeper_transport.send(
|
|
1712
|
+
f"🔁 Resuming BLOCKED session "
|
|
1713
|
+
f"`{session_id}` on {repo} with your "
|
|
1714
|
+
f"answer..."
|
|
1715
|
+
)
|
|
1716
|
+
except Exception:
|
|
1717
|
+
pass
|
|
1718
|
+
|
|
1719
|
+
try:
|
|
1720
|
+
if pipeline_name == "secops":
|
|
1721
|
+
sweep_repo_cfg = repo_cfg_by_name.get(repo)
|
|
1722
|
+
result = await resume_secops_from_pending(
|
|
1723
|
+
session_id=session_id,
|
|
1724
|
+
repo=repo,
|
|
1725
|
+
answer=answer,
|
|
1726
|
+
dispatcher=dispatcher,
|
|
1727
|
+
github=github,
|
|
1728
|
+
worktree=worktree,
|
|
1729
|
+
dashboard=scheduled_dashboard,
|
|
1730
|
+
state_db=state_db,
|
|
1731
|
+
transport=sweeper_transport,
|
|
1732
|
+
contexts_dir=config.paths.contexts,
|
|
1733
|
+
automation=(
|
|
1734
|
+
sweep_repo_cfg.automation
|
|
1735
|
+
if sweep_repo_cfg is not None
|
|
1736
|
+
else None
|
|
1737
|
+
),
|
|
1738
|
+
question=row.get("question"),
|
|
1739
|
+
)
|
|
1740
|
+
elif pipeline_name == "dev":
|
|
1741
|
+
# Dev resume needs the repo's branch template
|
|
1742
|
+
# to reconstruct the session's existing branch
|
|
1743
|
+
# name (fix/issue-<n> by default). If the
|
|
1744
|
+
# repo fell out of config (rename, removal),
|
|
1745
|
+
# we can't safely resume — mark resumed so
|
|
1746
|
+
# the row doesn't hot-loop and log it.
|
|
1747
|
+
repo_cfg = repo_cfg_by_name.get(repo)
|
|
1748
|
+
if repo_cfg is None:
|
|
1749
|
+
console.print(
|
|
1750
|
+
"[yellow]pending_resume_sweeper: "
|
|
1751
|
+
f"dev resume for {repo} skipped — "
|
|
1752
|
+
"repo not in current config. "
|
|
1753
|
+
f"Session={session_id}[/yellow]"
|
|
1754
|
+
)
|
|
1755
|
+
try:
|
|
1756
|
+
state_db.mark_pending_resume_resumed(
|
|
1757
|
+
session_id
|
|
1758
|
+
)
|
|
1759
|
+
except Exception:
|
|
1760
|
+
pass
|
|
1761
|
+
continue
|
|
1762
|
+
result = await resume_dev_from_pending(
|
|
1763
|
+
session_id=session_id,
|
|
1764
|
+
repo=repo,
|
|
1765
|
+
answer=answer,
|
|
1766
|
+
branch_template=repo_cfg.dev_branch_template,
|
|
1767
|
+
dispatcher=dispatcher,
|
|
1768
|
+
github=github,
|
|
1769
|
+
worktree=worktree,
|
|
1770
|
+
dashboard=scheduled_dashboard,
|
|
1771
|
+
state_db=state_db,
|
|
1772
|
+
transport=sweeper_transport,
|
|
1773
|
+
contexts_dir=config.paths.contexts,
|
|
1774
|
+
)
|
|
1775
|
+
elif pipeline_name == "task":
|
|
1776
|
+
result = await resume_task_from_pending(
|
|
1777
|
+
session_id=session_id,
|
|
1778
|
+
repo=repo,
|
|
1779
|
+
answer=answer,
|
|
1780
|
+
dispatcher=dispatcher,
|
|
1781
|
+
github=github,
|
|
1782
|
+
worktree=worktree,
|
|
1783
|
+
dashboard=scheduled_dashboard,
|
|
1784
|
+
state_db=state_db,
|
|
1785
|
+
transport=sweeper_transport,
|
|
1786
|
+
contexts_dir=config.paths.contexts,
|
|
1787
|
+
)
|
|
1788
|
+
else:
|
|
1789
|
+
console.print(
|
|
1790
|
+
"[yellow]pending_resume_sweeper: "
|
|
1791
|
+
f"unknown pipeline '{pipeline_name}' "
|
|
1792
|
+
f"for session {session_id} — marking "
|
|
1793
|
+
"resumed to avoid hot-loop[/yellow]"
|
|
1794
|
+
)
|
|
1795
|
+
try:
|
|
1796
|
+
state_db.mark_pending_resume_resumed(
|
|
1797
|
+
session_id
|
|
1798
|
+
)
|
|
1799
|
+
except Exception:
|
|
1800
|
+
pass
|
|
1801
|
+
continue
|
|
1802
|
+
except Exception as e:
|
|
1803
|
+
if sweeper_transport:
|
|
1804
|
+
try:
|
|
1805
|
+
await sweeper_transport.send(
|
|
1806
|
+
f"❌ Resume of `{session_id}` on "
|
|
1807
|
+
f"{repo} crashed: {e}"
|
|
1808
|
+
)
|
|
1809
|
+
except Exception:
|
|
1810
|
+
pass
|
|
1811
|
+
# Mark resumed so the sweeper doesn't hot-loop the
|
|
1812
|
+
# same broken row. Operator can inspect via the
|
|
1813
|
+
# sessions table.
|
|
1814
|
+
try:
|
|
1815
|
+
state_db.mark_pending_resume_resumed(session_id)
|
|
1816
|
+
except Exception:
|
|
1817
|
+
pass
|
|
1818
|
+
continue
|
|
1819
|
+
|
|
1820
|
+
# Lock-contention at the INITIAL acquire step is
|
|
1821
|
+
# retryable: the 6am secops cron or an in-flight
|
|
1822
|
+
# dev session holds the repo lock, we never got
|
|
1823
|
+
# to pipeline.resume so the operator's answer is
|
|
1824
|
+
# still intact. Leave the pending_resumes row and
|
|
1825
|
+
# let the next sweeper tick try again.
|
|
1826
|
+
if not result.success and result.error == (
|
|
1827
|
+
"Repository locked by another session"
|
|
1828
|
+
):
|
|
1829
|
+
console.print(
|
|
1830
|
+
"[dim]pending_resume_sweeper: "
|
|
1831
|
+
f"lock contention on {repo}, will retry "
|
|
1832
|
+
f"next tick (session={session_id})[/dim]"
|
|
1833
|
+
)
|
|
1834
|
+
continue
|
|
1835
|
+
|
|
1836
|
+
# The OTHER lock-contended error fires only AFTER
|
|
1837
|
+
# pipeline.resume consumed the operator's answer
|
|
1838
|
+
# and advanced the agent session to DONE: replaying
|
|
1839
|
+
# the same answer would double-apply side effects
|
|
1840
|
+
# on the PR or fail because the agent session is
|
|
1841
|
+
# already finalized. BUT just marking the row
|
|
1842
|
+
# consumed strands the workflow — the PR needs
|
|
1843
|
+
# another fix round and nothing would trigger it.
|
|
1844
|
+
# Re-insert a fresh pending_resumes row with a
|
|
1845
|
+
# synthetic prompt so a new operator reply can
|
|
1846
|
+
# attach via the bridge and drive the next round.
|
|
1847
|
+
if not result.success and result.error == (
|
|
1848
|
+
"Repo lock reacquire contended during PR verification"
|
|
1849
|
+
):
|
|
1850
|
+
pr_num = result.outputs.get("pr_number", "?")
|
|
1851
|
+
synthetic_question = (
|
|
1852
|
+
f"Post-verify lock contention on {repo} "
|
|
1853
|
+
f"while resuming session {session_id} "
|
|
1854
|
+
f"(PR #{pr_num}). The prior answer was "
|
|
1855
|
+
"consumed; reply with new guidance to "
|
|
1856
|
+
"retry the fix round once the peer "
|
|
1857
|
+
"session releases."
|
|
1858
|
+
)
|
|
1859
|
+
try:
|
|
1860
|
+
state_db.add_pending_resume(
|
|
1861
|
+
session_id=session_id,
|
|
1862
|
+
pipeline=pipeline_name,
|
|
1863
|
+
repo=repo,
|
|
1864
|
+
question=synthetic_question,
|
|
1865
|
+
)
|
|
1866
|
+
except Exception:
|
|
1867
|
+
pass
|
|
1868
|
+
console.print(
|
|
1869
|
+
f"[yellow]pending_resume_sweeper: "
|
|
1870
|
+
f"verify contention on {repo} (session="
|
|
1871
|
+
f"{session_id}); prior answer consumed, "
|
|
1872
|
+
"fresh pending_resumes row inserted so a "
|
|
1873
|
+
"new operator reply can drive the retry."
|
|
1874
|
+
"[/yellow]"
|
|
1875
|
+
)
|
|
1876
|
+
continue
|
|
1877
|
+
|
|
1878
|
+
try:
|
|
1879
|
+
state_db.mark_pending_resume_resumed(session_id)
|
|
1880
|
+
except Exception:
|
|
1881
|
+
pass
|
|
1882
|
+
|
|
1883
|
+
if sweeper_transport:
|
|
1884
|
+
try:
|
|
1885
|
+
if result.success:
|
|
1886
|
+
await sweeper_transport.send(
|
|
1887
|
+
f"✅ Resume succeeded on {repo}\n"
|
|
1888
|
+
f"Session: `{session_id}`\n"
|
|
1889
|
+
f"\n{result.summary}"
|
|
1890
|
+
)
|
|
1891
|
+
elif result.blocked:
|
|
1892
|
+
q = result.question or "(no question text)"
|
|
1893
|
+
await sweeper_transport.send(
|
|
1894
|
+
f"⏸️ Resume re-blocked on {repo}\n"
|
|
1895
|
+
f"Session: `{session_id}`\n"
|
|
1896
|
+
f"\n{q}"
|
|
1897
|
+
)
|
|
1898
|
+
else:
|
|
1899
|
+
err = result.error or result.summary
|
|
1900
|
+
await sweeper_transport.send(
|
|
1901
|
+
f"❌ Resume failed on {repo}\n"
|
|
1902
|
+
f"Session: `{session_id}`\n"
|
|
1903
|
+
f"\n{err}"
|
|
1904
|
+
)
|
|
1905
|
+
except Exception:
|
|
1906
|
+
pass
|
|
1907
|
+
finally:
|
|
1908
|
+
if sweeper_transport:
|
|
1909
|
+
try:
|
|
1910
|
+
await sweeper_transport.close()
|
|
1911
|
+
except Exception:
|
|
1912
|
+
pass
|
|
1913
|
+
|
|
1914
|
+
async def _main() -> None:
|
|
1915
|
+
async def _run_scheduled_personalization_pull() -> None:
|
|
1916
|
+
"""Cron-driven auto-pull of the personalization repo.
|
|
1917
|
+
|
|
1918
|
+
Runs at ``schedules.personalization_cron``. Skips when
|
|
1919
|
+
the working tree is dirty so unsaved operator edits
|
|
1920
|
+
aren't disturbed by the rebase. Conflicts during the
|
|
1921
|
+
rebase abort and are logged; operator must intervene
|
|
1922
|
+
via ``ctrlrelay personalization pull`` to surface the
|
|
1923
|
+
conflict files. Auto-push is intentionally NOT done
|
|
1924
|
+
here — a daemon committing on the operator's behalf
|
|
1925
|
+
without explicit intent is the kind of thing that
|
|
1926
|
+
surprises people; commits stay manual.
|
|
1927
|
+
|
|
1928
|
+
``auto_pull`` runs synchronous ``git fetch``/``rebase``
|
|
1929
|
+
subprocesses; dispatch to a worker thread so a slow
|
|
1930
|
+
or stuck remote can't stall the poller's event loop
|
|
1931
|
+
(Telegram dispatch, pending-resume sweeper, secops
|
|
1932
|
+
cron). Codex pass 3 caught this.
|
|
1933
|
+
"""
|
|
1934
|
+
if config.personalization is None:
|
|
1935
|
+
return
|
|
1936
|
+
import asyncio
|
|
1937
|
+
|
|
1938
|
+
from ctrlrelay.personalization import PersonalizationManager
|
|
1939
|
+
from ctrlrelay.personalization.manager import (
|
|
1940
|
+
PersonalizationError,
|
|
1941
|
+
)
|
|
1942
|
+
|
|
1943
|
+
try:
|
|
1944
|
+
mgr = PersonalizationManager(config)
|
|
1945
|
+
result = await asyncio.to_thread(mgr.auto_pull)
|
|
1946
|
+
except PersonalizationError as e:
|
|
1947
|
+
console.print(
|
|
1948
|
+
f"[yellow]Auto-pull failed: {e}[/yellow]"
|
|
1949
|
+
)
|
|
1950
|
+
return
|
|
1951
|
+
if result.success:
|
|
1952
|
+
console.print(f"[dim]Auto-pull: {result.summary}[/dim]")
|
|
1953
|
+
else:
|
|
1954
|
+
console.print(
|
|
1955
|
+
f"[yellow]Auto-pull: {result.summary}[/yellow]"
|
|
1956
|
+
)
|
|
1957
|
+
if result.conflict_files:
|
|
1958
|
+
for f in result.conflict_files:
|
|
1959
|
+
console.print(f"[yellow] conflict: {f}[/yellow]")
|
|
1960
|
+
|
|
1961
|
+
# Register + start the scheduler FIRST, before any potentially
|
|
1962
|
+
# slow startup work. Otherwise a 6am fire that lands during
|
|
1963
|
+
# seed_current's per-repo GitHub calls would be lost —
|
|
1964
|
+
# APScheduler's misfire_grace_time only rescues fires that
|
|
1965
|
+
# happened AFTER the job was registered.
|
|
1966
|
+
scheduler = make_scheduler(timezone=config.timezone)
|
|
1967
|
+
scheduler.add_cron_job(
|
|
1968
|
+
name="secops",
|
|
1969
|
+
cron_expr=config.schedules.secops_cron,
|
|
1970
|
+
func=_run_scheduled_secops,
|
|
1971
|
+
)
|
|
1972
|
+
# Drain answered pending_resumes every minute so a Telegram
|
|
1973
|
+
# reply to a BLOCKED session turns into an actual pipeline
|
|
1974
|
+
# resume within ~60s, not 24h (the next scheduled secops
|
|
1975
|
+
# cron). Cheap: no-ops when the pending_resumes table is empty.
|
|
1976
|
+
scheduler.add_cron_job(
|
|
1977
|
+
name="pending_resume_sweeper",
|
|
1978
|
+
cron_expr="* * * * *",
|
|
1979
|
+
func=_run_pending_resume_sweeper,
|
|
1980
|
+
)
|
|
1981
|
+
# Retire questions nobody can act on any more. Without
|
|
1982
|
+
# this the pending_resumes table only grows: unanswered
|
|
1983
|
+
# rows stay live targets for orphan reply routing and get
|
|
1984
|
+
# re-posted every sweep, including ones whose PR was
|
|
1985
|
+
# merged by hand days earlier.
|
|
1986
|
+
scheduler.add_cron_job(
|
|
1987
|
+
name="question_expiry_sweeper",
|
|
1988
|
+
cron_expr="0 * * * *",
|
|
1989
|
+
func=_run_question_expiry_sweeper,
|
|
1990
|
+
)
|
|
1991
|
+
# Register the personalization auto-pull only when the
|
|
1992
|
+
# operator has both configured the personalization block
|
|
1993
|
+
# AND set a cron expression. Either alone is intentional
|
|
1994
|
+
# (config without cron = manual sync only; cron without
|
|
1995
|
+
# config = invalid in load_config).
|
|
1996
|
+
personalization_cron_summary = ""
|
|
1997
|
+
if (
|
|
1998
|
+
config.personalization is not None
|
|
1999
|
+
and config.schedules.personalization_cron is not None
|
|
2000
|
+
):
|
|
2001
|
+
scheduler.add_cron_job(
|
|
2002
|
+
name="personalization_auto_pull",
|
|
2003
|
+
cron_expr=config.schedules.personalization_cron,
|
|
2004
|
+
func=_run_scheduled_personalization_pull,
|
|
2005
|
+
)
|
|
2006
|
+
personalization_cron_summary = (
|
|
2007
|
+
f" | personalization_auto_pull cron="
|
|
2008
|
+
f"{config.schedules.personalization_cron}"
|
|
2009
|
+
)
|
|
2010
|
+
scheduler.start()
|
|
2011
|
+
console.print(
|
|
2012
|
+
f"[dim]Scheduler: secops cron={config.schedules.secops_cron} "
|
|
2013
|
+
f"tz={config.timezone} | "
|
|
2014
|
+
f"pending_resume_sweeper=every 1m | "
|
|
2015
|
+
f"question_expiry_sweeper=hourly"
|
|
2016
|
+
f"{personalization_cron_summary}[/dim]"
|
|
2017
|
+
)
|
|
2018
|
+
|
|
2019
|
+
# Now the slow startup: first-run seeding (one gh call per
|
|
2020
|
+
# repo). Done inside _main so the scheduler is already up.
|
|
2021
|
+
if first_run:
|
|
2022
|
+
console.print(
|
|
2023
|
+
"[dim]First run: seeding with current assignments..."
|
|
2024
|
+
"[/dim]"
|
|
2025
|
+
)
|
|
2026
|
+
await poller.seed_current()
|
|
2027
|
+
|
|
2028
|
+
# Rehydrate PR watchers that survived the previous shutdown.
|
|
2029
|
+
# A launchd kickstart / crash / redeploy cancels in-memory
|
|
2030
|
+
# asyncio tasks; without this step, any PR sitting in review
|
|
2031
|
+
# across a restart would silently lose its post-merge
|
|
2032
|
+
# automation for the remainder of its 7-day watch window.
|
|
2033
|
+
# Runs BEFORE run_poll_loop so watchers are live before the
|
|
2034
|
+
# first poll interval elapses.
|
|
2035
|
+
try:
|
|
2036
|
+
surviving = state_db.list_pr_watches()
|
|
2037
|
+
except Exception as e:
|
|
2038
|
+
console.print(
|
|
2039
|
+
f"[yellow]pr_watches rehydrate: list failed ({e}) — "
|
|
2040
|
+
"skipping[/yellow]"
|
|
2041
|
+
)
|
|
2042
|
+
surviving = []
|
|
2043
|
+
if surviving:
|
|
2044
|
+
import time as _time
|
|
2045
|
+
# Respect the operator's current config: if a repo was
|
|
2046
|
+
# removed from `repos:`, skip respawning its watchers
|
|
2047
|
+
# even if old rows linger in pr_watches. Treating the
|
|
2048
|
+
# config as the source of truth matches how the rest
|
|
2049
|
+
# of the poller behaves; silently driving close/notify
|
|
2050
|
+
# on a now-unconfigured repo would be surprising.
|
|
2051
|
+
configured_repos = {r.name for r in config.repos}
|
|
2052
|
+
orphaned = [
|
|
2053
|
+
r for r in surviving if r["repo"] not in configured_repos
|
|
2054
|
+
]
|
|
2055
|
+
if orphaned:
|
|
2056
|
+
console.print(
|
|
2057
|
+
f"[yellow]pr_watches: {len(orphaned)} row(s) "
|
|
2058
|
+
"for repos no longer in config — leaving "
|
|
2059
|
+
"dormant[/yellow]"
|
|
2060
|
+
)
|
|
2061
|
+
active = [
|
|
2062
|
+
r for r in surviving if r["repo"] in configured_repos
|
|
2063
|
+
]
|
|
2064
|
+
console.print(
|
|
2065
|
+
f"[dim]Rehydrating {len(active)} PR watcher(s) "
|
|
2066
|
+
"from state.db[/dim]"
|
|
2067
|
+
)
|
|
2068
|
+
now = int(_time.time())
|
|
2069
|
+
# Always spawn with enough headroom for SEVERAL poll
|
|
2070
|
+
# attempts even on rows past the 7-day window. A
|
|
2071
|
+
# single-poll window (grace == poll_interval) would
|
|
2072
|
+
# expire the instant the first gh call has a transient
|
|
2073
|
+
# failure, logging watch_timeout and permanently
|
|
2074
|
+
# dropping the row. 5 minutes against the default 60s
|
|
2075
|
+
# poll_interval lets wait_for_merge retry through a
|
|
2076
|
+
# handful of hiccups and still covers a PR that merges
|
|
2077
|
+
# right after the restart. Abandoned-row cost is 5 min
|
|
2078
|
+
# of extra polling, which is negligible.
|
|
2079
|
+
# (Rehydrated rows with cleanup_phase set bypass
|
|
2080
|
+
# wait_for_merge entirely — see pr_watch_task — so
|
|
2081
|
+
# this headroom only affects phase=NULL rehydrates.)
|
|
2082
|
+
rehydrate_min_timeout = 300
|
|
2083
|
+
for row in active:
|
|
2084
|
+
# Honor the original watch deadline: subtract time
|
|
2085
|
+
# already spent watching from the default 7-day
|
|
2086
|
+
# timeout. Without this, every restart resets the
|
|
2087
|
+
# clock and abandoned PRs never time out.
|
|
2088
|
+
elapsed = max(0, now - int(row["started_at"]))
|
|
2089
|
+
remaining = max(
|
|
2090
|
+
DEFAULT_PR_WATCH_TIMEOUT - elapsed,
|
|
2091
|
+
rehydrate_min_timeout,
|
|
2092
|
+
)
|
|
2093
|
+
_spawn_pr_watch(
|
|
2094
|
+
repo=row["repo"],
|
|
2095
|
+
issue_number=row["issue_number"],
|
|
2096
|
+
pr_url=row["pr_url"] or "",
|
|
2097
|
+
pr_number=row["pr_number"],
|
|
2098
|
+
session_id=row.get("session_id"),
|
|
2099
|
+
timeout=remaining,
|
|
2100
|
+
skip_initial_persist=True,
|
|
2101
|
+
)
|
|
2102
|
+
|
|
2103
|
+
try:
|
|
2104
|
+
await run_poll_loop(
|
|
2105
|
+
poller=poller, handler=handle_issue, interval=interval,
|
|
2106
|
+
)
|
|
2107
|
+
finally:
|
|
2108
|
+
# Scheduler shutdown is async so it can cancel + await any
|
|
2109
|
+
# in-flight job tasks (e.g. a scheduled secops sweep that was
|
|
2110
|
+
# running when SIGTERM arrived). Without awaiting here the
|
|
2111
|
+
# loop closes before jobs finalize — state_db locks stay
|
|
2112
|
+
# held and worktrees stay dirty.
|
|
2113
|
+
await scheduler.shutdown()
|
|
2114
|
+
# Cancel any in-flight PR watchers so a poller stop/restart
|
|
2115
|
+
# doesn't leak asyncio tasks. Their handlers log
|
|
2116
|
+
# dev.pr.watch_cancelled and close their transport via the
|
|
2117
|
+
# finally block.
|
|
2118
|
+
if pr_watch_tasks:
|
|
2119
|
+
for t in list(pr_watch_tasks):
|
|
2120
|
+
t.cancel()
|
|
2121
|
+
await asyncio.gather(*list(pr_watch_tasks), return_exceptions=True)
|
|
2122
|
+
|
|
2123
|
+
loop = asyncio.new_event_loop()
|
|
2124
|
+
try:
|
|
2125
|
+
asyncio.set_event_loop(loop)
|
|
2126
|
+
main_task = loop.create_task(_main())
|
|
2127
|
+
|
|
2128
|
+
def _handle_stop(sig: int) -> None:
|
|
2129
|
+
main_task.cancel()
|
|
2130
|
+
|
|
2131
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
2132
|
+
loop.add_signal_handler(sig, _handle_stop, sig)
|
|
2133
|
+
|
|
2134
|
+
try:
|
|
2135
|
+
loop.run_until_complete(main_task)
|
|
2136
|
+
except asyncio.CancelledError:
|
|
2137
|
+
console.print("\n[yellow]Poller stopped.[/yellow]")
|
|
2138
|
+
finally:
|
|
2139
|
+
loop.close()
|
|
2140
|
+
state_db.close()
|
|
2141
|
+
finally:
|
|
2142
|
+
pid_file.unlink(missing_ok=True)
|
|
2143
|
+
|
|
2144
|
+
|
|
2145
|
+
@poller_app.command("stop")
|
|
2146
|
+
def poller_stop(
|
|
2147
|
+
config_path: str | None = typer.Option(
|
|
2148
|
+
None,
|
|
2149
|
+
"--config",
|
|
2150
|
+
"-c",
|
|
2151
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
2152
|
+
),
|
|
2153
|
+
) -> None:
|
|
2154
|
+
"""Stop the issue poller."""
|
|
2155
|
+
import os
|
|
2156
|
+
import signal
|
|
2157
|
+
|
|
2158
|
+
pid_file = _get_poller_pid_file(config_path)
|
|
2159
|
+
|
|
2160
|
+
if not pid_file.exists():
|
|
2161
|
+
console.print("[yellow]Poller not running (no PID file)[/yellow]")
|
|
2162
|
+
return
|
|
2163
|
+
|
|
2164
|
+
try:
|
|
2165
|
+
pid = int(pid_file.read_text().strip())
|
|
2166
|
+
os.kill(pid, signal.SIGTERM)
|
|
2167
|
+
console.print(f"[green]Stopped poller (PID {pid})[/green]")
|
|
2168
|
+
pid_file.unlink(missing_ok=True)
|
|
2169
|
+
except ProcessLookupError:
|
|
2170
|
+
console.print("[yellow]Poller process not found[/yellow]")
|
|
2171
|
+
pid_file.unlink(missing_ok=True)
|
|
2172
|
+
except ValueError:
|
|
2173
|
+
console.print("[red]Invalid PID file[/red]")
|
|
2174
|
+
pid_file.unlink(missing_ok=True)
|
|
2175
|
+
|
|
2176
|
+
|
|
2177
|
+
@poller_app.command("status")
|
|
2178
|
+
def poller_status(
|
|
2179
|
+
config_path: str | None = typer.Option(
|
|
2180
|
+
None,
|
|
2181
|
+
"--config",
|
|
2182
|
+
"-c",
|
|
2183
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
2184
|
+
),
|
|
2185
|
+
) -> None:
|
|
2186
|
+
"""Check poller status."""
|
|
2187
|
+
import os
|
|
2188
|
+
|
|
2189
|
+
pid_file = _get_poller_pid_file(config_path)
|
|
2190
|
+
|
|
2191
|
+
if pid_file.exists():
|
|
2192
|
+
try:
|
|
2193
|
+
pid = int(pid_file.read_text().strip())
|
|
2194
|
+
os.kill(pid, 0)
|
|
2195
|
+
console.print(f"[green]Poller running (PID {pid})[/green]")
|
|
2196
|
+
return
|
|
2197
|
+
except (ProcessLookupError, ValueError):
|
|
2198
|
+
pass
|
|
2199
|
+
|
|
2200
|
+
console.print("[dim]Poller not running[/dim]")
|
|
2201
|
+
raise typer.Exit(1)
|
|
2202
|
+
|
|
2203
|
+
|
|
2204
|
+
personalization_app = typer.Typer(help="Personalization sync (cross-machine operator state).")
|
|
2205
|
+
app.add_typer(personalization_app, name="personalization")
|
|
2206
|
+
|
|
2207
|
+
|
|
2208
|
+
def _load_config_or_exit(config_path: str | None):
|
|
2209
|
+
"""Resolve --config and load it, mapping ConfigError to a typer.Exit."""
|
|
2210
|
+
path = _resolve_config_or_exit(config_path)
|
|
2211
|
+
try:
|
|
2212
|
+
return load_config(path)
|
|
2213
|
+
except ConfigError as e:
|
|
2214
|
+
console.print(f"[red]Error loading config:[/red] {e}")
|
|
2215
|
+
raise typer.Exit(1)
|
|
2216
|
+
|
|
2217
|
+
|
|
2218
|
+
def _make_personalization_manager(config_path: str | None):
|
|
2219
|
+
"""Return a configured PersonalizationManager or exit with a helpful error."""
|
|
2220
|
+
from ctrlrelay.personalization import PersonalizationManager
|
|
2221
|
+
from ctrlrelay.personalization.manager import PersonalizationError
|
|
2222
|
+
|
|
2223
|
+
config = _load_config_or_exit(config_path)
|
|
2224
|
+
if config.personalization is None:
|
|
2225
|
+
console.print(
|
|
2226
|
+
"[red]Error:[/red] No personalization config in orchestrator.yaml. "
|
|
2227
|
+
"Add a `personalization:` section (see config/orchestrator.yaml.example) "
|
|
2228
|
+
"and re-run."
|
|
2229
|
+
)
|
|
2230
|
+
raise typer.Exit(1)
|
|
2231
|
+
try:
|
|
2232
|
+
return PersonalizationManager(config)
|
|
2233
|
+
except PersonalizationError as e:
|
|
2234
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
2235
|
+
raise typer.Exit(1)
|
|
2236
|
+
|
|
2237
|
+
|
|
2238
|
+
@personalization_app.command("init")
|
|
2239
|
+
def personalization_init(
|
|
2240
|
+
config_path: str | None = typer.Option(
|
|
2241
|
+
None,
|
|
2242
|
+
"--config",
|
|
2243
|
+
"-c",
|
|
2244
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
2245
|
+
),
|
|
2246
|
+
no_adopt: bool = typer.Option(
|
|
2247
|
+
False,
|
|
2248
|
+
"--no-adopt",
|
|
2249
|
+
help=(
|
|
2250
|
+
"Refuse to adopt pre-existing real files at target paths. "
|
|
2251
|
+
"Default: adopt (move target into checkout, then symlink). "
|
|
2252
|
+
"Use --no-adopt for the conservative Slice 1 behavior of "
|
|
2253
|
+
"skipping such targets with a back-up-and-remove instruction."
|
|
2254
|
+
),
|
|
2255
|
+
),
|
|
2256
|
+
) -> None:
|
|
2257
|
+
"""Clone the personalization repo and wire symlinks.
|
|
2258
|
+
|
|
2259
|
+
Idempotent: re-running on a machine that already cloned the repo
|
|
2260
|
+
converges branch + symlinks rather than failing.
|
|
2261
|
+
"""
|
|
2262
|
+
from ctrlrelay.personalization.manager import PersonalizationError
|
|
2263
|
+
|
|
2264
|
+
mgr = _make_personalization_manager(config_path)
|
|
2265
|
+
try:
|
|
2266
|
+
summary = mgr.init(adopt=not no_adopt)
|
|
2267
|
+
except PersonalizationError as e:
|
|
2268
|
+
console.print(f"[red]Init failed:[/red] {e}")
|
|
2269
|
+
raise typer.Exit(1)
|
|
2270
|
+
console.print(summary)
|
|
2271
|
+
|
|
2272
|
+
|
|
2273
|
+
@personalization_app.command("status")
|
|
2274
|
+
def personalization_status(
|
|
2275
|
+
config_path: str | None = typer.Option(
|
|
2276
|
+
None,
|
|
2277
|
+
"--config",
|
|
2278
|
+
"-c",
|
|
2279
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
2280
|
+
),
|
|
2281
|
+
) -> None:
|
|
2282
|
+
"""Show working-tree state and per-symlink correctness. Read-only."""
|
|
2283
|
+
mgr = _make_personalization_manager(config_path)
|
|
2284
|
+
console.print(mgr.status())
|
|
2285
|
+
|
|
2286
|
+
|
|
2287
|
+
@personalization_app.command("push")
|
|
2288
|
+
def personalization_push(
|
|
2289
|
+
config_path: str | None = typer.Option(
|
|
2290
|
+
None,
|
|
2291
|
+
"--config",
|
|
2292
|
+
"-c",
|
|
2293
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
2294
|
+
),
|
|
2295
|
+
message: str | None = typer.Option(
|
|
2296
|
+
None,
|
|
2297
|
+
"--message",
|
|
2298
|
+
"-m",
|
|
2299
|
+
help="Commit message override (default: auto-generated from branch name).",
|
|
2300
|
+
),
|
|
2301
|
+
) -> None:
|
|
2302
|
+
"""Commit, rebase onto main, and push. Aborts the rebase on conflict.
|
|
2303
|
+
|
|
2304
|
+
On conflict the working tree is restored to its pre-rebase state and
|
|
2305
|
+
the conflicting files are listed; resolve them in the checkout (the
|
|
2306
|
+
target of any symlinked path), commit manually, and re-run push.
|
|
2307
|
+
"""
|
|
2308
|
+
from ctrlrelay.personalization.manager import PersonalizationError
|
|
2309
|
+
|
|
2310
|
+
mgr = _make_personalization_manager(config_path)
|
|
2311
|
+
try:
|
|
2312
|
+
result = mgr.push(message=message)
|
|
2313
|
+
except PersonalizationError as e:
|
|
2314
|
+
console.print(f"[red]Push failed:[/red] {e}")
|
|
2315
|
+
raise typer.Exit(1)
|
|
2316
|
+
console.print(result.summary)
|
|
2317
|
+
if result.conflict_files:
|
|
2318
|
+
console.print("[yellow]conflicts:[/yellow]")
|
|
2319
|
+
for f in result.conflict_files:
|
|
2320
|
+
console.print(f" {f}")
|
|
2321
|
+
if not result.success:
|
|
2322
|
+
raise typer.Exit(1)
|
|
2323
|
+
|
|
2324
|
+
|
|
2325
|
+
@personalization_app.command("pull")
|
|
2326
|
+
def personalization_pull(
|
|
2327
|
+
config_path: str | None = typer.Option(
|
|
2328
|
+
None,
|
|
2329
|
+
"--config",
|
|
2330
|
+
"-c",
|
|
2331
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
2332
|
+
),
|
|
2333
|
+
) -> None:
|
|
2334
|
+
"""Fetch + rebase the local working branch and re-wire symlinks."""
|
|
2335
|
+
from ctrlrelay.personalization.manager import PersonalizationError
|
|
2336
|
+
|
|
2337
|
+
mgr = _make_personalization_manager(config_path)
|
|
2338
|
+
try:
|
|
2339
|
+
result = mgr.pull()
|
|
2340
|
+
except PersonalizationError as e:
|
|
2341
|
+
console.print(f"[red]Pull failed:[/red] {e}")
|
|
2342
|
+
raise typer.Exit(1)
|
|
2343
|
+
console.print(result.summary)
|
|
2344
|
+
if result.conflict_files:
|
|
2345
|
+
console.print("[yellow]conflicts:[/yellow]")
|
|
2346
|
+
for f in result.conflict_files:
|
|
2347
|
+
console.print(f" {f}")
|
|
2348
|
+
if not result.success:
|
|
2349
|
+
raise typer.Exit(1)
|
|
2350
|
+
|
|
2351
|
+
|
|
2352
|
+
@app.command("setup")
|
|
2353
|
+
def setup(
|
|
2354
|
+
owner: list[str] = typer.Option(
|
|
2355
|
+
[],
|
|
2356
|
+
"--owner",
|
|
2357
|
+
help=(
|
|
2358
|
+
"GitHub owner to include (repeat for multiple). When omitted, "
|
|
2359
|
+
"the command prompts to pick from your accessible owners."
|
|
2360
|
+
),
|
|
2361
|
+
),
|
|
2362
|
+
repo_root: Path = typer.Option(
|
|
2363
|
+
Path("~/Projects").expanduser(),
|
|
2364
|
+
"--repo-root",
|
|
2365
|
+
help="Folder under which repos will be cloned (one subdir per owner).",
|
|
2366
|
+
),
|
|
2367
|
+
config_out: Path = typer.Option(
|
|
2368
|
+
Path("~/.config/ctrlrelay/orchestrator.yaml").expanduser(),
|
|
2369
|
+
"--config-out",
|
|
2370
|
+
help="Where to write the generated orchestrator.yaml.",
|
|
2371
|
+
),
|
|
2372
|
+
timezone: str = typer.Option(
|
|
2373
|
+
"UTC",
|
|
2374
|
+
"--timezone",
|
|
2375
|
+
help="IANA timezone for scheduled jobs (e.g. 'America/Santiago').",
|
|
2376
|
+
),
|
|
2377
|
+
transport: str = typer.Option(
|
|
2378
|
+
"file_mock",
|
|
2379
|
+
"--transport",
|
|
2380
|
+
help="Transport for operator notifications: 'file_mock' or 'telegram'.",
|
|
2381
|
+
),
|
|
2382
|
+
telegram_chat_id: int | None = typer.Option(
|
|
2383
|
+
None,
|
|
2384
|
+
"--telegram-chat-id",
|
|
2385
|
+
help="Telegram chat ID. Only used when --transport=telegram.",
|
|
2386
|
+
),
|
|
2387
|
+
personalization_repo: str | None = typer.Option(
|
|
2388
|
+
None,
|
|
2389
|
+
"--personalization-repo",
|
|
2390
|
+
help=(
|
|
2391
|
+
"owner/repo for cross-machine sync (e.g. 'alice/dotclaude'). "
|
|
2392
|
+
"Adds a personalization block to the config."
|
|
2393
|
+
),
|
|
2394
|
+
),
|
|
2395
|
+
no_personalization: bool = typer.Option(
|
|
2396
|
+
False,
|
|
2397
|
+
"--no-personalization",
|
|
2398
|
+
help="Skip the personalization prompt entirely (non-interactive mode).",
|
|
2399
|
+
),
|
|
2400
|
+
wire_skills: bool = typer.Option(
|
|
2401
|
+
True,
|
|
2402
|
+
"--wire-skills/--no-wire-skills",
|
|
2403
|
+
help=(
|
|
2404
|
+
"When the personalization repo already contains "
|
|
2405
|
+
"global/skills/<name>/ directories, add a paths: entry per "
|
|
2406
|
+
"skill so they sync to ~/.claude/skills/<name>/. Default: on."
|
|
2407
|
+
),
|
|
2408
|
+
),
|
|
2409
|
+
install_daemons: bool = typer.Option(
|
|
2410
|
+
False,
|
|
2411
|
+
"--install-daemons",
|
|
2412
|
+
help=(
|
|
2413
|
+
"Render and write launchd (macOS) / systemd (Linux) unit files. "
|
|
2414
|
+
"Requires --transport=telegram + token in $CTRLRELAY_TELEGRAM_TOKEN "
|
|
2415
|
+
"for the rendered plist to be usable."
|
|
2416
|
+
),
|
|
2417
|
+
),
|
|
2418
|
+
skip_archived: bool = typer.Option(
|
|
2419
|
+
True, "--skip-archived/--include-archived"
|
|
2420
|
+
),
|
|
2421
|
+
skip_forks: bool = typer.Option(True, "--skip-forks/--include-forks"),
|
|
2422
|
+
yes: bool = typer.Option(
|
|
2423
|
+
False,
|
|
2424
|
+
"--yes",
|
|
2425
|
+
"-y",
|
|
2426
|
+
help="Accept all interactive defaults; never prompt.",
|
|
2427
|
+
),
|
|
2428
|
+
force: bool = typer.Option(
|
|
2429
|
+
False,
|
|
2430
|
+
"--force",
|
|
2431
|
+
help=(
|
|
2432
|
+
"Overwrite an existing orchestrator.yaml (and existing daemon "
|
|
2433
|
+
"plists when --install-daemons)."
|
|
2434
|
+
),
|
|
2435
|
+
),
|
|
2436
|
+
) -> None:
|
|
2437
|
+
"""First-run setup: detect orgs, enumerate repos, write config, clone, optional daemon install.
|
|
2438
|
+
|
|
2439
|
+
The interactive flow asks one question at a time and accepts every
|
|
2440
|
+
answer with a sensible default. Pass --yes to skip prompts and
|
|
2441
|
+
take all defaults. Provide --owner repeatedly to lock the owner
|
|
2442
|
+
list non-interactively.
|
|
2443
|
+
"""
|
|
2444
|
+
from ctrlrelay.setup import (
|
|
2445
|
+
GhAuthError,
|
|
2446
|
+
SetupOptions,
|
|
2447
|
+
assert_gh_auth,
|
|
2448
|
+
detect_owners,
|
|
2449
|
+
run_setup,
|
|
2450
|
+
)
|
|
2451
|
+
|
|
2452
|
+
try:
|
|
2453
|
+
assert_gh_auth()
|
|
2454
|
+
except GhAuthError as e:
|
|
2455
|
+
console.print(f"[red]Setup blocked:[/red] {e}")
|
|
2456
|
+
raise typer.Exit(1)
|
|
2457
|
+
|
|
2458
|
+
# ----- owners --------------------------------------------------------
|
|
2459
|
+
chosen_owners: list[str] = list(owner)
|
|
2460
|
+
if not chosen_owners:
|
|
2461
|
+
try:
|
|
2462
|
+
available = detect_owners()
|
|
2463
|
+
except RuntimeError as e:
|
|
2464
|
+
console.print(f"[red]Setup blocked:[/red] could not list owners: {e}")
|
|
2465
|
+
raise typer.Exit(1)
|
|
2466
|
+
if yes:
|
|
2467
|
+
chosen_owners = available # take everything
|
|
2468
|
+
else:
|
|
2469
|
+
console.print(
|
|
2470
|
+
"Available owners on this machine's gh login:\n "
|
|
2471
|
+
+ "\n ".join(f"{i + 1}. {o}" for i, o in enumerate(available))
|
|
2472
|
+
)
|
|
2473
|
+
picked_raw = typer.prompt(
|
|
2474
|
+
"Which to monitor? Enter numbers separated by space "
|
|
2475
|
+
"(or 'all' for everything)",
|
|
2476
|
+
default="all",
|
|
2477
|
+
).strip()
|
|
2478
|
+
if picked_raw.lower() == "all":
|
|
2479
|
+
chosen_owners = available
|
|
2480
|
+
else:
|
|
2481
|
+
try:
|
|
2482
|
+
indexes = [int(x) - 1 for x in picked_raw.split()]
|
|
2483
|
+
chosen_owners = [available[i] for i in indexes]
|
|
2484
|
+
except (ValueError, IndexError):
|
|
2485
|
+
console.print(f"[red]Invalid selection:[/red] {picked_raw}")
|
|
2486
|
+
raise typer.Exit(2)
|
|
2487
|
+
if not chosen_owners:
|
|
2488
|
+
console.print("[red]Setup blocked:[/red] no owners chosen")
|
|
2489
|
+
raise typer.Exit(2)
|
|
2490
|
+
|
|
2491
|
+
# ----- personalization ----------------------------------------------
|
|
2492
|
+
chosen_personalization = personalization_repo
|
|
2493
|
+
if (
|
|
2494
|
+
chosen_personalization is None
|
|
2495
|
+
and not no_personalization
|
|
2496
|
+
and not yes
|
|
2497
|
+
):
|
|
2498
|
+
if typer.confirm(
|
|
2499
|
+
"Configure cross-machine personalization sync (CLAUDE.md, skills, memory)?",
|
|
2500
|
+
default=False,
|
|
2501
|
+
):
|
|
2502
|
+
chosen_personalization = typer.prompt(
|
|
2503
|
+
"Personalization repo (owner/repo)"
|
|
2504
|
+
).strip() or None
|
|
2505
|
+
|
|
2506
|
+
# ----- telegram chat_id (required for telegram transport) ------------
|
|
2507
|
+
# ``chat_id: 0`` is a footgun (the bridge would happily try to send
|
|
2508
|
+
# there), so we force the operator to supply a real value before
|
|
2509
|
+
# writing the config — interactively or via --telegram-chat-id.
|
|
2510
|
+
if transport == "telegram":
|
|
2511
|
+
if telegram_chat_id is None:
|
|
2512
|
+
if yes:
|
|
2513
|
+
console.print(
|
|
2514
|
+
"[red]Setup blocked:[/red] --transport telegram "
|
|
2515
|
+
"requires --telegram-chat-id (non-interactive mode)."
|
|
2516
|
+
)
|
|
2517
|
+
raise typer.Exit(2)
|
|
2518
|
+
chat_raw = typer.prompt("Telegram chat ID").strip()
|
|
2519
|
+
try:
|
|
2520
|
+
telegram_chat_id = int(chat_raw)
|
|
2521
|
+
except ValueError:
|
|
2522
|
+
console.print(f"[red]Invalid chat ID:[/red] {chat_raw!r}")
|
|
2523
|
+
raise typer.Exit(2)
|
|
2524
|
+
if telegram_chat_id == 0:
|
|
2525
|
+
console.print(
|
|
2526
|
+
"[red]Setup blocked:[/red] telegram chat_id must be non-zero "
|
|
2527
|
+
"for the bridge to deliver messages."
|
|
2528
|
+
)
|
|
2529
|
+
raise typer.Exit(2)
|
|
2530
|
+
|
|
2531
|
+
# ----- daemons -------------------------------------------------------
|
|
2532
|
+
chosen_install_daemons = install_daemons
|
|
2533
|
+
if not chosen_install_daemons and not yes:
|
|
2534
|
+
chosen_install_daemons = typer.confirm(
|
|
2535
|
+
"Render launchd/systemd unit files for the bridge and poller?",
|
|
2536
|
+
default=transport == "telegram",
|
|
2537
|
+
)
|
|
2538
|
+
|
|
2539
|
+
# ----- telegram token (only relevant for plist rendering) ------------
|
|
2540
|
+
# The token is only baked into a rendered plist *if* we're also
|
|
2541
|
+
# installing daemons in this same setup run. Missing token is NOT
|
|
2542
|
+
# a reason to flip the transport: the config only stores
|
|
2543
|
+
# ``bot_token_env``; the literal value can be exported later before
|
|
2544
|
+
# bootstrapping the bridge.
|
|
2545
|
+
token: str | None = None
|
|
2546
|
+
if transport == "telegram" and chosen_install_daemons:
|
|
2547
|
+
token = os.environ.get("CTRLRELAY_TELEGRAM_TOKEN")
|
|
2548
|
+
if token is None and not yes:
|
|
2549
|
+
token = typer.prompt(
|
|
2550
|
+
"Telegram bot token (input hidden, used only to render plists)",
|
|
2551
|
+
hide_input=True,
|
|
2552
|
+
).strip() or None
|
|
2553
|
+
if token is None:
|
|
2554
|
+
console.print(
|
|
2555
|
+
"[yellow]Warning:[/yellow] no Telegram token in env or prompt. "
|
|
2556
|
+
"Rendered plist will carry the ${CTRLRELAY_TELEGRAM_TOKEN} "
|
|
2557
|
+
"placeholder; export the token before bootstrapping the bridge."
|
|
2558
|
+
)
|
|
2559
|
+
|
|
2560
|
+
options = SetupOptions(
|
|
2561
|
+
owners=chosen_owners,
|
|
2562
|
+
repo_root=repo_root.expanduser(),
|
|
2563
|
+
config_out=config_out.expanduser(),
|
|
2564
|
+
timezone=timezone,
|
|
2565
|
+
transport=transport,
|
|
2566
|
+
telegram_chat_id=telegram_chat_id,
|
|
2567
|
+
telegram_token=token,
|
|
2568
|
+
personalization_repo=chosen_personalization,
|
|
2569
|
+
wire_skills=wire_skills,
|
|
2570
|
+
install_daemons=chosen_install_daemons,
|
|
2571
|
+
skip_archived=skip_archived,
|
|
2572
|
+
skip_forks=skip_forks,
|
|
2573
|
+
force=force,
|
|
2574
|
+
)
|
|
2575
|
+
|
|
2576
|
+
console.print(
|
|
2577
|
+
f"\n[bold]Running setup[/bold]\n"
|
|
2578
|
+
f" owners: {', '.join(options.owners)}\n"
|
|
2579
|
+
f" repo_root: {options.repo_root}\n"
|
|
2580
|
+
f" config_out: {options.config_out}\n"
|
|
2581
|
+
f" transport: {options.transport}\n"
|
|
2582
|
+
f" personalization: {options.personalization_repo or '(skipped)'}\n"
|
|
2583
|
+
f" install_daemons: {options.install_daemons}\n"
|
|
2584
|
+
)
|
|
2585
|
+
try:
|
|
2586
|
+
result = run_setup(options)
|
|
2587
|
+
except FileExistsError as e:
|
|
2588
|
+
console.print(f"[red]{e}[/red]")
|
|
2589
|
+
raise typer.Exit(1)
|
|
2590
|
+
except (ValueError, RuntimeError) as e:
|
|
2591
|
+
console.print(f"[red]Setup failed:[/red] {e}")
|
|
2592
|
+
raise typer.Exit(1)
|
|
2593
|
+
|
|
2594
|
+
console.print(
|
|
2595
|
+
f"\n[green]✓ Wrote {result.config_out} ({result.n_repos} repos across "
|
|
2596
|
+
f"{len(result.owners)} owners)[/green]"
|
|
2597
|
+
)
|
|
2598
|
+
console.print(
|
|
2599
|
+
f" clone results: [green]{result.cloned} cloned[/green], "
|
|
2600
|
+
f"[dim]{result.skipped} skipped[/dim], "
|
|
2601
|
+
f"[red]{result.failed} failed[/red]"
|
|
2602
|
+
)
|
|
2603
|
+
if result.personalization_summary:
|
|
2604
|
+
console.print(f" personalization: {result.personalization_summary}")
|
|
2605
|
+
if result.personalization_failed:
|
|
2606
|
+
console.print(
|
|
2607
|
+
"\n[bold red]ACTION NEEDED[/bold red] — personalization repo "
|
|
2608
|
+
"sync failed; the rest of setup completed but your "
|
|
2609
|
+
"personalization config/skills were not cloned. Check your "
|
|
2610
|
+
"git auth (`gh auth status`, `gh config get git_protocol`) "
|
|
2611
|
+
"and re-run `ctrlrelay personalization init` once it's fixed."
|
|
2612
|
+
)
|
|
2613
|
+
if result.daemon_units:
|
|
2614
|
+
console.print(" daemon unit files:")
|
|
2615
|
+
for p in result.daemon_units:
|
|
2616
|
+
console.print(f" - {p}")
|
|
2617
|
+
console.print(
|
|
2618
|
+
"\n[bold]Next:[/bold] bootstrap the daemons via:\n"
|
|
2619
|
+
" launchctl bootstrap gui/$(id -u) <plist>"
|
|
2620
|
+
" # macOS, one per file above\n"
|
|
2621
|
+
" systemctl --user daemon-reload && systemctl --user enable --now <unit>"
|
|
2622
|
+
" # Linux"
|
|
2623
|
+
)
|
|
2624
|
+
# Clone and personalization-sync failures must surface as a
|
|
2625
|
+
# non-zero exit even when daemon units were rendered — automation
|
|
2626
|
+
# watching exit code shouldn't mistake a partial setup for success.
|
|
2627
|
+
if result.failed or result.personalization_failed:
|
|
2628
|
+
raise typer.Exit(1)
|
|
2629
|
+
|
|
2630
|
+
|
|
2631
|
+
@app.command("version")
|
|
2632
|
+
def version() -> None:
|
|
2633
|
+
"""Print the package version."""
|
|
2634
|
+
console.print(__version__)
|
|
2635
|
+
|
|
2636
|
+
|
|
2637
|
+
@app.command("status")
|
|
2638
|
+
def status(
|
|
2639
|
+
config_path: str | None = typer.Option(
|
|
2640
|
+
None,
|
|
2641
|
+
"--config",
|
|
2642
|
+
"-c",
|
|
2643
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
2644
|
+
),
|
|
2645
|
+
) -> None:
|
|
2646
|
+
"""Show orchestrator status and active sessions."""
|
|
2647
|
+
from ctrlrelay.core.state import StateDB
|
|
2648
|
+
|
|
2649
|
+
try:
|
|
2650
|
+
config = load_config(resolve_config_path(config_path))
|
|
2651
|
+
except ConfigError as e:
|
|
2652
|
+
console.print(f"[red]Error loading config:[/red] {e}")
|
|
2653
|
+
raise typer.Exit(1)
|
|
2654
|
+
|
|
2655
|
+
db_path = config.paths.state_db
|
|
2656
|
+
if not db_path.exists():
|
|
2657
|
+
console.print(f"[yellow]State database not found at {db_path}[/yellow]")
|
|
2658
|
+
console.print("Run a pipeline first to initialize the database.")
|
|
2659
|
+
return
|
|
2660
|
+
|
|
2661
|
+
import sqlite3
|
|
2662
|
+
|
|
2663
|
+
try:
|
|
2664
|
+
db = StateDB(db_path)
|
|
2665
|
+
except sqlite3.Error as e:
|
|
2666
|
+
console.print(f"[red]Error opening database:[/red] {e}")
|
|
2667
|
+
raise typer.Exit(1)
|
|
2668
|
+
|
|
2669
|
+
try:
|
|
2670
|
+
# Show locks
|
|
2671
|
+
locks = db.list_locks()
|
|
2672
|
+
if locks:
|
|
2673
|
+
console.print("\n[bold]Active Locks:[/bold]")
|
|
2674
|
+
for lock in locks:
|
|
2675
|
+
console.print(f" • {lock['repo']} → session {lock['session_id']}")
|
|
2676
|
+
else:
|
|
2677
|
+
console.print("\n[dim]No active locks[/dim]")
|
|
2678
|
+
|
|
2679
|
+
# Show recent sessions
|
|
2680
|
+
rows = db.execute(
|
|
2681
|
+
"SELECT * FROM sessions ORDER BY started_at DESC LIMIT 5"
|
|
2682
|
+
).fetchall()
|
|
2683
|
+
|
|
2684
|
+
if rows:
|
|
2685
|
+
console.print("\n[bold]Recent Sessions:[/bold]")
|
|
2686
|
+
table = Table()
|
|
2687
|
+
table.add_column("ID", style="dim", max_width=12)
|
|
2688
|
+
table.add_column("Pipeline")
|
|
2689
|
+
table.add_column("Repo")
|
|
2690
|
+
table.add_column("Status")
|
|
2691
|
+
|
|
2692
|
+
for row in rows:
|
|
2693
|
+
status_style = {
|
|
2694
|
+
"done": "green",
|
|
2695
|
+
"failed": "red",
|
|
2696
|
+
"running": "yellow",
|
|
2697
|
+
"blocked": "blue",
|
|
2698
|
+
}.get(row["status"], "white")
|
|
2699
|
+
|
|
2700
|
+
table.add_row(
|
|
2701
|
+
row["id"][:12],
|
|
2702
|
+
row["pipeline"],
|
|
2703
|
+
row["repo"],
|
|
2704
|
+
f"[{status_style}]{row['status']}[/{status_style}]",
|
|
2705
|
+
)
|
|
2706
|
+
console.print(table)
|
|
2707
|
+
else:
|
|
2708
|
+
console.print("\n[dim]No sessions recorded yet[/dim]")
|
|
2709
|
+
except sqlite3.Error as e:
|
|
2710
|
+
console.print(f"[red]Database error:[/red] {e}")
|
|
2711
|
+
raise typer.Exit(1)
|
|
2712
|
+
finally:
|
|
2713
|
+
db.close()
|
|
2714
|
+
|
|
2715
|
+
|
|
2716
|
+
repos_app = typer.Typer(help="Bulk repo operations across the orchestrator manifest.")
|
|
2717
|
+
app.add_typer(repos_app, name="repos")
|
|
2718
|
+
|
|
2719
|
+
|
|
2720
|
+
def _iter_repos(config_path: str | None, filter_str: str | None):
|
|
2721
|
+
"""Yield (name, org, repo, remote, local_path) tuples for repos in
|
|
2722
|
+
the orchestrator config.
|
|
2723
|
+
|
|
2724
|
+
``local_path`` is the fully-resolved path the dev pipeline expects,
|
|
2725
|
+
so callers (clone-all, pull-all, repos status) land repos in the
|
|
2726
|
+
same place every other ctrlrelay command operates on. Before 0.4.0
|
|
2727
|
+
these handlers ignored ``local_path`` and computed ``DEST/org/repo``
|
|
2728
|
+
from the raw owner — which broke when ``paths.owner_aliases`` was
|
|
2729
|
+
in play. The resolved value comes from the config validator so
|
|
2730
|
+
legacy aliases, lowercase-owner derivation, and per-repo overrides
|
|
2731
|
+
all collapse into one source of truth here.
|
|
2732
|
+
"""
|
|
2733
|
+
try:
|
|
2734
|
+
config = load_config(resolve_config_path(config_path))
|
|
2735
|
+
except ConfigError as e:
|
|
2736
|
+
console.print(f"[red]Error loading config:[/red] {e}")
|
|
2737
|
+
raise typer.Exit(1)
|
|
2738
|
+
|
|
2739
|
+
needle = filter_str.lower() if filter_str else None
|
|
2740
|
+
for r in config.repos:
|
|
2741
|
+
if needle and needle not in r.name.lower():
|
|
2742
|
+
continue
|
|
2743
|
+
if "/" not in r.name:
|
|
2744
|
+
console.print(
|
|
2745
|
+
f"[yellow]skip[/yellow] {r.name} — name must be 'org/repo'"
|
|
2746
|
+
)
|
|
2747
|
+
continue
|
|
2748
|
+
org, repo = r.name.split("/", 1)
|
|
2749
|
+
remote = f"git@github.com:{r.name}.git"
|
|
2750
|
+
# ``local_path`` is always populated by the time we reach here:
|
|
2751
|
+
# either the operator declared it explicitly OR the model
|
|
2752
|
+
# validator filled it in from ``paths.repo_root + owner.lower()``.
|
|
2753
|
+
# If neither path was taken the validator would have raised at
|
|
2754
|
+
# config load, never letting us get this far.
|
|
2755
|
+
local_path = Path(str(r.local_path)).expanduser()
|
|
2756
|
+
yield r.name, org, repo, remote, local_path
|
|
2757
|
+
|
|
2758
|
+
|
|
2759
|
+
def _resolve_target(
|
|
2760
|
+
dest: Path | None,
|
|
2761
|
+
org: str,
|
|
2762
|
+
repo: str,
|
|
2763
|
+
config_local_path: Path,
|
|
2764
|
+
) -> Path:
|
|
2765
|
+
"""Return where a repo should land on disk.
|
|
2766
|
+
|
|
2767
|
+
Without ``dest``, use the config-resolved ``local_path`` so
|
|
2768
|
+
clone-all/pull-all/status agree with the dev pipeline. With
|
|
2769
|
+
``dest``, override to ``dest/<owner.lower()>/<repo>`` (the new
|
|
2770
|
+
convention since 0.4.0). Pre-0.4.0 the dest-provided form used
|
|
2771
|
+
the raw owner with mixed casing — that was the source of the
|
|
2772
|
+
clone-all/local_path mismatch bug. See #128.
|
|
2773
|
+
"""
|
|
2774
|
+
if dest is None:
|
|
2775
|
+
return config_local_path
|
|
2776
|
+
return Path(dest).expanduser().resolve() / org.lower() / repo
|
|
2777
|
+
|
|
2778
|
+
|
|
2779
|
+
@repos_app.command("clone-all")
|
|
2780
|
+
def repos_clone_all(
|
|
2781
|
+
dest: Path | None = typer.Argument(
|
|
2782
|
+
None,
|
|
2783
|
+
help=(
|
|
2784
|
+
"Workspace root override (e.g. ~/code/sandbox). When omitted, "
|
|
2785
|
+
"each repo lands at its resolved local_path from the config."
|
|
2786
|
+
),
|
|
2787
|
+
),
|
|
2788
|
+
config_path: str | None = typer.Option(
|
|
2789
|
+
None,
|
|
2790
|
+
"--config",
|
|
2791
|
+
"-c",
|
|
2792
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
2793
|
+
),
|
|
2794
|
+
filter_str: str | None = typer.Option(
|
|
2795
|
+
None, "--filter", "-f", help="Substring filter on repo name (e.g. 'AInvirion')"
|
|
2796
|
+
),
|
|
2797
|
+
dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Show actions without running"),
|
|
2798
|
+
) -> None:
|
|
2799
|
+
"""Clone every configured repo to its resolved local_path
|
|
2800
|
+
(or DEST/<owner.lower()>/<repo> when DEST is given)."""
|
|
2801
|
+
import subprocess
|
|
2802
|
+
|
|
2803
|
+
cloned = skipped = failed = 0
|
|
2804
|
+
|
|
2805
|
+
for name, org, repo, remote, local_path in _iter_repos(config_path, filter_str):
|
|
2806
|
+
target = _resolve_target(dest, org, repo, local_path)
|
|
2807
|
+
if (target / ".git").exists():
|
|
2808
|
+
console.print(f"[dim]✓ {name} (already cloned)[/dim]")
|
|
2809
|
+
skipped += 1
|
|
2810
|
+
continue
|
|
2811
|
+
if target.exists() and any(target.iterdir()):
|
|
2812
|
+
console.print(f"[yellow]skip[/yellow] {name} — {target} exists and is not empty")
|
|
2813
|
+
skipped += 1
|
|
2814
|
+
continue
|
|
2815
|
+
if dry_run:
|
|
2816
|
+
console.print(f"[blue]would clone[/blue] {remote} → {target}")
|
|
2817
|
+
continue
|
|
2818
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
2819
|
+
result = subprocess.run(
|
|
2820
|
+
["git", "clone", "--quiet", remote, str(target)],
|
|
2821
|
+
capture_output=True,
|
|
2822
|
+
text=True,
|
|
2823
|
+
)
|
|
2824
|
+
if result.returncode == 0:
|
|
2825
|
+
console.print(f"[green]cloned[/green] {name}")
|
|
2826
|
+
cloned += 1
|
|
2827
|
+
else:
|
|
2828
|
+
console.print(f"[red]failed[/red] {name}: {result.stderr.strip()}")
|
|
2829
|
+
failed += 1
|
|
2830
|
+
|
|
2831
|
+
if not dry_run:
|
|
2832
|
+
console.print(
|
|
2833
|
+
f"\n[bold]{cloned} cloned, {skipped} skipped, {failed} failed[/bold]"
|
|
2834
|
+
)
|
|
2835
|
+
if failed:
|
|
2836
|
+
raise typer.Exit(1)
|
|
2837
|
+
|
|
2838
|
+
|
|
2839
|
+
@repos_app.command("pull-all")
|
|
2840
|
+
def repos_pull_all(
|
|
2841
|
+
dest: Path | None = typer.Argument(
|
|
2842
|
+
None,
|
|
2843
|
+
help=(
|
|
2844
|
+
"Workspace root override. When omitted, pulls each repo at "
|
|
2845
|
+
"its resolved local_path from the config."
|
|
2846
|
+
),
|
|
2847
|
+
),
|
|
2848
|
+
config_path: str | None = typer.Option(
|
|
2849
|
+
None,
|
|
2850
|
+
"--config",
|
|
2851
|
+
"-c",
|
|
2852
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
2853
|
+
),
|
|
2854
|
+
filter_str: str | None = typer.Option(
|
|
2855
|
+
None, "--filter", "-f", help="Substring filter on repo name"
|
|
2856
|
+
),
|
|
2857
|
+
dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Show actions without running"),
|
|
2858
|
+
) -> None:
|
|
2859
|
+
"""Fast-forward pull every cloned repo at its resolved local_path
|
|
2860
|
+
(or DEST/<owner.lower()>/<repo> when DEST is given)."""
|
|
2861
|
+
import subprocess
|
|
2862
|
+
|
|
2863
|
+
pulled = skipped = dirty = failed = 0
|
|
2864
|
+
|
|
2865
|
+
for name, org, repo, _remote, local_path in _iter_repos(config_path, filter_str):
|
|
2866
|
+
target = _resolve_target(dest, org, repo, local_path)
|
|
2867
|
+
if not (target / ".git").exists():
|
|
2868
|
+
console.print(f"[dim]– {name} (not cloned)[/dim]")
|
|
2869
|
+
skipped += 1
|
|
2870
|
+
continue
|
|
2871
|
+
if dry_run:
|
|
2872
|
+
console.print(f"[blue]would pull[/blue] {target}")
|
|
2873
|
+
continue
|
|
2874
|
+
# Refuse to pull a dirty tree — fetch only.
|
|
2875
|
+
porcelain = subprocess.run(
|
|
2876
|
+
["git", "-C", str(target), "status", "--porcelain"],
|
|
2877
|
+
capture_output=True,
|
|
2878
|
+
text=True,
|
|
2879
|
+
)
|
|
2880
|
+
if porcelain.returncode != 0:
|
|
2881
|
+
console.print(
|
|
2882
|
+
f"[red]failed[/red] {name}: git status — {porcelain.stderr.strip()}"
|
|
2883
|
+
)
|
|
2884
|
+
failed += 1
|
|
2885
|
+
continue
|
|
2886
|
+
if porcelain.stdout.strip():
|
|
2887
|
+
fetched = subprocess.run(
|
|
2888
|
+
["git", "-C", str(target), "fetch", "--quiet"],
|
|
2889
|
+
capture_output=True,
|
|
2890
|
+
text=True,
|
|
2891
|
+
)
|
|
2892
|
+
if fetched.returncode != 0:
|
|
2893
|
+
console.print(
|
|
2894
|
+
f"[red]failed[/red] {name}: fetch — {fetched.stderr.strip()}"
|
|
2895
|
+
)
|
|
2896
|
+
failed += 1
|
|
2897
|
+
else:
|
|
2898
|
+
console.print(f"[yellow]dirty[/yellow] {name} — fetched only")
|
|
2899
|
+
dirty += 1
|
|
2900
|
+
continue
|
|
2901
|
+
result = subprocess.run(
|
|
2902
|
+
["git", "-C", str(target), "pull", "--ff-only", "--quiet"],
|
|
2903
|
+
capture_output=True,
|
|
2904
|
+
text=True,
|
|
2905
|
+
)
|
|
2906
|
+
if result.returncode == 0:
|
|
2907
|
+
console.print(f"[green]pulled[/green] {name}")
|
|
2908
|
+
pulled += 1
|
|
2909
|
+
else:
|
|
2910
|
+
console.print(f"[red]failed[/red] {name}: {result.stderr.strip()}")
|
|
2911
|
+
failed += 1
|
|
2912
|
+
|
|
2913
|
+
if not dry_run:
|
|
2914
|
+
console.print(
|
|
2915
|
+
f"\n[bold]{pulled} pulled, {dirty} dirty, {skipped} skipped, "
|
|
2916
|
+
f"{failed} failed[/bold]"
|
|
2917
|
+
)
|
|
2918
|
+
if failed:
|
|
2919
|
+
raise typer.Exit(1)
|
|
2920
|
+
|
|
2921
|
+
|
|
2922
|
+
@repos_app.command("status")
|
|
2923
|
+
def repos_status(
|
|
2924
|
+
dest: Path | None = typer.Argument(
|
|
2925
|
+
None,
|
|
2926
|
+
help=(
|
|
2927
|
+
"Workspace root override. When omitted, inspects each repo "
|
|
2928
|
+
"at its resolved local_path from the config."
|
|
2929
|
+
),
|
|
2930
|
+
),
|
|
2931
|
+
config_path: str | None = typer.Option(
|
|
2932
|
+
None,
|
|
2933
|
+
"--config",
|
|
2934
|
+
"-c",
|
|
2935
|
+
help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
|
|
2936
|
+
),
|
|
2937
|
+
filter_str: str | None = typer.Option(
|
|
2938
|
+
None, "--filter", "-f", help="Substring filter on repo name"
|
|
2939
|
+
),
|
|
2940
|
+
) -> None:
|
|
2941
|
+
"""Show clean/dirty/ahead/behind for every repo at its resolved
|
|
2942
|
+
local_path (or DEST/<owner.lower()>/<repo> when DEST is given)."""
|
|
2943
|
+
import subprocess
|
|
2944
|
+
|
|
2945
|
+
title = "Repo status" if dest is None else f"Repo status — {Path(dest).expanduser().resolve()}"
|
|
2946
|
+
table = Table(title=title)
|
|
2947
|
+
table.add_column("Repo", style="cyan")
|
|
2948
|
+
table.add_column("Branch", style="blue")
|
|
2949
|
+
table.add_column("State")
|
|
2950
|
+
table.add_column("Ahead/Behind", justify="right")
|
|
2951
|
+
|
|
2952
|
+
for name, org, repo, _remote, local_path in _iter_repos(config_path, filter_str):
|
|
2953
|
+
target = _resolve_target(dest, org, repo, local_path)
|
|
2954
|
+
if not (target / ".git").exists():
|
|
2955
|
+
table.add_row(name, "-", "[red]not cloned[/red]", "")
|
|
2956
|
+
continue
|
|
2957
|
+
branch = subprocess.run(
|
|
2958
|
+
["git", "-C", str(target), "rev-parse", "--abbrev-ref", "HEAD"],
|
|
2959
|
+
capture_output=True,
|
|
2960
|
+
text=True,
|
|
2961
|
+
).stdout.strip()
|
|
2962
|
+
porcelain = subprocess.run(
|
|
2963
|
+
["git", "-C", str(target), "status", "--porcelain"],
|
|
2964
|
+
capture_output=True,
|
|
2965
|
+
text=True,
|
|
2966
|
+
).stdout.strip()
|
|
2967
|
+
state = "[yellow]dirty[/yellow]" if porcelain else "[green]clean[/green]"
|
|
2968
|
+
|
|
2969
|
+
upstream = subprocess.run(
|
|
2970
|
+
["git", "-C", str(target), "rev-parse", "--abbrev-ref", "@{upstream}"],
|
|
2971
|
+
capture_output=True,
|
|
2972
|
+
text=True,
|
|
2973
|
+
)
|
|
2974
|
+
ahead_behind = ""
|
|
2975
|
+
if upstream.returncode == 0:
|
|
2976
|
+
def _count(spec: str) -> int:
|
|
2977
|
+
proc = subprocess.run(
|
|
2978
|
+
["git", "-C", str(target), "rev-list", "--count", spec],
|
|
2979
|
+
capture_output=True,
|
|
2980
|
+
text=True,
|
|
2981
|
+
)
|
|
2982
|
+
if proc.returncode != 0:
|
|
2983
|
+
return 0
|
|
2984
|
+
try:
|
|
2985
|
+
return int(proc.stdout.strip() or "0")
|
|
2986
|
+
except ValueError:
|
|
2987
|
+
return 0
|
|
2988
|
+
|
|
2989
|
+
ahead_n = _count("@{upstream}..HEAD")
|
|
2990
|
+
behind_n = _count("HEAD..@{upstream}")
|
|
2991
|
+
parts = []
|
|
2992
|
+
if ahead_n > 0:
|
|
2993
|
+
parts.append(f"[green]+{ahead_n}[/green]")
|
|
2994
|
+
if behind_n > 0:
|
|
2995
|
+
parts.append(f"[red]-{behind_n}[/red]")
|
|
2996
|
+
ahead_behind = " ".join(parts)
|
|
2997
|
+
|
|
2998
|
+
table.add_row(name, branch or "-", state, ahead_behind)
|
|
2999
|
+
|
|
3000
|
+
console.print(table)
|
|
3001
|
+
|
|
3002
|
+
|
|
3003
|
+
install_app = typer.Typer(
|
|
3004
|
+
help="Generate launchd / systemd unit files from in-package templates."
|
|
3005
|
+
)
|
|
3006
|
+
app.add_typer(install_app, name="install")
|
|
3007
|
+
|
|
3008
|
+
|
|
3009
|
+
def _print_install_summary(
|
|
3010
|
+
units: list, written: list, dry_run: bool
|
|
3011
|
+
) -> None:
|
|
3012
|
+
from ctrlrelay.install import RenderedUnit # local to avoid CLI import cost
|
|
3013
|
+
|
|
3014
|
+
for unit in units:
|
|
3015
|
+
assert isinstance(unit, RenderedUnit)
|
|
3016
|
+
marker = "would write" if dry_run else "wrote"
|
|
3017
|
+
path_str = (
|
|
3018
|
+
f"[green]{marker}[/green] {unit.target_path}"
|
|
3019
|
+
if not dry_run or unit.target_path in written
|
|
3020
|
+
else f"[yellow]{marker}[/yellow] {unit.target_path}"
|
|
3021
|
+
)
|
|
3022
|
+
console.print(path_str)
|
|
3023
|
+
if unit.unresolved:
|
|
3024
|
+
console.print(
|
|
3025
|
+
f" [yellow]warning:[/yellow] unresolved template variables: "
|
|
3026
|
+
f"{', '.join('${'+v+'}' for v in unit.unresolved)}"
|
|
3027
|
+
)
|
|
3028
|
+
if "CTRLRELAY_TELEGRAM_TOKEN" in unit.unresolved:
|
|
3029
|
+
console.print(
|
|
3030
|
+
" [dim]hint: export CTRLRELAY_TELEGRAM_TOKEN before "
|
|
3031
|
+
"re-running, or edit the file by hand.[/dim]"
|
|
3032
|
+
)
|
|
3033
|
+
|
|
3034
|
+
|
|
3035
|
+
@install_app.command("launchd")
|
|
3036
|
+
def install_launchd(
|
|
3037
|
+
workdir: Path = typer.Option(
|
|
3038
|
+
Path.cwd(),
|
|
3039
|
+
"--workdir",
|
|
3040
|
+
"-w",
|
|
3041
|
+
help="Service WorkingDirectory (defaults to current directory).",
|
|
3042
|
+
),
|
|
3043
|
+
label_prefix: str = typer.Option(
|
|
3044
|
+
"com.ctrlrelay",
|
|
3045
|
+
"--label-prefix",
|
|
3046
|
+
help="Reverse-DNS prefix for the launchd Label and filename.",
|
|
3047
|
+
),
|
|
3048
|
+
poller_interval: int = typer.Option(
|
|
3049
|
+
300, "--poller-interval", help="Poller --interval seconds."
|
|
3050
|
+
),
|
|
3051
|
+
target_dir: Path | None = typer.Option(
|
|
3052
|
+
None,
|
|
3053
|
+
"--target-dir",
|
|
3054
|
+
help="Where to write plists (default: ~/Library/LaunchAgents).",
|
|
3055
|
+
),
|
|
3056
|
+
force: bool = typer.Option(
|
|
3057
|
+
False, "--force", help="Overwrite existing plists if present."
|
|
3058
|
+
),
|
|
3059
|
+
dry_run: bool = typer.Option(
|
|
3060
|
+
False,
|
|
3061
|
+
"--dry-run",
|
|
3062
|
+
help="Print what would be written without touching the filesystem.",
|
|
3063
|
+
),
|
|
3064
|
+
) -> None:
|
|
3065
|
+
"""Render and install bridge + poller plists for macOS launchd.
|
|
3066
|
+
|
|
3067
|
+
After running, load the agents with:
|
|
3068
|
+
launchctl bootstrap gui/$(id -u) <plist-path>
|
|
3069
|
+
"""
|
|
3070
|
+
from ctrlrelay.install import render_launchd, write_units
|
|
3071
|
+
|
|
3072
|
+
units = render_launchd(
|
|
3073
|
+
workdir=workdir,
|
|
3074
|
+
label_prefix=label_prefix,
|
|
3075
|
+
poller_interval=poller_interval,
|
|
3076
|
+
target_dir=target_dir,
|
|
3077
|
+
)
|
|
3078
|
+
written: list[Path] = []
|
|
3079
|
+
if not dry_run:
|
|
3080
|
+
try:
|
|
3081
|
+
written = write_units(units, overwrite=force)
|
|
3082
|
+
except FileExistsError as e:
|
|
3083
|
+
console.print(f"[red]error:[/red] {e}")
|
|
3084
|
+
raise typer.Exit(1)
|
|
3085
|
+
_print_install_summary(units, written, dry_run)
|
|
3086
|
+
|
|
3087
|
+
|
|
3088
|
+
@install_app.command("systemd")
|
|
3089
|
+
def install_systemd(
|
|
3090
|
+
workdir: Path = typer.Option(
|
|
3091
|
+
Path.cwd(),
|
|
3092
|
+
"--workdir",
|
|
3093
|
+
"-w",
|
|
3094
|
+
help="Service WorkingDirectory (defaults to current directory).",
|
|
3095
|
+
),
|
|
3096
|
+
poller_interval: int = typer.Option(
|
|
3097
|
+
300, "--poller-interval", help="Poller --interval seconds."
|
|
3098
|
+
),
|
|
3099
|
+
target_dir: Path | None = typer.Option(
|
|
3100
|
+
None,
|
|
3101
|
+
"--target-dir",
|
|
3102
|
+
help="Where to write units (default: ~/.config/systemd/user).",
|
|
3103
|
+
),
|
|
3104
|
+
force: bool = typer.Option(
|
|
3105
|
+
False, "--force", help="Overwrite existing unit files if present."
|
|
3106
|
+
),
|
|
3107
|
+
dry_run: bool = typer.Option(
|
|
3108
|
+
False,
|
|
3109
|
+
"--dry-run",
|
|
3110
|
+
help="Print what would be written without touching the filesystem.",
|
|
3111
|
+
),
|
|
3112
|
+
) -> None:
|
|
3113
|
+
"""Render and install bridge + poller systemd user units (Linux).
|
|
3114
|
+
|
|
3115
|
+
After running, enable with:
|
|
3116
|
+
systemctl --user daemon-reload
|
|
3117
|
+
systemctl --user enable --now ctrlrelay-bridge.service
|
|
3118
|
+
systemctl --user enable --now ctrlrelay-poller.service
|
|
3119
|
+
"""
|
|
3120
|
+
from ctrlrelay.install import render_systemd, write_units
|
|
3121
|
+
|
|
3122
|
+
units = render_systemd(
|
|
3123
|
+
workdir=workdir,
|
|
3124
|
+
poller_interval=poller_interval,
|
|
3125
|
+
target_dir=target_dir,
|
|
3126
|
+
)
|
|
3127
|
+
written: list[Path] = []
|
|
3128
|
+
if not dry_run:
|
|
3129
|
+
try:
|
|
3130
|
+
written = write_units(units, overwrite=force)
|
|
3131
|
+
except FileExistsError as e:
|
|
3132
|
+
console.print(f"[red]error:[/red] {e}")
|
|
3133
|
+
raise typer.Exit(1)
|
|
3134
|
+
_print_install_summary(units, written, dry_run)
|
|
3135
|
+
|
|
3136
|
+
|
|
3137
|
+
if __name__ == "__main__":
|
|
3138
|
+
app()
|