ctrlrelay 0.10.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ctrlrelay/__init__.py +33 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +89 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +740 -0
- ctrlrelay/bridge/telegram_handler.py +136 -0
- ctrlrelay/cli.py +3138 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +778 -0
- ctrlrelay/core/dispatcher.py +372 -0
- ctrlrelay/core/github.py +432 -0
- ctrlrelay/core/network.py +277 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +920 -0
- ctrlrelay/core/pr_verifier.py +230 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/question_expiry.py +193 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +642 -0
- ctrlrelay/core/worktree.py +893 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/gh_protocol.py +47 -0
- ctrlrelay/install.py +203 -0
- ctrlrelay/personalization/__init__.py +26 -0
- ctrlrelay/personalization/manager.py +1183 -0
- ctrlrelay/personalization/paths.py +143 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +1367 -0
- ctrlrelay/pipelines/post_merge.py +411 -0
- ctrlrelay/pipelines/secops.py +1018 -0
- ctrlrelay/pipelines/task.py +713 -0
- ctrlrelay/setup.py +617 -0
- ctrlrelay/templates/__init__.py +0 -0
- ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
- ctrlrelay/templates/launchd/__init__.py +0 -0
- ctrlrelay/templates/launchd/bridge.plist.template +37 -0
- ctrlrelay/templates/launchd/poller.plist.template +44 -0
- ctrlrelay/templates/systemd/__init__.py +0 -0
- ctrlrelay/templates/systemd/bridge.service.template +20 -0
- ctrlrelay/templates/systemd/poller.service.template +25 -0
- ctrlrelay/transports/__init__.py +34 -0
- ctrlrelay/transports/base.py +74 -0
- ctrlrelay/transports/file_mock.py +103 -0
- ctrlrelay/transports/socket_client.py +298 -0
- ctrlrelay-0.10.0.dist-info/METADATA +254 -0
- ctrlrelay-0.10.0.dist-info/RECORD +53 -0
- ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
- ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
- ctrlrelay-0.10.0.dist-info/licenses/LICENSE +201 -0
ctrlrelay/core/state.py
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
"""SQLite state management for ctrlrelay orchestrator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
SCHEMA = """
|
|
11
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
12
|
+
id TEXT PRIMARY KEY,
|
|
13
|
+
pipeline TEXT NOT NULL,
|
|
14
|
+
repo TEXT NOT NULL,
|
|
15
|
+
issue_number INTEGER,
|
|
16
|
+
worktree_path TEXT,
|
|
17
|
+
status TEXT NOT NULL,
|
|
18
|
+
blocked_question TEXT,
|
|
19
|
+
started_at INTEGER NOT NULL,
|
|
20
|
+
ended_at INTEGER,
|
|
21
|
+
claude_exit_code INTEGER,
|
|
22
|
+
summary TEXT,
|
|
23
|
+
agent_session_id TEXT
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS repo_locks (
|
|
27
|
+
repo TEXT PRIMARY KEY,
|
|
28
|
+
session_id TEXT NOT NULL,
|
|
29
|
+
acquired_at INTEGER NOT NULL
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE TABLE IF NOT EXISTS github_cursor (
|
|
33
|
+
repo TEXT PRIMARY KEY,
|
|
34
|
+
last_checked_at INTEGER NOT NULL,
|
|
35
|
+
last_seen_issue_update TEXT
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE TABLE IF NOT EXISTS telegram_pending (
|
|
39
|
+
request_id TEXT PRIMARY KEY,
|
|
40
|
+
session_id TEXT NOT NULL,
|
|
41
|
+
question TEXT NOT NULL,
|
|
42
|
+
asked_at INTEGER NOT NULL,
|
|
43
|
+
answered_at INTEGER,
|
|
44
|
+
answer TEXT
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS automation_decisions (
|
|
48
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
49
|
+
repo TEXT NOT NULL,
|
|
50
|
+
operation TEXT NOT NULL,
|
|
51
|
+
policy TEXT NOT NULL,
|
|
52
|
+
item_id TEXT,
|
|
53
|
+
decision TEXT,
|
|
54
|
+
decided_by TEXT,
|
|
55
|
+
decided_at INTEGER,
|
|
56
|
+
context TEXT
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
-- Sessions that exited BLOCKED_NEEDS_INPUT and can be resumed by an
|
|
60
|
+
-- operator reply arriving AFTER the session has already torn down.
|
|
61
|
+
-- Without this, a Telegram reply to a closed session disappears silently
|
|
62
|
+
-- because the bridge's in-memory _pending_questions entry dies with the
|
|
63
|
+
-- session socket. A scheduled sweeper in the poller picks up rows where
|
|
64
|
+
-- answered_at IS NOT NULL AND resumed_at IS NULL and drives the resume.
|
|
65
|
+
CREATE TABLE IF NOT EXISTS pending_resumes (
|
|
66
|
+
session_id TEXT PRIMARY KEY,
|
|
67
|
+
pipeline TEXT NOT NULL,
|
|
68
|
+
repo TEXT NOT NULL,
|
|
69
|
+
question TEXT NOT NULL,
|
|
70
|
+
created_at INTEGER NOT NULL,
|
|
71
|
+
answer TEXT,
|
|
72
|
+
answered_at INTEGER,
|
|
73
|
+
resumed_at INTEGER,
|
|
74
|
+
expired_at INTEGER,
|
|
75
|
+
expired_reason TEXT
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
-- In-flight PR merge watchers that must survive a poller restart. Without
|
|
79
|
+
-- this, a launchd kickstart / crash / redeploy / reboot cancels the
|
|
80
|
+
-- in-memory asyncio watcher tasks and any PR that gets merged afterwards
|
|
81
|
+
-- silently loses its post-merge automation (issue close + Telegram ping)
|
|
82
|
+
-- for the remainder of the 7-day watch window. Poller startup rehydrates
|
|
83
|
+
-- one `pr_watch_task` per surviving row before `run_poll_loop` enters its
|
|
84
|
+
-- main loop. Rows are inserted on `dev.pr.watching` and deleted on the
|
|
85
|
+
-- terminal `dev.pr.merged` / `dev.pr.watch_timeout` events only — a
|
|
86
|
+
-- `dev.pr.watch_cancelled` (shutdown-driven cancellation, not operator
|
|
87
|
+
-- intent) deliberately leaves the row in place so the next startup
|
|
88
|
+
-- resumes the watch. `pr_number` is the unique row key because we watch
|
|
89
|
+
-- at most one PR per (repo, pr_number).
|
|
90
|
+
CREATE TABLE IF NOT EXISTS pr_watches (
|
|
91
|
+
repo TEXT NOT NULL,
|
|
92
|
+
pr_number INTEGER NOT NULL,
|
|
93
|
+
session_id TEXT,
|
|
94
|
+
issue_number INTEGER NOT NULL,
|
|
95
|
+
pr_url TEXT,
|
|
96
|
+
started_at INTEGER NOT NULL,
|
|
97
|
+
cleanup_phase TEXT,
|
|
98
|
+
PRIMARY KEY (repo, pr_number)
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_repo ON sessions(repo);
|
|
102
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
|
103
|
+
CREATE INDEX IF NOT EXISTS idx_automation_repo ON automation_decisions(repo);
|
|
104
|
+
CREATE INDEX IF NOT EXISTS idx_pending_resumes_unanswered
|
|
105
|
+
ON pending_resumes(answered_at) WHERE answered_at IS NULL;
|
|
106
|
+
CREATE INDEX IF NOT EXISTS idx_pending_resumes_answered_unresumed
|
|
107
|
+
ON pending_resumes(answered_at, resumed_at)
|
|
108
|
+
WHERE answered_at IS NOT NULL AND resumed_at IS NULL;
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class StateDB:
|
|
113
|
+
"""SQLite database for orchestrator state.
|
|
114
|
+
|
|
115
|
+
Thread-safety: Each thread/async context should create its own StateDB instance.
|
|
116
|
+
The underlying SQLite connection is not shared.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self, db_path: Path | str) -> None:
|
|
120
|
+
"""Initialize the database, creating tables if needed.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
db_path: Path to the SQLite database file.
|
|
124
|
+
"""
|
|
125
|
+
self.db_path = Path(db_path)
|
|
126
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
|
|
128
|
+
self._conn = sqlite3.connect(str(self.db_path))
|
|
129
|
+
self._conn.row_factory = sqlite3.Row
|
|
130
|
+
self._conn.executescript(SCHEMA)
|
|
131
|
+
self._migrate()
|
|
132
|
+
self._conn.commit()
|
|
133
|
+
|
|
134
|
+
def _migrate(self) -> None:
|
|
135
|
+
"""Apply in-place schema additions to databases written by older versions.
|
|
136
|
+
|
|
137
|
+
SQLite's ``CREATE TABLE IF NOT EXISTS`` preserves the *existing* shape
|
|
138
|
+
if the table was created by a prior version, so additive column bumps
|
|
139
|
+
need a guarded ALTER. We check ``PRAGMA table_info`` and only ALTER
|
|
140
|
+
when the column is missing.
|
|
141
|
+
"""
|
|
142
|
+
existing = {
|
|
143
|
+
row[1]
|
|
144
|
+
for row in self._conn.execute("PRAGMA table_info(sessions)").fetchall()
|
|
145
|
+
}
|
|
146
|
+
if "agent_session_id" not in existing:
|
|
147
|
+
self._conn.execute(
|
|
148
|
+
"ALTER TABLE sessions ADD COLUMN agent_session_id TEXT"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
pr_watches_cols = {
|
|
152
|
+
row[1]
|
|
153
|
+
for row in self._conn.execute(
|
|
154
|
+
"PRAGMA table_info(pr_watches)"
|
|
155
|
+
).fetchall()
|
|
156
|
+
}
|
|
157
|
+
if pr_watches_cols and "cleanup_phase" not in pr_watches_cols:
|
|
158
|
+
self._conn.execute(
|
|
159
|
+
"ALTER TABLE pr_watches ADD COLUMN cleanup_phase TEXT"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
pending_cols = {
|
|
163
|
+
row[1]
|
|
164
|
+
for row in self._conn.execute(
|
|
165
|
+
"PRAGMA table_info(pending_resumes)"
|
|
166
|
+
).fetchall()
|
|
167
|
+
}
|
|
168
|
+
if pending_cols and "expired_at" not in pending_cols:
|
|
169
|
+
self._conn.execute(
|
|
170
|
+
"ALTER TABLE pending_resumes ADD COLUMN expired_at INTEGER"
|
|
171
|
+
)
|
|
172
|
+
if pending_cols and "expired_reason" not in pending_cols:
|
|
173
|
+
self._conn.execute(
|
|
174
|
+
"ALTER TABLE pending_resumes ADD COLUMN expired_reason TEXT"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def close(self) -> None:
|
|
178
|
+
"""Close the database connection."""
|
|
179
|
+
self._conn.close()
|
|
180
|
+
|
|
181
|
+
def execute(self, sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Cursor:
|
|
182
|
+
"""Execute a SQL statement.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
sql: SQL statement to execute.
|
|
186
|
+
params: Parameters for the statement.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Cursor with results.
|
|
190
|
+
"""
|
|
191
|
+
return self._conn.execute(sql, params)
|
|
192
|
+
|
|
193
|
+
def commit(self) -> None:
|
|
194
|
+
"""Commit the current transaction."""
|
|
195
|
+
self._conn.commit()
|
|
196
|
+
|
|
197
|
+
# Repo locks
|
|
198
|
+
|
|
199
|
+
def acquire_lock(self, repo: str, session_id: str) -> bool:
|
|
200
|
+
"""Attempt to acquire a lock on a repository.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
repo: Repository name (e.g., "owner/repo").
|
|
204
|
+
session_id: Session ID acquiring the lock.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
True if lock was acquired, False if already held.
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
self._conn.execute(
|
|
211
|
+
"INSERT INTO repo_locks (repo, session_id, acquired_at) VALUES (?, ?, ?)",
|
|
212
|
+
(repo, session_id, int(time.time())),
|
|
213
|
+
)
|
|
214
|
+
self._conn.commit()
|
|
215
|
+
return True
|
|
216
|
+
except sqlite3.IntegrityError:
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
def release_lock(self, repo: str, session_id: str) -> bool:
|
|
220
|
+
"""Release a lock on a repository.
|
|
221
|
+
|
|
222
|
+
Only releases if the lock is held by the specified session.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
repo: Repository name to unlock.
|
|
226
|
+
session_id: Session ID that should own the lock.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
True if lock was released, False if not held by this session.
|
|
230
|
+
"""
|
|
231
|
+
cursor = self._conn.execute(
|
|
232
|
+
"DELETE FROM repo_locks WHERE repo = ? AND session_id = ?",
|
|
233
|
+
(repo, session_id),
|
|
234
|
+
)
|
|
235
|
+
self._conn.commit()
|
|
236
|
+
return cursor.rowcount > 0
|
|
237
|
+
|
|
238
|
+
def get_lock_holder(self, repo: str) -> str | None:
|
|
239
|
+
"""Get the session ID holding a lock.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
repo: Repository name.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Session ID if locked, None otherwise.
|
|
246
|
+
"""
|
|
247
|
+
row = self._conn.execute(
|
|
248
|
+
"SELECT session_id FROM repo_locks WHERE repo = ?", (repo,)
|
|
249
|
+
).fetchone()
|
|
250
|
+
return row["session_id"] if row else None
|
|
251
|
+
|
|
252
|
+
def list_locks(self) -> list[dict[str, Any]]:
|
|
253
|
+
"""List all current locks.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
List of lock records.
|
|
257
|
+
"""
|
|
258
|
+
rows = self._conn.execute("SELECT * FROM repo_locks").fetchall()
|
|
259
|
+
return [dict(row) for row in rows]
|
|
260
|
+
|
|
261
|
+
# Agent session ids
|
|
262
|
+
|
|
263
|
+
def set_agent_session_id(self, session_id: str, agent_session_id: str) -> None:
|
|
264
|
+
"""Persist Claude's session UUID against our composite session id.
|
|
265
|
+
|
|
266
|
+
``agent_session_id`` is what ``claude --resume`` needs — our
|
|
267
|
+
composite id fails validation on newer CLI versions.
|
|
268
|
+
"""
|
|
269
|
+
self._conn.execute(
|
|
270
|
+
"UPDATE sessions SET agent_session_id = ? WHERE id = ?",
|
|
271
|
+
(agent_session_id, session_id),
|
|
272
|
+
)
|
|
273
|
+
self._conn.commit()
|
|
274
|
+
|
|
275
|
+
def get_agent_session_id(self, session_id: str) -> str | None:
|
|
276
|
+
"""Fetch Claude's session UUID for a given composite session id.
|
|
277
|
+
|
|
278
|
+
Returns ``None`` if the row is missing or the column was never set
|
|
279
|
+
(sessions that predate the agent-uuid capture).
|
|
280
|
+
"""
|
|
281
|
+
row = self._conn.execute(
|
|
282
|
+
"SELECT agent_session_id FROM sessions WHERE id = ?",
|
|
283
|
+
(session_id,),
|
|
284
|
+
).fetchone()
|
|
285
|
+
if row is None:
|
|
286
|
+
return None
|
|
287
|
+
value = row["agent_session_id"]
|
|
288
|
+
return value if value else None
|
|
289
|
+
|
|
290
|
+
def get_session_row(self, session_id: str) -> dict[str, Any] | None:
|
|
291
|
+
"""Full sessions-row snapshot, used when the pending-resume
|
|
292
|
+
sweeper needs pipeline-specific context (issue_number for dev,
|
|
293
|
+
worktree_path for sanity checks, etc.) that isn't mirrored into
|
|
294
|
+
pending_resumes."""
|
|
295
|
+
row = self._conn.execute(
|
|
296
|
+
"SELECT * FROM sessions WHERE id = ?",
|
|
297
|
+
(session_id,),
|
|
298
|
+
).fetchone()
|
|
299
|
+
return dict(row) if row else None
|
|
300
|
+
|
|
301
|
+
# Pending resumes (BLOCKED sessions awaiting an operator answer)
|
|
302
|
+
|
|
303
|
+
def add_pending_resume(
|
|
304
|
+
self,
|
|
305
|
+
session_id: str,
|
|
306
|
+
pipeline: str,
|
|
307
|
+
repo: str,
|
|
308
|
+
question: str,
|
|
309
|
+
) -> None:
|
|
310
|
+
"""Record that a session exited BLOCKED_NEEDS_INPUT and can be
|
|
311
|
+
resumed if an operator reply arrives later. Idempotent: re-inserting
|
|
312
|
+
the same session_id refreshes ``created_at`` and clears any stale
|
|
313
|
+
answer so a new BLOCKED on the same session_id starts fresh."""
|
|
314
|
+
self._conn.execute(
|
|
315
|
+
"""INSERT OR REPLACE INTO pending_resumes
|
|
316
|
+
(session_id, pipeline, repo, question, created_at,
|
|
317
|
+
answer, answered_at, resumed_at)
|
|
318
|
+
VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL)""",
|
|
319
|
+
(session_id, pipeline, repo, question, int(time.time())),
|
|
320
|
+
)
|
|
321
|
+
self._conn.commit()
|
|
322
|
+
|
|
323
|
+
def get_oldest_unanswered_pending_resume(
|
|
324
|
+
self,
|
|
325
|
+
pipeline: str | None = None,
|
|
326
|
+
) -> dict[str, Any] | None:
|
|
327
|
+
"""Return the oldest BLOCKED session still awaiting an operator
|
|
328
|
+
answer, optionally filtered by pipeline. Used by the bridge to
|
|
329
|
+
route an orphan Telegram reply when there's no in-memory pending
|
|
330
|
+
question to match against."""
|
|
331
|
+
if pipeline is None:
|
|
332
|
+
row = self._conn.execute(
|
|
333
|
+
"SELECT * FROM pending_resumes "
|
|
334
|
+
"WHERE answered_at IS NULL AND expired_at IS NULL "
|
|
335
|
+
"ORDER BY created_at ASC LIMIT 1"
|
|
336
|
+
).fetchone()
|
|
337
|
+
else:
|
|
338
|
+
row = self._conn.execute(
|
|
339
|
+
"SELECT * FROM pending_resumes "
|
|
340
|
+
"WHERE answered_at IS NULL AND expired_at IS NULL "
|
|
341
|
+
"AND pipeline = ? "
|
|
342
|
+
"ORDER BY created_at ASC LIMIT 1",
|
|
343
|
+
(pipeline,),
|
|
344
|
+
).fetchone()
|
|
345
|
+
return dict(row) if row else None
|
|
346
|
+
|
|
347
|
+
def list_unanswered_pending_resumes(self) -> list[dict[str, Any]]:
|
|
348
|
+
"""All BLOCKED sessions still awaiting an answer, oldest first.
|
|
349
|
+
Used by the bridge to disambiguate when multiple repos are blocked
|
|
350
|
+
at once — FIFO routing would otherwise send the operator's reply
|
|
351
|
+
about repo B onto repo A."""
|
|
352
|
+
rows = self._conn.execute(
|
|
353
|
+
"SELECT * FROM pending_resumes "
|
|
354
|
+
"WHERE answered_at IS NULL AND expired_at IS NULL "
|
|
355
|
+
"ORDER BY created_at ASC"
|
|
356
|
+
).fetchall()
|
|
357
|
+
return [dict(row) for row in rows]
|
|
358
|
+
|
|
359
|
+
def expire_pending_resume(self, session_id: str, reason: str) -> bool:
|
|
360
|
+
"""Retire an unanswered question so it stops being routable.
|
|
361
|
+
|
|
362
|
+
A question nobody can act on is worse than no question: the
|
|
363
|
+
bridge keeps offering it as a target for an orphan reply, and
|
|
364
|
+
the operator keeps seeing it re-posted. Guarded on
|
|
365
|
+
``answered_at IS NULL`` so a reply that landed in the same tick
|
|
366
|
+
wins the race — expiry must never discard a real answer.
|
|
367
|
+
|
|
368
|
+
Returns True iff a row was expired."""
|
|
369
|
+
cursor = self._conn.execute(
|
|
370
|
+
"""UPDATE pending_resumes
|
|
371
|
+
SET expired_at = ?, expired_reason = ?
|
|
372
|
+
WHERE session_id = ? AND answered_at IS NULL
|
|
373
|
+
AND expired_at IS NULL""",
|
|
374
|
+
(int(time.time()), reason, session_id),
|
|
375
|
+
)
|
|
376
|
+
self._conn.commit()
|
|
377
|
+
return cursor.rowcount > 0
|
|
378
|
+
|
|
379
|
+
def list_expirable_pending_resumes(
|
|
380
|
+
self,
|
|
381
|
+
older_than_ts: int,
|
|
382
|
+
) -> list[dict[str, Any]]:
|
|
383
|
+
"""Unanswered, unexpired questions created at or before
|
|
384
|
+
``older_than_ts``. The TTL sweep's candidate set."""
|
|
385
|
+
rows = self._conn.execute(
|
|
386
|
+
"SELECT * FROM pending_resumes "
|
|
387
|
+
"WHERE answered_at IS NULL AND expired_at IS NULL "
|
|
388
|
+
"AND created_at <= ? "
|
|
389
|
+
"ORDER BY created_at ASC",
|
|
390
|
+
(older_than_ts,),
|
|
391
|
+
).fetchall()
|
|
392
|
+
return [dict(row) for row in rows]
|
|
393
|
+
|
|
394
|
+
def answer_pending_resume(self, session_id: str, answer: str) -> bool:
|
|
395
|
+
"""Attach an operator's answer to a pending resume, marking it
|
|
396
|
+
ready for the sweeper to execute. Returns True if a row was
|
|
397
|
+
updated, False if the session_id was unknown or already answered."""
|
|
398
|
+
cursor = self._conn.execute(
|
|
399
|
+
"""UPDATE pending_resumes
|
|
400
|
+
SET answer = ?, answered_at = ?
|
|
401
|
+
WHERE session_id = ? AND answered_at IS NULL
|
|
402
|
+
AND expired_at IS NULL""",
|
|
403
|
+
(answer, int(time.time()), session_id),
|
|
404
|
+
)
|
|
405
|
+
self._conn.commit()
|
|
406
|
+
return cursor.rowcount > 0
|
|
407
|
+
|
|
408
|
+
def list_pending_resumes_to_execute(self) -> list[dict[str, Any]]:
|
|
409
|
+
"""Rows that have been answered by the operator but not yet
|
|
410
|
+
resumed. Poller's pending-resume sweeper loads these and drives
|
|
411
|
+
the pipeline resume. Oldest first so FIFO semantics hold.
|
|
412
|
+
|
|
413
|
+
Expired rows are excluded even though ``answer_pending_resume``
|
|
414
|
+
already refuses to answer one. That guard only holds while every
|
|
415
|
+
process runs the same code: the bridge writes the answer and the
|
|
416
|
+
poller reads it back, they are separate daemons, and a deploy
|
|
417
|
+
that restarts one before the other leaves a window where an old
|
|
418
|
+
bridge stamps ``answered_at`` on a row this poller has already
|
|
419
|
+
expired. Filtering on read closes that window from the side that
|
|
420
|
+
actually acts on the row.
|
|
421
|
+
"""
|
|
422
|
+
rows = self._conn.execute(
|
|
423
|
+
"SELECT * FROM pending_resumes "
|
|
424
|
+
"WHERE answered_at IS NOT NULL AND resumed_at IS NULL "
|
|
425
|
+
"AND expired_at IS NULL "
|
|
426
|
+
"ORDER BY answered_at ASC"
|
|
427
|
+
).fetchall()
|
|
428
|
+
return [dict(row) for row in rows]
|
|
429
|
+
|
|
430
|
+
def mark_pending_resume_resumed(self, session_id: str) -> bool:
|
|
431
|
+
"""Mark a pending resume as executed so the sweeper doesn't pick
|
|
432
|
+
it up again.
|
|
433
|
+
|
|
434
|
+
Guarded on ``answered_at IS NOT NULL``: during a resume that
|
|
435
|
+
re-blocks, the pipeline inserts a FRESH pending_resumes row
|
|
436
|
+
(via ``add_pending_resume`` which is INSERT OR REPLACE,
|
|
437
|
+
clearing answer/answered_at/resumed_at). If the sweeper then
|
|
438
|
+
blindly stamped ``resumed_at`` on that fresh unanswered row,
|
|
439
|
+
the next operator reply would set ``answered_at`` but leave
|
|
440
|
+
``resumed_at`` populated, hiding the row from
|
|
441
|
+
``list_pending_resumes_to_execute`` forever and permanently
|
|
442
|
+
wedging the session. The guard makes this a no-op when the
|
|
443
|
+
row has been refreshed.
|
|
444
|
+
|
|
445
|
+
Returns True iff a row was updated."""
|
|
446
|
+
cursor = self._conn.execute(
|
|
447
|
+
"UPDATE pending_resumes SET resumed_at = ? "
|
|
448
|
+
"WHERE session_id = ? AND answered_at IS NOT NULL",
|
|
449
|
+
(int(time.time()), session_id),
|
|
450
|
+
)
|
|
451
|
+
self._conn.commit()
|
|
452
|
+
return cursor.rowcount > 0
|
|
453
|
+
|
|
454
|
+
# Automation decisions (operator answers persisted across sweeps)
|
|
455
|
+
|
|
456
|
+
def record_automation_decision(
|
|
457
|
+
self,
|
|
458
|
+
*,
|
|
459
|
+
repo: str,
|
|
460
|
+
operation: str,
|
|
461
|
+
item_id: str,
|
|
462
|
+
decision: str,
|
|
463
|
+
decided_by: str = "operator",
|
|
464
|
+
context: str | None = None,
|
|
465
|
+
policy: str = "",
|
|
466
|
+
) -> None:
|
|
467
|
+
"""Record an operator decision so future sweeps can avoid
|
|
468
|
+
re-asking the same question.
|
|
469
|
+
|
|
470
|
+
``operation`` namespaces the decision domain
|
|
471
|
+
(e.g. ``"dependabot_pr"`` or ``"codeql_alert"``); ``item_id``
|
|
472
|
+
is the specific item the decision applies to (e.g. ``"#60"``
|
|
473
|
+
for PR #60). ``decision`` is the operator's verbatim answer —
|
|
474
|
+
the agent that reads this back interprets it; we don't try to
|
|
475
|
+
normalize to yes/no here because Telegram replies are free-form.
|
|
476
|
+
"""
|
|
477
|
+
self._conn.execute(
|
|
478
|
+
"""INSERT INTO automation_decisions
|
|
479
|
+
(repo, operation, policy, item_id, decision,
|
|
480
|
+
decided_by, decided_at, context)
|
|
481
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
482
|
+
(repo, operation, policy, item_id, decision,
|
|
483
|
+
decided_by, int(time.time()), context),
|
|
484
|
+
)
|
|
485
|
+
self._conn.commit()
|
|
486
|
+
|
|
487
|
+
def list_recent_automation_decisions(
|
|
488
|
+
self,
|
|
489
|
+
repo: str,
|
|
490
|
+
*,
|
|
491
|
+
operation: str | None = None,
|
|
492
|
+
since_ts: int | None = None,
|
|
493
|
+
limit: int = 100,
|
|
494
|
+
) -> list[dict[str, Any]]:
|
|
495
|
+
"""Recent operator decisions for this repo, newest first.
|
|
496
|
+
|
|
497
|
+
``operation`` namespaces the read so a Dependabot-PR consumer
|
|
498
|
+
only sees ``dependabot_pr`` rows, not e.g. ``codeql_alert``
|
|
499
|
+
decisions also tracked in the same repo. Without this filter,
|
|
500
|
+
a "suppress this CVE" answer for a CodeQL alert would render
|
|
501
|
+
into the Dependabot prompt as a prior-PR decision and the
|
|
502
|
+
agent could act on it. Defaults to ``None`` (all operations)
|
|
503
|
+
to preserve the original behaviour for callers that want a
|
|
504
|
+
full audit view.
|
|
505
|
+
|
|
506
|
+
``since_ts`` (unix seconds) filters to decisions made
|
|
507
|
+
at-or-after that point — used by the secops prompt builder to
|
|
508
|
+
inject a 30-day rolling window into the agent's context so the
|
|
509
|
+
agent can act on prior decisions instead of asking again.
|
|
510
|
+
"""
|
|
511
|
+
clauses = ["repo = ?"]
|
|
512
|
+
params: list[Any] = [repo]
|
|
513
|
+
if operation is not None:
|
|
514
|
+
clauses.append("operation = ?")
|
|
515
|
+
params.append(operation)
|
|
516
|
+
if since_ts is not None:
|
|
517
|
+
clauses.append("decided_at >= ?")
|
|
518
|
+
params.append(since_ts)
|
|
519
|
+
sql = (
|
|
520
|
+
"SELECT * FROM automation_decisions WHERE "
|
|
521
|
+
+ " AND ".join(clauses)
|
|
522
|
+
+ " ORDER BY decided_at DESC LIMIT ?"
|
|
523
|
+
)
|
|
524
|
+
params.append(limit)
|
|
525
|
+
rows = self._conn.execute(sql, tuple(params)).fetchall()
|
|
526
|
+
return [dict(row) for row in rows]
|
|
527
|
+
|
|
528
|
+
# PR watches (durable, cross-restart)
|
|
529
|
+
|
|
530
|
+
def add_pr_watch(
|
|
531
|
+
self,
|
|
532
|
+
*,
|
|
533
|
+
repo: str,
|
|
534
|
+
pr_number: int,
|
|
535
|
+
issue_number: int,
|
|
536
|
+
session_id: str | None,
|
|
537
|
+
pr_url: str | None,
|
|
538
|
+
started_at: int | None = None,
|
|
539
|
+
) -> None:
|
|
540
|
+
"""Record an in-flight merge watcher so a poller restart can
|
|
541
|
+
rehydrate it. Idempotent on ``(repo, pr_number)``.
|
|
542
|
+
|
|
543
|
+
When ``started_at`` is None (production path, including the
|
|
544
|
+
rehydrate re-insert), a new row gets ``now()`` and an existing
|
|
545
|
+
row keeps its original ``started_at``. Preserving the original
|
|
546
|
+
timestamp is load-bearing: if every rehydrate refreshed it, the
|
|
547
|
+
7-day watch deadline would reset on every poller restart and
|
|
548
|
+
abandoned PRs would never time out.
|
|
549
|
+
|
|
550
|
+
When ``started_at`` is explicit (tests, seed flows), the row
|
|
551
|
+
is overwritten outright.
|
|
552
|
+
"""
|
|
553
|
+
if started_at is None:
|
|
554
|
+
self._conn.execute(
|
|
555
|
+
"""INSERT INTO pr_watches
|
|
556
|
+
(repo, pr_number, session_id, issue_number, pr_url, started_at)
|
|
557
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
558
|
+
ON CONFLICT(repo, pr_number) DO UPDATE SET
|
|
559
|
+
session_id = excluded.session_id,
|
|
560
|
+
issue_number = excluded.issue_number,
|
|
561
|
+
pr_url = excluded.pr_url""",
|
|
562
|
+
(repo, pr_number, session_id, issue_number, pr_url,
|
|
563
|
+
int(time.time())),
|
|
564
|
+
)
|
|
565
|
+
else:
|
|
566
|
+
self._conn.execute(
|
|
567
|
+
"""INSERT OR REPLACE INTO pr_watches
|
|
568
|
+
(repo, pr_number, session_id, issue_number, pr_url, started_at)
|
|
569
|
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
570
|
+
(repo, pr_number, session_id, issue_number, pr_url,
|
|
571
|
+
int(started_at)),
|
|
572
|
+
)
|
|
573
|
+
self._conn.commit()
|
|
574
|
+
|
|
575
|
+
def remove_pr_watch(self, repo: str, pr_number: int) -> bool:
|
|
576
|
+
"""Delete a PR watch row after a terminal outcome (merge
|
|
577
|
+
detected, or watch window expired). Returns True iff a row was
|
|
578
|
+
deleted so callers can tell whether the row was still present."""
|
|
579
|
+
cursor = self._conn.execute(
|
|
580
|
+
"DELETE FROM pr_watches WHERE repo = ? AND pr_number = ?",
|
|
581
|
+
(repo, pr_number),
|
|
582
|
+
)
|
|
583
|
+
self._conn.commit()
|
|
584
|
+
return cursor.rowcount > 0
|
|
585
|
+
|
|
586
|
+
def list_pr_watches(self) -> list[dict[str, Any]]:
|
|
587
|
+
"""Return all surviving PR watches, oldest first. Called by the
|
|
588
|
+
poller at startup (before ``run_poll_loop``) to rehydrate any
|
|
589
|
+
watchers cancelled by the previous shutdown."""
|
|
590
|
+
rows = self._conn.execute(
|
|
591
|
+
"SELECT * FROM pr_watches ORDER BY started_at ASC"
|
|
592
|
+
).fetchall()
|
|
593
|
+
return [dict(row) for row in rows]
|
|
594
|
+
|
|
595
|
+
# Valid post-merge cleanup phases, in monotonic progression. Each
|
|
596
|
+
# phase stamps *after* its side effect has succeeded, so if the
|
|
597
|
+
# poller is killed mid-cleanup and rehydrates later, the resumed
|
|
598
|
+
# handle_merge can skip every step the prior run already completed.
|
|
599
|
+
_PR_WATCH_CLEANUP_PHASES = frozenset(
|
|
600
|
+
{"commented", "closed", "notified"}
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
def set_pr_watch_cleanup_phase(
|
|
604
|
+
self, repo: str, pr_number: int, phase: str
|
|
605
|
+
) -> None:
|
|
606
|
+
"""Stamp a durable marker that a given post-merge cleanup step
|
|
607
|
+
has succeeded for this watch. Caller must invoke AFTER the
|
|
608
|
+
side effect completes so a process kill between the stamp and
|
|
609
|
+
the next step never leaves us claiming work we didn't do.
|
|
610
|
+
|
|
611
|
+
Validated: ``phase`` must be one of the known monotonic values
|
|
612
|
+
(``commented`` / ``closed`` / ``notified``). An invalid value
|
|
613
|
+
raises rather than silently corrupting resume semantics.
|
|
614
|
+
"""
|
|
615
|
+
if phase not in self._PR_WATCH_CLEANUP_PHASES:
|
|
616
|
+
raise ValueError(
|
|
617
|
+
f"invalid pr_watches cleanup_phase: {phase!r}; "
|
|
618
|
+
f"expected one of {sorted(self._PR_WATCH_CLEANUP_PHASES)}"
|
|
619
|
+
)
|
|
620
|
+
self._conn.execute(
|
|
621
|
+
"UPDATE pr_watches SET cleanup_phase = ? "
|
|
622
|
+
"WHERE repo = ? AND pr_number = ?",
|
|
623
|
+
(phase, repo, pr_number),
|
|
624
|
+
)
|
|
625
|
+
self._conn.commit()
|
|
626
|
+
|
|
627
|
+
def get_pr_watch_cleanup_phase(
|
|
628
|
+
self, repo: str, pr_number: int
|
|
629
|
+
) -> str | None:
|
|
630
|
+
"""Read the persisted cleanup phase for a watch, used by
|
|
631
|
+
rehydration so handle_merge can skip completed steps. Returns
|
|
632
|
+
None when the row is missing or the phase column is NULL
|
|
633
|
+
(fresh watch that never reached cleanup)."""
|
|
634
|
+
row = self._conn.execute(
|
|
635
|
+
"SELECT cleanup_phase FROM pr_watches "
|
|
636
|
+
"WHERE repo = ? AND pr_number = ?",
|
|
637
|
+
(repo, pr_number),
|
|
638
|
+
).fetchone()
|
|
639
|
+
if row is None:
|
|
640
|
+
return None
|
|
641
|
+
value = row["cleanup_phase"]
|
|
642
|
+
return value if value else None
|