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
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""In-process job scheduler for recurring background work.
|
|
2
|
+
|
|
3
|
+
Wraps APScheduler's AsyncIOScheduler with the project's conventions:
|
|
4
|
+
|
|
5
|
+
- MemoryJobStore only (cron triggers recompute the next fire time on every
|
|
6
|
+
start, so persistence buys us nothing and adds SQLAlchemy as a runtime
|
|
7
|
+
dep).
|
|
8
|
+
- ``coalesce=True`` + ``misfire_grace_time=3600s`` so a laptop that was
|
|
9
|
+
asleep at the fire time still runs the job when it wakes within an hour,
|
|
10
|
+
and multiple missed fires collapse into one run.
|
|
11
|
+
- Structured obs logging via ``log_event`` so job lifecycle shows up in
|
|
12
|
+
the same log stream as the poller itself.
|
|
13
|
+
|
|
14
|
+
Cross-platform: the scheduler runs in the poller's asyncio loop, so macOS
|
|
15
|
+
(launchd) and Linux (systemd) behave identically — no per-OS timer unit
|
|
16
|
+
is required.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
from collections.abc import Awaitable, Callable
|
|
23
|
+
|
|
24
|
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
25
|
+
from apscheduler.triggers.combining import OrTrigger
|
|
26
|
+
from apscheduler.triggers.cron import CronTrigger
|
|
27
|
+
|
|
28
|
+
from ctrlrelay.core.obs import get_logger, log_event
|
|
29
|
+
|
|
30
|
+
_logger = get_logger("core.scheduler")
|
|
31
|
+
|
|
32
|
+
JobFunc = Callable[[], Awaitable[None]]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# APScheduler's CronTrigger.from_crontab uses Mon=0..Sun=6 for numeric
|
|
36
|
+
# day-of-week, and rejects 7 entirely. Vixie cron (the one every reference
|
|
37
|
+
# and the orchestrator.yaml docs describe) uses Sun=0..Sat=6 with 7 as an
|
|
38
|
+
# alias of Sun. Users writing `0 6 * * 1` expecting Monday would silently
|
|
39
|
+
# get Tuesday runs under APScheduler's numbering. Normalize by remapping
|
|
40
|
+
# numeric DOW fields to APScheduler's named weekdays before building the
|
|
41
|
+
# trigger — names mean the same thing under either numbering scheme.
|
|
42
|
+
_VIXIE_DOW_NAMES = ("sun", "mon", "tue", "wed", "thu", "fri", "sat")
|
|
43
|
+
_VIXIE_NAME_TO_NUM = {name: idx for idx, name in enumerate(_VIXIE_DOW_NAMES)}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _dow_to_vixie_num(tok: str) -> int | None:
|
|
47
|
+
"""Parse a DOW token as a Vixie number. Accepts digits 0..7 (with 7 as
|
|
48
|
+
Sunday alias) and the standard three-letter names. Returns ``None`` if
|
|
49
|
+
the token is neither (so callers can leave it for APScheduler to
|
|
50
|
+
error on)."""
|
|
51
|
+
if tok.isdigit():
|
|
52
|
+
n = int(tok)
|
|
53
|
+
if 0 <= n <= 7:
|
|
54
|
+
return 0 if n == 7 else n
|
|
55
|
+
return None
|
|
56
|
+
return _VIXIE_NAME_TO_NUM.get(tok.lower())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _dow_name(n: int) -> str:
|
|
60
|
+
"""Vixie DOW number → APScheduler name. 0 and 7 both = Sunday."""
|
|
61
|
+
return _VIXIE_DOW_NAMES[0 if n == 7 else n]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _expand_numeric_dow_range(
|
|
65
|
+
start: int, end: int, step: int = 1
|
|
66
|
+
) -> list[str] | None:
|
|
67
|
+
"""Expand a numeric Vixie-style DOW range to a list of APScheduler names,
|
|
68
|
+
or ``None`` if any endpoint is out of 0..7. Vixie ordering (Sun=0..Sat=6,
|
|
69
|
+
7=Sun alias) is NOT compatible with APScheduler's named-weekday ordering
|
|
70
|
+
(mon..sun), so a range like ``0-6`` cannot be rewritten as ``sun-sat`` —
|
|
71
|
+
APScheduler would reject that as an inverted range. Expand to an
|
|
72
|
+
explicit comma-list instead so the behavior is always well-defined."""
|
|
73
|
+
if not (0 <= start <= 7 and 0 <= end <= 7 and step >= 1):
|
|
74
|
+
return None
|
|
75
|
+
if start > end:
|
|
76
|
+
return None
|
|
77
|
+
return [_dow_name(n) for n in range(start, end + 1, step)]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _remap_dow_token(tok: str) -> str:
|
|
81
|
+
"""Convert a single Vixie DOW token (number, range, step, or name) to
|
|
82
|
+
APScheduler's named-weekday form.
|
|
83
|
+
|
|
84
|
+
Every range/step form — numeric OR named — is expanded into an
|
|
85
|
+
explicit comma-separated name list. APScheduler orders weekdays
|
|
86
|
+
``mon..sun``, so a perfectly valid Vixie expression like ``sun-fri``
|
|
87
|
+
looks inverted to APScheduler and gets rejected; expanding to a name
|
|
88
|
+
list dodges the ordering mismatch. Stepped forms like ``mon/2`` also
|
|
89
|
+
need expansion because APScheduler reads named-with-step as "every
|
|
90
|
+
Nth named-weekday occurrence", not Vixie's "from base, every N days".
|
|
91
|
+
"""
|
|
92
|
+
if "/" in tok:
|
|
93
|
+
base, step_str = tok.split("/", 1)
|
|
94
|
+
try:
|
|
95
|
+
step = int(step_str)
|
|
96
|
+
except ValueError:
|
|
97
|
+
return tok
|
|
98
|
+
if step < 1:
|
|
99
|
+
return tok
|
|
100
|
+
# Range-with-step "a-b/s" — endpoints can be numeric or named.
|
|
101
|
+
if "-" in base:
|
|
102
|
+
a, b = base.split("-", 1)
|
|
103
|
+
a_num = _dow_to_vixie_num(a)
|
|
104
|
+
b_num = _dow_to_vixie_num(b)
|
|
105
|
+
if a_num is not None and b_num is not None:
|
|
106
|
+
expanded = _expand_numeric_dow_range(a_num, b_num, step)
|
|
107
|
+
if expanded is not None:
|
|
108
|
+
return ",".join(expanded)
|
|
109
|
+
return tok
|
|
110
|
+
# Wildcard with step: "*/s" — expand across the full week.
|
|
111
|
+
if base == "*":
|
|
112
|
+
expanded = _expand_numeric_dow_range(0, 6, step)
|
|
113
|
+
return ",".join(expanded) if expanded else tok
|
|
114
|
+
# Single base with step: "n/s" or "mon/s" — Vixie says "from base,
|
|
115
|
+
# every s days until end of week". Expand explicitly.
|
|
116
|
+
base_num = _dow_to_vixie_num(base)
|
|
117
|
+
if base_num is not None:
|
|
118
|
+
expanded = _expand_numeric_dow_range(base_num, 6, step)
|
|
119
|
+
if expanded is not None:
|
|
120
|
+
return ",".join(expanded)
|
|
121
|
+
return tok
|
|
122
|
+
# Range without step "a-b" — endpoints numeric or named.
|
|
123
|
+
if "-" in tok:
|
|
124
|
+
a, b = tok.split("-", 1)
|
|
125
|
+
a_num = _dow_to_vixie_num(a)
|
|
126
|
+
b_num = _dow_to_vixie_num(b)
|
|
127
|
+
if a_num is not None and b_num is not None:
|
|
128
|
+
expanded = _expand_numeric_dow_range(a_num, b_num)
|
|
129
|
+
if expanded is not None:
|
|
130
|
+
return ",".join(expanded)
|
|
131
|
+
# Bare number: "0".."7"
|
|
132
|
+
if tok.isdigit():
|
|
133
|
+
n = int(tok)
|
|
134
|
+
if 0 <= n <= 7:
|
|
135
|
+
return _dow_name(n)
|
|
136
|
+
return tok
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _build_vixie_trigger(cron_expr: str, timezone):
|
|
140
|
+
"""Build an APScheduler trigger that honors Vixie cron DOM/DOW OR
|
|
141
|
+
semantics.
|
|
142
|
+
|
|
143
|
+
Vixie cron: when BOTH day-of-month and day-of-week are non-wildcard,
|
|
144
|
+
the expression fires when EITHER field matches (union). APScheduler's
|
|
145
|
+
``CronTrigger.from_crontab`` treats them as AND (intersection), which
|
|
146
|
+
makes ``0 6 1 * mon`` fire only on Mondays that fall on the 1st — a
|
|
147
|
+
much rarer schedule than the user wrote.
|
|
148
|
+
|
|
149
|
+
When both fields are set, we split the expression into two triggers
|
|
150
|
+
(``m h DOM mon *`` and ``m h * mon DOW``) wrapped in an ``OrTrigger``
|
|
151
|
+
so APScheduler fires on either match. The rare case where a given
|
|
152
|
+
minute matches both triggers (Aug 1 is a Monday + cron is
|
|
153
|
+
``0 6 1 8 mon``) produces a single fire because ``OrTrigger``
|
|
154
|
+
coalesces simultaneous sub-trigger hits by fire time.
|
|
155
|
+
|
|
156
|
+
If either DOM or DOW is a wildcard (the common case), we take the
|
|
157
|
+
simple path and return a single ``CronTrigger`` — saves a log entry
|
|
158
|
+
and an allocation.
|
|
159
|
+
"""
|
|
160
|
+
normalized = _normalize_cron(cron_expr)
|
|
161
|
+
parts = normalized.split()
|
|
162
|
+
if len(parts) == 5:
|
|
163
|
+
m, h, dom, mon, dow = parts
|
|
164
|
+
if dom != "*" and dow != "*":
|
|
165
|
+
dom_only = f"{m} {h} {dom} {mon} *"
|
|
166
|
+
dow_only = f"{m} {h} * {mon} {dow}"
|
|
167
|
+
return OrTrigger([
|
|
168
|
+
CronTrigger.from_crontab(dom_only, timezone=timezone),
|
|
169
|
+
CronTrigger.from_crontab(dow_only, timezone=timezone),
|
|
170
|
+
])
|
|
171
|
+
return CronTrigger.from_crontab(normalized, timezone=timezone)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _normalize_cron(expr: str) -> str:
|
|
175
|
+
"""Convert a Vixie-style 5-field cron expression to APScheduler syntax.
|
|
176
|
+
|
|
177
|
+
Only the day-of-week field is rewritten (numeric → name); the other
|
|
178
|
+
four fields share semantics across both systems. Returns the input
|
|
179
|
+
unchanged if it isn't a 5-field expression so APScheduler's own
|
|
180
|
+
parser can emit the real error message.
|
|
181
|
+
|
|
182
|
+
Every DOW token is passed through ``_remap_dow_token`` individually
|
|
183
|
+
so mixed expressions like ``sun,1`` or ``mon,5`` get normalized —
|
|
184
|
+
leaving a bare numeric token in a mostly-named list would let
|
|
185
|
+
APScheduler silently mis-interpret it (their 1 = Tuesday).
|
|
186
|
+
"""
|
|
187
|
+
parts = expr.split()
|
|
188
|
+
if len(parts) != 5:
|
|
189
|
+
return expr
|
|
190
|
+
m, h, dom, mon, dow = parts
|
|
191
|
+
new_dow = ",".join(_remap_dow_token(t) for t in dow.split(","))
|
|
192
|
+
return f"{m} {h} {dom} {mon} {new_dow}"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class Scheduler:
|
|
196
|
+
"""Thin wrapper so the poller doesn't import APScheduler directly.
|
|
197
|
+
|
|
198
|
+
Instances are created with ``make_scheduler``. Lifecycle:
|
|
199
|
+
|
|
200
|
+
scheduler = make_scheduler(timezone="America/Santiago")
|
|
201
|
+
scheduler.add_cron_job("secops", "0 6 * * *", my_async_fn)
|
|
202
|
+
scheduler.start()
|
|
203
|
+
...
|
|
204
|
+
await scheduler.shutdown() # must be awaited; see below
|
|
205
|
+
|
|
206
|
+
The wrapper is intentionally narrow (no pause/resume, no job lookup):
|
|
207
|
+
yagni — we have one caller and one job today.
|
|
208
|
+
|
|
209
|
+
``shutdown`` is async so it can cancel and await in-flight job tasks.
|
|
210
|
+
``AsyncIOScheduler.shutdown(wait=False)`` only posts the shutdown —
|
|
211
|
+
the loop has to keep running for the pending job coroutines to finish
|
|
212
|
+
their ``finally`` blocks (releasing state-DB locks, closing worktrees).
|
|
213
|
+
Calling ``wait=True`` synchronously from inside the loop would
|
|
214
|
+
deadlock because the jobs need the same loop to complete.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
def __init__(self, impl: AsyncIOScheduler) -> None:
|
|
218
|
+
self._impl = impl
|
|
219
|
+
self._started = False
|
|
220
|
+
self._running_jobs: set[asyncio.Task[None]] = set()
|
|
221
|
+
|
|
222
|
+
def add_cron_job(
|
|
223
|
+
self,
|
|
224
|
+
name: str,
|
|
225
|
+
cron_expr: str,
|
|
226
|
+
func: JobFunc,
|
|
227
|
+
*,
|
|
228
|
+
misfire_grace_time: int = 3600,
|
|
229
|
+
coalesce: bool = True,
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Register an async function to fire on a cron schedule.
|
|
232
|
+
|
|
233
|
+
Wraps ``func`` so exceptions are logged but don't poison the
|
|
234
|
+
scheduler — the next fire should still go through. This matches how
|
|
235
|
+
the poll loop isolates per-repo failures. Also tracks the running
|
|
236
|
+
task so ``shutdown`` can cancel and await it cleanly.
|
|
237
|
+
"""
|
|
238
|
+
trigger = _build_vixie_trigger(cron_expr, timezone=self._impl.timezone)
|
|
239
|
+
|
|
240
|
+
async def _safe_job() -> None:
|
|
241
|
+
task = asyncio.current_task()
|
|
242
|
+
if task is not None:
|
|
243
|
+
self._running_jobs.add(task)
|
|
244
|
+
log_event(_logger, "scheduler.job.start", job=name, cron=cron_expr)
|
|
245
|
+
try:
|
|
246
|
+
await func()
|
|
247
|
+
log_event(_logger, "scheduler.job.done", job=name)
|
|
248
|
+
except asyncio.CancelledError:
|
|
249
|
+
log_event(_logger, "scheduler.job.cancelled", job=name)
|
|
250
|
+
raise
|
|
251
|
+
except Exception as e:
|
|
252
|
+
log_event(
|
|
253
|
+
_logger,
|
|
254
|
+
"scheduler.job.failed",
|
|
255
|
+
job=name,
|
|
256
|
+
error_type=type(e).__name__,
|
|
257
|
+
error=str(e)[:200],
|
|
258
|
+
)
|
|
259
|
+
finally:
|
|
260
|
+
if task is not None:
|
|
261
|
+
self._running_jobs.discard(task)
|
|
262
|
+
|
|
263
|
+
self._impl.add_job(
|
|
264
|
+
_safe_job,
|
|
265
|
+
trigger=trigger,
|
|
266
|
+
id=name,
|
|
267
|
+
name=name,
|
|
268
|
+
misfire_grace_time=misfire_grace_time,
|
|
269
|
+
coalesce=coalesce,
|
|
270
|
+
replace_existing=True,
|
|
271
|
+
)
|
|
272
|
+
log_event(
|
|
273
|
+
_logger,
|
|
274
|
+
"scheduler.job.registered",
|
|
275
|
+
job=name,
|
|
276
|
+
cron=cron_expr,
|
|
277
|
+
timezone=str(self._impl.timezone),
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def start(self) -> None:
|
|
281
|
+
self._impl.start()
|
|
282
|
+
self._started = True
|
|
283
|
+
log_event(_logger, "scheduler.started")
|
|
284
|
+
|
|
285
|
+
async def shutdown(self, *, cancel_timeout: float = 150.0) -> None:
|
|
286
|
+
"""Stop the scheduler and await in-flight jobs to finalize.
|
|
287
|
+
|
|
288
|
+
1. Signals APScheduler to stop accepting new fires.
|
|
289
|
+
2. Cancels any currently running job tasks so their ``finally``
|
|
290
|
+
blocks run (release DB locks, close transports).
|
|
291
|
+
3. Awaits those tasks up to ``cancel_timeout`` seconds so the
|
|
292
|
+
poller's ``loop.close()`` doesn't land mid-cleanup.
|
|
293
|
+
|
|
294
|
+
``cancel_timeout`` defaults to 150s — comfortably above the
|
|
295
|
+
``WorktreeManager._run_git`` 120s ceiling so a scheduled secops
|
|
296
|
+
sweep that's mid ``git worktree prune`` when SIGTERM arrives
|
|
297
|
+
gets a real chance to finish cleanup before ``loop.close()``
|
|
298
|
+
terminates everything. If your launchd plist /
|
|
299
|
+
systemd unit imposes a stricter ``ExitTimeOut`` /
|
|
300
|
+
``TimeoutStopSec``, raise that limit too — the scheduler can
|
|
301
|
+
only keep the loop alive within the supervisor's kill window.
|
|
302
|
+
|
|
303
|
+
Calling shutdown before ``start`` is a no-op.
|
|
304
|
+
"""
|
|
305
|
+
if not self._started:
|
|
306
|
+
return
|
|
307
|
+
self._impl.shutdown(wait=False)
|
|
308
|
+
self._started = False
|
|
309
|
+
|
|
310
|
+
if self._running_jobs:
|
|
311
|
+
in_flight = list(self._running_jobs)
|
|
312
|
+
for task in in_flight:
|
|
313
|
+
task.cancel()
|
|
314
|
+
try:
|
|
315
|
+
await asyncio.wait_for(
|
|
316
|
+
asyncio.gather(*in_flight, return_exceptions=True),
|
|
317
|
+
timeout=cancel_timeout,
|
|
318
|
+
)
|
|
319
|
+
except asyncio.TimeoutError:
|
|
320
|
+
log_event(
|
|
321
|
+
_logger,
|
|
322
|
+
"scheduler.shutdown.jobs_timed_out",
|
|
323
|
+
count=len(in_flight),
|
|
324
|
+
timeout=cancel_timeout,
|
|
325
|
+
)
|
|
326
|
+
log_event(_logger, "scheduler.shutdown")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def make_scheduler(timezone: str) -> Scheduler:
|
|
330
|
+
"""Build a Scheduler configured for the orchestrator's timezone.
|
|
331
|
+
|
|
332
|
+
Uses MemoryJobStore implicitly (APScheduler's default). The caller owns
|
|
333
|
+
the lifecycle — call ``start()`` after your asyncio loop is up and
|
|
334
|
+
``shutdown()`` in a ``finally`` block alongside other teardown.
|
|
335
|
+
"""
|
|
336
|
+
impl = AsyncIOScheduler(timezone=timezone)
|
|
337
|
+
return Scheduler(impl)
|