ctrlrelay 0.1.5__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 +8 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +69 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +285 -0
- ctrlrelay/bridge/telegram_handler.py +117 -0
- ctrlrelay/cli.py +1449 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +291 -0
- ctrlrelay/core/dispatcher.py +202 -0
- ctrlrelay/core/github.py +272 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +319 -0
- ctrlrelay/core/pr_verifier.py +177 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +167 -0
- ctrlrelay/core/worktree.py +673 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +562 -0
- ctrlrelay/pipelines/post_merge.py +279 -0
- ctrlrelay/pipelines/secops.py +379 -0
- ctrlrelay/transports/__init__.py +33 -0
- ctrlrelay/transports/base.py +47 -0
- ctrlrelay/transports/file_mock.py +94 -0
- ctrlrelay/transports/socket_client.py +180 -0
- ctrlrelay-0.1.5.dist-info/METADATA +251 -0
- ctrlrelay-0.1.5.dist-info/RECORD +36 -0
- ctrlrelay-0.1.5.dist-info/WHEEL +4 -0
- ctrlrelay-0.1.5.dist-info/entry_points.txt +2 -0
- ctrlrelay-0.1.5.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Post-merge handling for dev pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any, Awaitable, Callable
|
|
7
|
+
|
|
8
|
+
from ctrlrelay.core.github import GitHubCLI
|
|
9
|
+
from ctrlrelay.core.obs import get_logger, log_event
|
|
10
|
+
from ctrlrelay.core.pr_watcher import PRWatcher
|
|
11
|
+
from ctrlrelay.transports.base import Transport
|
|
12
|
+
|
|
13
|
+
_logger = get_logger("pipelines.post_merge")
|
|
14
|
+
|
|
15
|
+
# Default merge-watch window. Real PR review cycles commonly exceed a
|
|
16
|
+
# day (weekends, time-zone splits, larger changes), and a silent watch
|
|
17
|
+
# timeout means the linked issue never auto-closes and no merge
|
|
18
|
+
# notification fires. 7 days is the sweet spot: covers typical review
|
|
19
|
+
# lag without keeping a zombie task alive for months on an abandoned PR.
|
|
20
|
+
# Operators can still override via an explicit `timeout=` argument.
|
|
21
|
+
DEFAULT_PR_WATCH_TIMEOUT = 7 * 24 * 60 * 60
|
|
22
|
+
|
|
23
|
+
# After a merge is detected, handle_merge can fail transiently on
|
|
24
|
+
# `gh issue close` or the transport.send (network blip, rate limit).
|
|
25
|
+
# Without retry the whole automation is wasted — PR merged, issue
|
|
26
|
+
# stays open forever. Retry with modest backoff; if all attempts fail,
|
|
27
|
+
# the last failure is logged via dev.pr.watch_failed.
|
|
28
|
+
_HANDLE_MERGE_RETRY_ATTEMPTS = 5
|
|
29
|
+
_HANDLE_MERGE_RETRY_BASE_DELAY = 5 # seconds; doubled each attempt
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def handle_merge(
|
|
33
|
+
repo: str,
|
|
34
|
+
pr_number: int,
|
|
35
|
+
issue_number: int,
|
|
36
|
+
github: GitHubCLI,
|
|
37
|
+
transport: Transport | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Handle post-merge actions."""
|
|
40
|
+
await github.close_issue(
|
|
41
|
+
repo,
|
|
42
|
+
issue_number,
|
|
43
|
+
f"Closed by PR #{pr_number}",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if transport:
|
|
47
|
+
await transport.send(
|
|
48
|
+
f"Issue #{issue_number} closed after PR #{pr_number} merged in {repo}"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def watch_and_handle_merge(
|
|
53
|
+
repo: str,
|
|
54
|
+
pr_number: int,
|
|
55
|
+
issue_number: int,
|
|
56
|
+
github: GitHubCLI,
|
|
57
|
+
transport: Transport | None = None,
|
|
58
|
+
poll_interval: int = 60,
|
|
59
|
+
timeout: int = DEFAULT_PR_WATCH_TIMEOUT,
|
|
60
|
+
) -> bool:
|
|
61
|
+
"""Watch for PR merge and handle post-merge actions."""
|
|
62
|
+
watcher = PRWatcher(github=github, poll_interval=poll_interval)
|
|
63
|
+
|
|
64
|
+
merged = await watcher.wait_for_merge(repo, pr_number, timeout=timeout)
|
|
65
|
+
|
|
66
|
+
if merged:
|
|
67
|
+
await handle_merge(
|
|
68
|
+
repo=repo,
|
|
69
|
+
pr_number=pr_number,
|
|
70
|
+
issue_number=issue_number,
|
|
71
|
+
github=github,
|
|
72
|
+
transport=transport,
|
|
73
|
+
)
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def _handle_merge_with_retry(
|
|
80
|
+
*,
|
|
81
|
+
repo: str,
|
|
82
|
+
pr_number: int,
|
|
83
|
+
issue_number: int,
|
|
84
|
+
github: GitHubCLI,
|
|
85
|
+
transport_factory: Callable[[], Awaitable[Transport | None]] | None,
|
|
86
|
+
session_id: str | None,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Run the post-merge close + notify steps with per-step idempotent
|
|
89
|
+
retry, rebuilding the transport on each attempt so a bridge restart
|
|
90
|
+
mid-retry doesn't permanently break the notification.
|
|
91
|
+
|
|
92
|
+
Idempotency: ``github.close_issue`` is called at most once even
|
|
93
|
+
across retries — if it succeeds but the notification step later
|
|
94
|
+
fails, subsequent attempts skip the close so we don't post
|
|
95
|
+
duplicate "Closed by PR #N" comments.
|
|
96
|
+
|
|
97
|
+
Transport rebuild: ``transport_factory`` is invoked INSIDE each
|
|
98
|
+
attempt. If the bridge socket drops between the watcher start and
|
|
99
|
+
a merge several days later, or during a retry, the next attempt
|
|
100
|
+
gets a fresh connection.
|
|
101
|
+
|
|
102
|
+
On final exhaustion, raises the last exception; pr_watch_task's
|
|
103
|
+
outer handler logs it as dev.pr.watch_failed.
|
|
104
|
+
"""
|
|
105
|
+
# Split the post-merge work into three independent steps with
|
|
106
|
+
# their own idempotency flags. close_issue() in the github layer
|
|
107
|
+
# is NOT atomic — it posts a comment then runs `gh issue close`.
|
|
108
|
+
# If the close fails after the comment succeeded, a retry that
|
|
109
|
+
# called close_issue again would post a DUPLICATE comment.
|
|
110
|
+
# Tracking each sub-step separately ensures every side-effect
|
|
111
|
+
# runs at most once across retries.
|
|
112
|
+
comment_posted = False
|
|
113
|
+
issue_closed = False
|
|
114
|
+
notification_sent = False
|
|
115
|
+
last_exc: Exception | None = None
|
|
116
|
+
delay = _HANDLE_MERGE_RETRY_BASE_DELAY
|
|
117
|
+
comment = f"Closed by PR #{pr_number}"
|
|
118
|
+
notification = (
|
|
119
|
+
f"Issue #{issue_number} closed after PR #{pr_number} merged in {repo}"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
for attempt in range(1, _HANDLE_MERGE_RETRY_ATTEMPTS + 1):
|
|
123
|
+
transport: Transport | None = None
|
|
124
|
+
transport_build_error: Exception | None = None
|
|
125
|
+
if not notification_sent and transport_factory is not None:
|
|
126
|
+
try:
|
|
127
|
+
transport = await transport_factory()
|
|
128
|
+
except Exception as e:
|
|
129
|
+
# Factory raised — bridge likely transiently unavailable.
|
|
130
|
+
# Record the error so we can retry on the next attempt
|
|
131
|
+
# instead of silently dropping the notification.
|
|
132
|
+
transport_build_error = e
|
|
133
|
+
log_event(
|
|
134
|
+
_logger, "dev.pr.watch_transport_failed",
|
|
135
|
+
session_id=session_id, repo=repo,
|
|
136
|
+
issue_number=issue_number, pr_number=pr_number,
|
|
137
|
+
attempt=attempt,
|
|
138
|
+
reason=type(e).__name__, error=str(e)[:200],
|
|
139
|
+
)
|
|
140
|
+
transport = None
|
|
141
|
+
# Factory returned None legitimately (e.g. non-Telegram
|
|
142
|
+
# config or socket intentionally absent) → no notification
|
|
143
|
+
# channel is configured; do NOT retry. `transport_build_error`
|
|
144
|
+
# remains None, so the "done" check below returns cleanly.
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
if not comment_posted:
|
|
148
|
+
await github.comment_on_issue(repo, issue_number, comment)
|
|
149
|
+
comment_posted = True
|
|
150
|
+
if not issue_closed:
|
|
151
|
+
await github._run_gh(
|
|
152
|
+
"issue", "close", str(issue_number), "--repo", repo,
|
|
153
|
+
)
|
|
154
|
+
issue_closed = True
|
|
155
|
+
if not notification_sent and transport is not None:
|
|
156
|
+
await transport.send(notification)
|
|
157
|
+
notification_sent = True
|
|
158
|
+
|
|
159
|
+
# Done when either: we sent the notification this round, OR
|
|
160
|
+
# the factory returned None meaning no channel was ever
|
|
161
|
+
# configured. Fall through to the retry path ONLY if the
|
|
162
|
+
# factory itself raised — that's a transient failure worth
|
|
163
|
+
# retrying so a brief bridge outage doesn't drop the
|
|
164
|
+
# notification permanently.
|
|
165
|
+
if notification_sent or transport_build_error is None:
|
|
166
|
+
return
|
|
167
|
+
raise transport_build_error
|
|
168
|
+
except asyncio.CancelledError:
|
|
169
|
+
raise
|
|
170
|
+
except Exception as e:
|
|
171
|
+
last_exc = e
|
|
172
|
+
log_event(
|
|
173
|
+
_logger, "dev.pr.handle_merge_retry",
|
|
174
|
+
session_id=session_id, repo=repo,
|
|
175
|
+
issue_number=issue_number, pr_number=pr_number,
|
|
176
|
+
attempt=attempt,
|
|
177
|
+
max_attempts=_HANDLE_MERGE_RETRY_ATTEMPTS,
|
|
178
|
+
comment_posted=comment_posted,
|
|
179
|
+
issue_closed=issue_closed,
|
|
180
|
+
notification_sent=notification_sent,
|
|
181
|
+
reason=type(e).__name__, error=str(e)[:200],
|
|
182
|
+
)
|
|
183
|
+
if attempt >= _HANDLE_MERGE_RETRY_ATTEMPTS:
|
|
184
|
+
break
|
|
185
|
+
await asyncio.sleep(delay)
|
|
186
|
+
delay = min(delay * 2, 300)
|
|
187
|
+
finally:
|
|
188
|
+
if transport is not None:
|
|
189
|
+
try:
|
|
190
|
+
await transport.close()
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
assert last_exc is not None
|
|
194
|
+
raise last_exc
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def pr_watch_task(
|
|
198
|
+
*,
|
|
199
|
+
repo: str,
|
|
200
|
+
issue_number: int,
|
|
201
|
+
pr_url: str,
|
|
202
|
+
pr_number: int,
|
|
203
|
+
session_id: str | None,
|
|
204
|
+
github: GitHubCLI,
|
|
205
|
+
transport_factory: Callable[[], Awaitable[Transport | None]] | None = None,
|
|
206
|
+
poll_interval: int = 60,
|
|
207
|
+
timeout: int = DEFAULT_PR_WATCH_TIMEOUT,
|
|
208
|
+
) -> dict[str, Any]:
|
|
209
|
+
"""Background-safe wrapper around the merge watcher that emits
|
|
210
|
+
structured ``dev.pr.*`` events and manages a transport LAZILY —
|
|
211
|
+
the transport is only created AFTER the merge is detected, so a
|
|
212
|
+
bridge that's down or restarted during the multi-day watch window
|
|
213
|
+
doesn't leave us with a stale connection at notification time.
|
|
214
|
+
|
|
215
|
+
``transport_factory`` is an async callable returning a connected
|
|
216
|
+
transport (or None). Called once, right before ``handle_merge``;
|
|
217
|
+
its result is closed immediately after the merge handler runs.
|
|
218
|
+
A raising factory is logged and skipped (merge detection proceeds
|
|
219
|
+
without the notification channel rather than aborting the close).
|
|
220
|
+
|
|
221
|
+
Returns a dict describing the outcome:
|
|
222
|
+
{"merged": bool, "timed_out": bool, "cancelled": bool, "failed": str|None}
|
|
223
|
+
"""
|
|
224
|
+
outcome: dict[str, Any] = {
|
|
225
|
+
"merged": False, "timed_out": False,
|
|
226
|
+
"cancelled": False, "failed": None,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
log_event(
|
|
230
|
+
_logger, "dev.pr.watching",
|
|
231
|
+
session_id=session_id, repo=repo, issue_number=issue_number,
|
|
232
|
+
pr_number=pr_number, pr_url=pr_url,
|
|
233
|
+
)
|
|
234
|
+
try:
|
|
235
|
+
watcher = PRWatcher(github=github, poll_interval=poll_interval)
|
|
236
|
+
merged = await watcher.wait_for_merge(
|
|
237
|
+
repo=repo, pr_number=pr_number, timeout=timeout,
|
|
238
|
+
)
|
|
239
|
+
# Record merge detection BEFORE running the post-merge handler,
|
|
240
|
+
# so an exhausted retry loop (e.g. permanent bridge outage) still
|
|
241
|
+
# leaves `outcome["merged"] = True`. The merge really did happen;
|
|
242
|
+
# only the cleanup chain is degraded, and `outcome["failed"]`
|
|
243
|
+
# already surfaces that via the except branch below.
|
|
244
|
+
outcome["merged"] = merged
|
|
245
|
+
outcome["timed_out"] = not merged
|
|
246
|
+
log_event(
|
|
247
|
+
_logger,
|
|
248
|
+
"dev.pr.merged" if merged else "dev.pr.watch_timeout",
|
|
249
|
+
session_id=session_id, repo=repo, issue_number=issue_number,
|
|
250
|
+
pr_number=pr_number,
|
|
251
|
+
)
|
|
252
|
+
if merged:
|
|
253
|
+
# Defer transport construction to inside the retry loop so
|
|
254
|
+
# each attempt gets a fresh connection — a bridge restart
|
|
255
|
+
# during the watch or between retries doesn't leave us
|
|
256
|
+
# hitting a dead socket forever.
|
|
257
|
+
await _handle_merge_with_retry(
|
|
258
|
+
repo=repo, pr_number=pr_number,
|
|
259
|
+
issue_number=issue_number, github=github,
|
|
260
|
+
transport_factory=transport_factory,
|
|
261
|
+
session_id=session_id,
|
|
262
|
+
)
|
|
263
|
+
except asyncio.CancelledError:
|
|
264
|
+
outcome["cancelled"] = True
|
|
265
|
+
log_event(
|
|
266
|
+
_logger, "dev.pr.watch_cancelled",
|
|
267
|
+
session_id=session_id, repo=repo, issue_number=issue_number,
|
|
268
|
+
pr_number=pr_number,
|
|
269
|
+
)
|
|
270
|
+
raise
|
|
271
|
+
except Exception as e:
|
|
272
|
+
outcome["failed"] = type(e).__name__
|
|
273
|
+
log_event(
|
|
274
|
+
_logger, "dev.pr.watch_failed",
|
|
275
|
+
session_id=session_id, repo=repo, issue_number=issue_number,
|
|
276
|
+
pr_number=pr_number,
|
|
277
|
+
reason=type(e).__name__, error=str(e)[:200],
|
|
278
|
+
)
|
|
279
|
+
return outcome
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Secops pipeline for security triage across repos."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ctrlrelay.core.checkpoint import CheckpointStatus
|
|
13
|
+
from ctrlrelay.core.dispatcher import AgentAdapter, SessionResult
|
|
14
|
+
from ctrlrelay.core.github import GitHubCLI
|
|
15
|
+
from ctrlrelay.core.obs import get_logger, hash_text, log_event
|
|
16
|
+
from ctrlrelay.core.state import StateDB
|
|
17
|
+
from ctrlrelay.core.worktree import WorktreeManager
|
|
18
|
+
from ctrlrelay.dashboard.client import DashboardClient, EventPayload
|
|
19
|
+
from ctrlrelay.pipelines.base import PipelineContext, PipelineResult
|
|
20
|
+
from ctrlrelay.transports.base import Transport
|
|
21
|
+
|
|
22
|
+
_logger = get_logger("pipeline.secops")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class SecopsPipeline:
|
|
27
|
+
"""Security operations pipeline for daily triage."""
|
|
28
|
+
|
|
29
|
+
dispatcher: AgentAdapter
|
|
30
|
+
github: GitHubCLI
|
|
31
|
+
worktree: WorktreeManager
|
|
32
|
+
dashboard: DashboardClient | None
|
|
33
|
+
state_db: StateDB
|
|
34
|
+
transport: Transport | None
|
|
35
|
+
|
|
36
|
+
name: str = "secops"
|
|
37
|
+
|
|
38
|
+
async def run(self, ctx: PipelineContext) -> PipelineResult:
|
|
39
|
+
"""Run secops on a single repo."""
|
|
40
|
+
prompt = self._build_prompt(
|
|
41
|
+
ctx.repo,
|
|
42
|
+
session_id=ctx.session_id,
|
|
43
|
+
state_file=ctx.state_file,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
result = await self.dispatcher.spawn_session(
|
|
47
|
+
session_id=ctx.session_id,
|
|
48
|
+
prompt=prompt,
|
|
49
|
+
working_dir=ctx.worktree_path,
|
|
50
|
+
state_file=ctx.state_file,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return self._session_to_result(result)
|
|
54
|
+
|
|
55
|
+
async def resume(self, ctx: PipelineContext, answer: str) -> PipelineResult:
|
|
56
|
+
"""Resume blocked secops with user answer."""
|
|
57
|
+
prompt = f"User answered: {answer}\n\nContinue from where you left off."
|
|
58
|
+
|
|
59
|
+
log_event(
|
|
60
|
+
_logger,
|
|
61
|
+
"dev.session.resumed",
|
|
62
|
+
session_id=ctx.session_id,
|
|
63
|
+
repo=ctx.repo,
|
|
64
|
+
issue_number=ctx.issue_number,
|
|
65
|
+
pipeline=self.name,
|
|
66
|
+
resume_session_id=ctx.session_id,
|
|
67
|
+
answer_length=len(answer),
|
|
68
|
+
answer_hash=hash_text(answer),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
result = await self.dispatcher.spawn_session(
|
|
72
|
+
session_id=ctx.session_id,
|
|
73
|
+
prompt=prompt,
|
|
74
|
+
working_dir=ctx.worktree_path,
|
|
75
|
+
state_file=ctx.state_file,
|
|
76
|
+
resume_session_id=ctx.session_id,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return self._session_to_result(result)
|
|
80
|
+
|
|
81
|
+
def _build_prompt(
|
|
82
|
+
self,
|
|
83
|
+
repo: str,
|
|
84
|
+
session_id: str = "",
|
|
85
|
+
state_file: Path | None = None,
|
|
86
|
+
) -> str:
|
|
87
|
+
"""Build the secops prompt."""
|
|
88
|
+
state_file_path = str(state_file) if state_file else "/tmp/state.json"
|
|
89
|
+
|
|
90
|
+
return f"""Execute security operations for repository {repo}.
|
|
91
|
+
|
|
92
|
+
1. Check Dependabot alerts:
|
|
93
|
+
`gh api repos/{repo}/dependabot/alerts --jq '.[] | select(.state=="open")'`
|
|
94
|
+
2. Check security PRs:
|
|
95
|
+
`gh pr list --repo {repo} --author "app/dependabot" --json number,title`
|
|
96
|
+
3. For each alert or PR:
|
|
97
|
+
- Review the severity and impact
|
|
98
|
+
- If patch/minor update with passing CI, merge the PR
|
|
99
|
+
- If major or unclear, signal BLOCKED to ask for guidance
|
|
100
|
+
4. Summarize actions taken
|
|
101
|
+
|
|
102
|
+
## Signaling Completion
|
|
103
|
+
|
|
104
|
+
**CRITICAL**: Before exiting, you MUST write a checkpoint file to signal completion.
|
|
105
|
+
|
|
106
|
+
STATE_FILE: {state_file_path}
|
|
107
|
+
SESSION_ID: {session_id}
|
|
108
|
+
|
|
109
|
+
**DONE** (completed):
|
|
110
|
+
```bash
|
|
111
|
+
mkdir -p "$(dirname '{state_file_path}')"
|
|
112
|
+
printf '{{"version":"1","status":"DONE","session_id":"{session_id}",'\
|
|
113
|
+
'"timestamp":"%s","summary":"%s"}}' \\
|
|
114
|
+
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<SUMMARY>" > '{state_file_path}'
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**BLOCKED** (need input):
|
|
118
|
+
```bash
|
|
119
|
+
mkdir -p "$(dirname '{state_file_path}')"
|
|
120
|
+
printf '{{"version":"1","status":"BLOCKED_NEEDS_INPUT",'\
|
|
121
|
+
'"session_id":"{session_id}","timestamp":"%s","question":"%s"}}' \\
|
|
122
|
+
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<QUESTION>" > '{state_file_path}'
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**FAILED**:
|
|
126
|
+
```bash
|
|
127
|
+
mkdir -p "$(dirname '{state_file_path}')"
|
|
128
|
+
printf '{{"version":"1","status":"FAILED","session_id":"{session_id}",'\
|
|
129
|
+
'"timestamp":"%s","error":"%s"}}' \\
|
|
130
|
+
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<ERROR>" > '{state_file_path}'
|
|
131
|
+
```"""
|
|
132
|
+
|
|
133
|
+
def _session_to_result(self, result: SessionResult) -> PipelineResult:
|
|
134
|
+
"""Convert SessionResult to PipelineResult."""
|
|
135
|
+
if result.state is None:
|
|
136
|
+
return PipelineResult(
|
|
137
|
+
success=False,
|
|
138
|
+
session_id=result.session_id,
|
|
139
|
+
summary="No checkpoint state returned",
|
|
140
|
+
error=result.stderr or "Unknown error",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if result.state.status == CheckpointStatus.DONE:
|
|
144
|
+
return PipelineResult(
|
|
145
|
+
success=True,
|
|
146
|
+
session_id=result.session_id,
|
|
147
|
+
summary=result.state.summary or "Completed",
|
|
148
|
+
outputs=result.state.outputs,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if result.state.status == CheckpointStatus.BLOCKED_NEEDS_INPUT:
|
|
152
|
+
return PipelineResult(
|
|
153
|
+
success=False,
|
|
154
|
+
session_id=result.session_id,
|
|
155
|
+
summary="Blocked on user input",
|
|
156
|
+
blocked=True,
|
|
157
|
+
question=result.state.question,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return PipelineResult(
|
|
161
|
+
success=False,
|
|
162
|
+
session_id=result.session_id,
|
|
163
|
+
summary="Failed",
|
|
164
|
+
error=result.state.error,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def run_secops_all(
|
|
169
|
+
repos: list[Any],
|
|
170
|
+
dispatcher: AgentAdapter,
|
|
171
|
+
github: GitHubCLI,
|
|
172
|
+
worktree: WorktreeManager,
|
|
173
|
+
dashboard: DashboardClient | None,
|
|
174
|
+
state_db: StateDB,
|
|
175
|
+
transport: Transport | None,
|
|
176
|
+
contexts_dir: Path,
|
|
177
|
+
) -> list[PipelineResult]:
|
|
178
|
+
"""Run secops pipeline on all configured repos."""
|
|
179
|
+
results = []
|
|
180
|
+
|
|
181
|
+
pipeline = SecopsPipeline(
|
|
182
|
+
dispatcher=dispatcher,
|
|
183
|
+
github=github,
|
|
184
|
+
worktree=worktree,
|
|
185
|
+
dashboard=dashboard,
|
|
186
|
+
state_db=state_db,
|
|
187
|
+
transport=transport,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
for repo_config in repos:
|
|
191
|
+
repo = repo_config.name
|
|
192
|
+
session_id = f"secops-{repo.replace('/', '-')}-{uuid.uuid4().hex[:8]}"
|
|
193
|
+
|
|
194
|
+
if not state_db.acquire_lock(repo, session_id):
|
|
195
|
+
results.append(PipelineResult(
|
|
196
|
+
success=False,
|
|
197
|
+
session_id=session_id,
|
|
198
|
+
summary=f"Could not acquire lock for {repo}",
|
|
199
|
+
error="Repository locked by another session",
|
|
200
|
+
))
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
worktree_path: Path | None = None
|
|
204
|
+
session_row_inserted = False
|
|
205
|
+
session_final_state_written = False
|
|
206
|
+
try:
|
|
207
|
+
await worktree.ensure_bare_repo(repo)
|
|
208
|
+
worktree_path = await worktree.create_worktree(repo, session_id)
|
|
209
|
+
|
|
210
|
+
context_path = contexts_dir / repo.replace("/", "-") / "CLAUDE.md"
|
|
211
|
+
if context_path.exists():
|
|
212
|
+
worktree.symlink_context(worktree_path, context_path)
|
|
213
|
+
|
|
214
|
+
state_file = worktree_path / ".ctrlrelay" / "state.json"
|
|
215
|
+
state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
|
|
217
|
+
ctx = PipelineContext(
|
|
218
|
+
session_id=session_id,
|
|
219
|
+
repo=repo,
|
|
220
|
+
worktree_path=worktree_path,
|
|
221
|
+
context_path=context_path,
|
|
222
|
+
state_file=state_file,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
state_db.execute(
|
|
226
|
+
"""INSERT INTO sessions (id, pipeline, repo, worktree_path, status, started_at)
|
|
227
|
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
228
|
+
(session_id, "secops", repo, str(worktree_path), "running", int(time.time())),
|
|
229
|
+
)
|
|
230
|
+
state_db.commit()
|
|
231
|
+
session_row_inserted = True
|
|
232
|
+
|
|
233
|
+
result = await pipeline.run(ctx)
|
|
234
|
+
results.append(result)
|
|
235
|
+
|
|
236
|
+
status = "done" if result.success else ("blocked" if result.blocked else "failed")
|
|
237
|
+
state_db.execute(
|
|
238
|
+
"UPDATE sessions SET status = ?, summary = ?, ended_at = ? WHERE id = ?",
|
|
239
|
+
(status, result.summary, int(time.time()), session_id),
|
|
240
|
+
)
|
|
241
|
+
state_db.commit()
|
|
242
|
+
session_final_state_written = True
|
|
243
|
+
|
|
244
|
+
if dashboard and result.success:
|
|
245
|
+
await dashboard.push_event(EventPayload(
|
|
246
|
+
level="info",
|
|
247
|
+
pipeline="secops",
|
|
248
|
+
repo=repo,
|
|
249
|
+
message=result.summary,
|
|
250
|
+
session_id=session_id,
|
|
251
|
+
))
|
|
252
|
+
|
|
253
|
+
except asyncio.CancelledError:
|
|
254
|
+
# Scheduled secops interrupted mid-run (SIGTERM during a
|
|
255
|
+
# scheduler.shutdown). Mark the session as cancelled so later
|
|
256
|
+
# inspection doesn't see a phantom "running" row — but ONLY
|
|
257
|
+
# if we hadn't already written a final state. Without this
|
|
258
|
+
# guard, a cancel landing during the post-run dashboard push
|
|
259
|
+
# would clobber a successful "done" status with "cancelled"
|
|
260
|
+
# even though the work completed.
|
|
261
|
+
if session_row_inserted and not session_final_state_written:
|
|
262
|
+
try:
|
|
263
|
+
state_db.execute(
|
|
264
|
+
"UPDATE sessions SET status = ?, summary = ?, "
|
|
265
|
+
"ended_at = ? WHERE id = ?",
|
|
266
|
+
(
|
|
267
|
+
"cancelled",
|
|
268
|
+
"Cancelled during shutdown",
|
|
269
|
+
int(time.time()),
|
|
270
|
+
session_id,
|
|
271
|
+
),
|
|
272
|
+
)
|
|
273
|
+
state_db.commit()
|
|
274
|
+
except Exception:
|
|
275
|
+
pass
|
|
276
|
+
raise
|
|
277
|
+
|
|
278
|
+
except Exception as e:
|
|
279
|
+
if session_row_inserted:
|
|
280
|
+
state_db.execute(
|
|
281
|
+
"UPDATE sessions SET status = ?, summary = ?, "
|
|
282
|
+
"ended_at = ? WHERE id = ?",
|
|
283
|
+
("failed", f"Error: {e}", int(time.time()), session_id),
|
|
284
|
+
)
|
|
285
|
+
state_db.commit()
|
|
286
|
+
results.append(PipelineResult(
|
|
287
|
+
success=False,
|
|
288
|
+
session_id=session_id,
|
|
289
|
+
summary=f"Error processing {repo}",
|
|
290
|
+
error=str(e),
|
|
291
|
+
))
|
|
292
|
+
|
|
293
|
+
finally:
|
|
294
|
+
# Hold the per-repo lock THROUGH worktree cleanup so a
|
|
295
|
+
# concurrent dev/secops run can't acquire the same repo and
|
|
296
|
+
# race `git worktree prune` on the shared bare clone.
|
|
297
|
+
# Cleanup is bounded by an asyncio.wait_for timeout so a
|
|
298
|
+
# truly-stuck operation can't pin the lock forever — once
|
|
299
|
+
# the timeout fires we release anyway, preferring "possibly
|
|
300
|
+
# leaked git admin state (recoverable via `git worktree
|
|
301
|
+
# prune`)" over "repo locked across daemon restart".
|
|
302
|
+
#
|
|
303
|
+
# No asyncio.shield here: `_run_git` kills its subprocess
|
|
304
|
+
# synchronously on CancelledError so unshielded cleanup
|
|
305
|
+
# unwinds fast and without leaking a stray git. Previously
|
|
306
|
+
# the shield made the outer task cancel immediately while
|
|
307
|
+
# the inner cleanup kept running untracked — leaking state.
|
|
308
|
+
if worktree_path is not None:
|
|
309
|
+
try:
|
|
310
|
+
worktree.remove_context_symlink(worktree_path)
|
|
311
|
+
except Exception as cleanup_exc:
|
|
312
|
+
log_event(
|
|
313
|
+
_logger,
|
|
314
|
+
"secops.cleanup.symlink_failed",
|
|
315
|
+
session_id=session_id,
|
|
316
|
+
repo=repo,
|
|
317
|
+
error_type=type(cleanup_exc).__name__,
|
|
318
|
+
error=str(cleanup_exc)[:200],
|
|
319
|
+
)
|
|
320
|
+
try:
|
|
321
|
+
# Timeout matches Scheduler's cancel budget (150s) — a
|
|
322
|
+
# full worktree prune can take up to 120s per
|
|
323
|
+
# WorktreeManager._run_git ceiling.
|
|
324
|
+
await asyncio.wait_for(
|
|
325
|
+
worktree.remove_worktree(repo, session_id),
|
|
326
|
+
timeout=130.0,
|
|
327
|
+
)
|
|
328
|
+
except asyncio.TimeoutError:
|
|
329
|
+
log_event(
|
|
330
|
+
_logger,
|
|
331
|
+
"secops.cleanup.worktree_timeout",
|
|
332
|
+
session_id=session_id,
|
|
333
|
+
repo=repo,
|
|
334
|
+
worktree_path=str(worktree_path),
|
|
335
|
+
)
|
|
336
|
+
except asyncio.CancelledError:
|
|
337
|
+
log_event(
|
|
338
|
+
_logger,
|
|
339
|
+
"secops.cleanup.worktree_cancelled_mid_shutdown",
|
|
340
|
+
session_id=session_id,
|
|
341
|
+
repo=repo,
|
|
342
|
+
worktree_path=str(worktree_path),
|
|
343
|
+
)
|
|
344
|
+
# Release the lock before propagating — leaving it
|
|
345
|
+
# held across a daemon restart is worse than the
|
|
346
|
+
# small race with a future scheduled run (the dev
|
|
347
|
+
# handler retries on lock conflict anyway).
|
|
348
|
+
try:
|
|
349
|
+
state_db.release_lock(repo, session_id)
|
|
350
|
+
except Exception:
|
|
351
|
+
pass
|
|
352
|
+
raise
|
|
353
|
+
except Exception as cleanup_exc:
|
|
354
|
+
log_event(
|
|
355
|
+
_logger,
|
|
356
|
+
"secops.cleanup.worktree_failed",
|
|
357
|
+
session_id=session_id,
|
|
358
|
+
repo=repo,
|
|
359
|
+
worktree_path=str(worktree_path),
|
|
360
|
+
error_type=type(cleanup_exc).__name__,
|
|
361
|
+
error=str(cleanup_exc)[:200],
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# Release the lock AFTER cleanup attempt so new runs don't
|
|
365
|
+
# race the prune. In the cancel path above we released early
|
|
366
|
+
# and re-raised; here we handle the non-cancel paths.
|
|
367
|
+
try:
|
|
368
|
+
state_db.release_lock(repo, session_id)
|
|
369
|
+
except Exception as lock_exc:
|
|
370
|
+
log_event(
|
|
371
|
+
_logger,
|
|
372
|
+
"secops.cleanup.lock_release_failed",
|
|
373
|
+
session_id=session_id,
|
|
374
|
+
repo=repo,
|
|
375
|
+
error_type=type(lock_exc).__name__,
|
|
376
|
+
error=str(lock_exc)[:200],
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
return results
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Transport abstraction for orchestrator communication."""
|
|
2
|
+
|
|
3
|
+
from ctrlrelay.core.config import TransportConfig, TransportType
|
|
4
|
+
from ctrlrelay.transports.base import Transport, TransportError
|
|
5
|
+
from ctrlrelay.transports.file_mock import FileMockTransport
|
|
6
|
+
from ctrlrelay.transports.socket_client import SocketTransport
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_transport(config: TransportConfig) -> Transport:
|
|
10
|
+
"""Create transport instance from config."""
|
|
11
|
+
if config.type == TransportType.FILE_MOCK:
|
|
12
|
+
assert config.file_mock is not None
|
|
13
|
+
return FileMockTransport(
|
|
14
|
+
inbox=config.file_mock.inbox,
|
|
15
|
+
outbox=config.file_mock.outbox,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if config.type == TransportType.TELEGRAM:
|
|
19
|
+
assert config.telegram is not None
|
|
20
|
+
return SocketTransport(
|
|
21
|
+
socket_path=config.telegram.socket_path,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
raise TransportError(f"Unknown transport type: {config.type}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"FileMockTransport",
|
|
29
|
+
"SocketTransport",
|
|
30
|
+
"Transport",
|
|
31
|
+
"TransportError",
|
|
32
|
+
"get_transport",
|
|
33
|
+
]
|