ctrlrelay 0.10.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. ctrlrelay/__init__.py +33 -0
  2. ctrlrelay/bridge/__init__.py +21 -0
  3. ctrlrelay/bridge/__main__.py +89 -0
  4. ctrlrelay/bridge/protocol.py +75 -0
  5. ctrlrelay/bridge/server.py +740 -0
  6. ctrlrelay/bridge/telegram_handler.py +136 -0
  7. ctrlrelay/cli.py +3138 -0
  8. ctrlrelay/core/__init__.py +54 -0
  9. ctrlrelay/core/audit.py +257 -0
  10. ctrlrelay/core/checkpoint.py +155 -0
  11. ctrlrelay/core/config.py +778 -0
  12. ctrlrelay/core/dispatcher.py +372 -0
  13. ctrlrelay/core/github.py +432 -0
  14. ctrlrelay/core/network.py +277 -0
  15. ctrlrelay/core/obs.py +118 -0
  16. ctrlrelay/core/poller.py +920 -0
  17. ctrlrelay/core/pr_verifier.py +230 -0
  18. ctrlrelay/core/pr_watcher.py +121 -0
  19. ctrlrelay/core/question_expiry.py +193 -0
  20. ctrlrelay/core/scheduler.py +337 -0
  21. ctrlrelay/core/state.py +642 -0
  22. ctrlrelay/core/worktree.py +893 -0
  23. ctrlrelay/dashboard/__init__.py +5 -0
  24. ctrlrelay/dashboard/client.py +159 -0
  25. ctrlrelay/gh_protocol.py +47 -0
  26. ctrlrelay/install.py +203 -0
  27. ctrlrelay/personalization/__init__.py +26 -0
  28. ctrlrelay/personalization/manager.py +1183 -0
  29. ctrlrelay/personalization/paths.py +143 -0
  30. ctrlrelay/pipelines/__init__.py +15 -0
  31. ctrlrelay/pipelines/base.py +50 -0
  32. ctrlrelay/pipelines/dev.py +1367 -0
  33. ctrlrelay/pipelines/post_merge.py +411 -0
  34. ctrlrelay/pipelines/secops.py +1018 -0
  35. ctrlrelay/pipelines/task.py +713 -0
  36. ctrlrelay/setup.py +617 -0
  37. ctrlrelay/templates/__init__.py +0 -0
  38. ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
  39. ctrlrelay/templates/launchd/__init__.py +0 -0
  40. ctrlrelay/templates/launchd/bridge.plist.template +37 -0
  41. ctrlrelay/templates/launchd/poller.plist.template +44 -0
  42. ctrlrelay/templates/systemd/__init__.py +0 -0
  43. ctrlrelay/templates/systemd/bridge.service.template +20 -0
  44. ctrlrelay/templates/systemd/poller.service.template +25 -0
  45. ctrlrelay/transports/__init__.py +34 -0
  46. ctrlrelay/transports/base.py +74 -0
  47. ctrlrelay/transports/file_mock.py +103 -0
  48. ctrlrelay/transports/socket_client.py +298 -0
  49. ctrlrelay-0.10.0.dist-info/METADATA +254 -0
  50. ctrlrelay-0.10.0.dist-info/RECORD +53 -0
  51. ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
  52. ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
  53. ctrlrelay-0.10.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,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