diffbutler 0.5.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 (105) hide show
  1. diffbutler/__init__.py +1 -0
  2. diffbutler/__main__.py +6 -0
  3. diffbutler/config/__init__.py +1 -0
  4. diffbutler/config/default.toml +91 -0
  5. diffbutler/config/diagnostics.py +90 -0
  6. diffbutler/config/file.py +30 -0
  7. diffbutler/config/instance.py +55 -0
  8. diffbutler/config/settings.py +297 -0
  9. diffbutler/config/setup.py +92 -0
  10. diffbutler/config/transport.py +65 -0
  11. diffbutler/core/__init__.py +1 -0
  12. diffbutler/core/application.py +103 -0
  13. diffbutler/core/context.py +54 -0
  14. diffbutler/core/dashboard.py +393 -0
  15. diffbutler/core/doctor.py +200 -0
  16. diffbutler/core/lifecycle.py +70 -0
  17. diffbutler/core/managed.py +441 -0
  18. diffbutler/core/native.py +301 -0
  19. diffbutler/core/polling.py +118 -0
  20. diffbutler/core/process.py +64 -0
  21. diffbutler/core/repositories.py +260 -0
  22. diffbutler/core/reset.py +34 -0
  23. diffbutler/core/resources.py +50 -0
  24. diffbutler/core/review.py +331 -0
  25. diffbutler/core/setup.py +232 -0
  26. diffbutler/core/snapshot.py +70 -0
  27. diffbutler/core/sync.py +259 -0
  28. diffbutler/core/worker.py +205 -0
  29. diffbutler/core/workspaces.py +139 -0
  30. diffbutler/domain/__init__.py +1 -0
  31. diffbutler/domain/dashboard.py +63 -0
  32. diffbutler/domain/diff.py +285 -0
  33. diffbutler/domain/forge.py +123 -0
  34. diffbutler/domain/lifecycle.py +51 -0
  35. diffbutler/domain/polling.py +92 -0
  36. diffbutler/domain/process.py +29 -0
  37. diffbutler/domain/redaction.py +61 -0
  38. diffbutler/domain/resources.py +50 -0
  39. diffbutler/domain/review.py +167 -0
  40. diffbutler/domain/sync.py +196 -0
  41. diffbutler/forges/__init__.py +1 -0
  42. diffbutler/forges/base.py +39 -0
  43. diffbutler/forges/diagnostics.py +104 -0
  44. diffbutler/forges/github/__init__.py +1 -0
  45. diffbutler/forges/github/adapter.py +352 -0
  46. diffbutler/forges/github/auth.py +49 -0
  47. diffbutler/forges/github/http.py +326 -0
  48. diffbutler/forges/github/mapping.py +157 -0
  49. diffbutler/forges/github/setup.py +86 -0
  50. diffbutler/forges/registry.py +23 -0
  51. diffbutler/py.typed +0 -0
  52. diffbutler/reviewers/__init__.py +1 -0
  53. diffbutler/reviewers/base.py +101 -0
  54. diffbutler/reviewers/extraction.py +198 -0
  55. diffbutler/reviewers/native.py +238 -0
  56. diffbutler/reviewers/parsing.py +247 -0
  57. diffbutler/reviewers/presets.toml +12 -0
  58. diffbutler/reviewers/process.py +78 -0
  59. diffbutler/reviewers/prompt.py +148 -0
  60. diffbutler/reviewers/redaction.py +30 -0
  61. diffbutler/reviewers/resources.py +162 -0
  62. diffbutler/sources/__init__.py +1 -0
  63. diffbutler/sources/diagnostics.py +100 -0
  64. diffbutler/sources/fetch.py +285 -0
  65. diffbutler/sources/git.py +461 -0
  66. diffbutler/sources/remotes.py +82 -0
  67. diffbutler/store/__init__.py +1 -0
  68. diffbutler/store/base.py +88 -0
  69. diffbutler/store/dashboard.py +201 -0
  70. diffbutler/store/database.py +172 -0
  71. diffbutler/store/lifecycle.py +247 -0
  72. diffbutler/store/migrations/__init__.py +1 -0
  73. diffbutler/store/migrations/env.py +14 -0
  74. diffbutler/store/migrations/versions/0001_initial.py +147 -0
  75. diffbutler/store/migrations/versions/0002_monitoring.py +38 -0
  76. diffbutler/store/migrations/versions/0003_reviews.py +72 -0
  77. diffbutler/store/migrations/versions/0004_claims.py +22 -0
  78. diffbutler/store/migrations/versions/0005_results.py +27 -0
  79. diffbutler/store/migrations/versions/0006_anchors.py +17 -0
  80. diffbutler/store/migrations/versions/0007_lifecycle.py +36 -0
  81. diffbutler/store/migrations/versions/0008_processes.py +30 -0
  82. diffbutler/store/migrations/versions/0009_dashboard.py +43 -0
  83. diffbutler/store/migrations/versions/0010_run_reasons.py +17 -0
  84. diffbutler/store/migrations/versions/__init__.py +1 -0
  85. diffbutler/store/models.py +290 -0
  86. diffbutler/store/process.py +75 -0
  87. diffbutler/store/queue.py +289 -0
  88. diffbutler/store/review.py +583 -0
  89. diffbutler/ui/__init__.py +1 -0
  90. diffbutler/ui/cli/__init__.py +532 -0
  91. diffbutler/ui/cli/editor.py +33 -0
  92. diffbutler/ui/tui/__init__.py +1 -0
  93. diffbutler/ui/tui/_fake_agent.py +47 -0
  94. diffbutler/ui/tui/_foreground_exec.py +23 -0
  95. diffbutler/ui/tui/app.py +34 -0
  96. diffbutler/ui/tui/browse.py +119 -0
  97. diffbutler/ui/tui/browser.py +42 -0
  98. diffbutler/ui/tui/dashboard.py +926 -0
  99. diffbutler/ui/tui/foreground.py +103 -0
  100. diffbutler/ui/tui/handoff_probe.py +184 -0
  101. diffbutler-0.5.0.dist-info/METADATA +170 -0
  102. diffbutler-0.5.0.dist-info/RECORD +105 -0
  103. diffbutler-0.5.0.dist-info/WHEEL +4 -0
  104. diffbutler-0.5.0.dist-info/entry_points.txt +3 -0
  105. diffbutler-0.5.0.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,290 @@
1
+ """Identity, review-cycle history, and durable per-account monitoring."""
2
+
3
+ from datetime import UTC, datetime
4
+ from uuid import UUID, uuid4
5
+
6
+ from sqlalchemy import (
7
+ JSON,
8
+ CheckConstraint,
9
+ DateTime,
10
+ Float,
11
+ ForeignKey,
12
+ ForeignKeyConstraint,
13
+ LargeBinary,
14
+ MetaData,
15
+ String,
16
+ Text,
17
+ UniqueConstraint,
18
+ )
19
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
20
+
21
+ LOCAL_ACCOUNT_ID = UUID("00000000-0000-0000-0000-000000000001")
22
+
23
+
24
+ def utc_now() -> datetime:
25
+ return datetime.now(UTC)
26
+
27
+
28
+ class Base(DeclarativeBase):
29
+ metadata = MetaData(
30
+ naming_convention={
31
+ "ix": "ix_%(table_name)s_%(column_0_name)s",
32
+ "uq": "uq_%(table_name)s_%(column_0_name)s",
33
+ "ck": "ck_%(table_name)s_%(constraint_name)s",
34
+ "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
35
+ "pk": "pk_%(table_name)s",
36
+ }
37
+ )
38
+
39
+
40
+ class Account(Base):
41
+ __tablename__ = "account"
42
+ id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
43
+ forge: Mapped[str] = mapped_column(String(32))
44
+ login: Mapped[str] = mapped_column(String(255))
45
+ display_name: Mapped[str] = mapped_column(String(255))
46
+
47
+
48
+ class Repository(Base):
49
+ __tablename__ = "repository"
50
+ __table_args__ = (UniqueConstraint("forge", "namespace", "name"),)
51
+ id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
52
+ forge: Mapped[str] = mapped_column(String(255))
53
+ namespace: Mapped[str] = mapped_column(String(255))
54
+ name: Mapped[str] = mapped_column(String(255))
55
+ default_branch: Mapped[str] = mapped_column(String(255))
56
+ config_json: Mapped[dict[str, object]] = mapped_column(JSON, default=dict)
57
+ enabled: Mapped[bool] = mapped_column(default=True)
58
+
59
+
60
+ class PullRequest(Base):
61
+ __tablename__ = "pull_request"
62
+ __table_args__ = (UniqueConstraint("repository_id", "number"),)
63
+ id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
64
+ repository_id: Mapped[UUID] = mapped_column(ForeignKey("repository.id"))
65
+ number: Mapped[int]
66
+ title: Mapped[str] = mapped_column(String(1024))
67
+ author: Mapped[str] = mapped_column(String(255))
68
+ head_sha: Mapped[str] = mapped_column(String(64))
69
+ base_sha: Mapped[str] = mapped_column(String(64))
70
+ head_ref: Mapped[str] = mapped_column(String(255))
71
+ base_ref: Mapped[str] = mapped_column(String(255))
72
+ is_draft: Mapped[bool] = mapped_column(default=False)
73
+ url: Mapped[str] = mapped_column(String(2048))
74
+ state: Mapped[str] = mapped_column(String(32))
75
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
76
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
77
+ fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
78
+ extra_json: Mapped[dict[str, object]] = mapped_column(JSON, default=dict)
79
+
80
+
81
+ class ReviewTask(Base):
82
+ __tablename__ = "review_task"
83
+ __table_args__ = (
84
+ UniqueConstraint("pull_request_id", "account_id", "cycle_number"),
85
+ CheckConstraint("cycle_number > 0", name="positive_cycle"),
86
+ )
87
+ id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
88
+ pull_request_id: Mapped[UUID] = mapped_column(ForeignKey("pull_request.id"))
89
+ account_id: Mapped[UUID] = mapped_column(ForeignKey("account.id"), default=LOCAL_ACCOUNT_ID)
90
+ cycle_number: Mapped[int] = mapped_column(default=1)
91
+ state: Mapped[str] = mapped_column(String(32), default="PENDING_AI_REVIEW")
92
+ snapshot_json: Mapped[dict[str, object] | None] = mapped_column(JSON)
93
+ snoozed_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
94
+ priority: Mapped[int] = mapped_column(default=0)
95
+ notes: Mapped[str | None] = mapped_column(String(16384))
96
+ first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
97
+ opened_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
98
+ posted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
99
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
100
+ following: Mapped[bool] = mapped_column(default=True)
101
+ request_sources_json: Mapped[list[str]] = mapped_column(JSON, default=list)
102
+ last_request_event: Mapped[str | None] = mapped_column(String(255))
103
+ last_activity_cursor: Mapped[str | None] = mapped_column(String(1024))
104
+ last_reviewed_sha: Mapped[str | None] = mapped_column(String(64))
105
+ approved_sha: Mapped[str | None] = mapped_column(String(64))
106
+ completed_head_sha: Mapped[str | None] = mapped_column(String(64))
107
+ completion_reason: Mapped[str | None] = mapped_column(String(64))
108
+ completion_source: Mapped[str | None] = mapped_column(String(64))
109
+ completion_event_watermark: Mapped[str | None] = mapped_column(String(1024))
110
+ action_version: Mapped[int] = mapped_column(default=0)
111
+ state_source: Mapped[str | None] = mapped_column(String(64))
112
+ state_event_watermark: Mapped[str | None] = mapped_column(String(1024))
113
+ completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
114
+
115
+
116
+ class CleanupJob(Base):
117
+ __tablename__ = "cleanup_job"
118
+ __table_args__ = (UniqueConstraint("review_task_id"),)
119
+ id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
120
+ review_task_id: Mapped[UUID] = mapped_column(ForeignKey("review_task.id"))
121
+ status: Mapped[str] = mapped_column(String(32), default="pending")
122
+ attempts: Mapped[int] = mapped_column(default=0)
123
+ next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
124
+ last_error: Mapped[str | None] = mapped_column(String(4096))
125
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
126
+ completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
127
+
128
+
129
+ class ForgeAccount(Base):
130
+ """Full API-instance identity mapped to a ledger account; local stays separate."""
131
+
132
+ __tablename__ = "forge_account"
133
+ __table_args__ = (UniqueConstraint("forge", "login"),)
134
+ account_id: Mapped[UUID] = mapped_column(ForeignKey("account.id"), primary_key=True)
135
+ forge: Mapped[str] = mapped_column(String(255))
136
+ login: Mapped[str] = mapped_column(String(255))
137
+ version: Mapped[int] = mapped_column(default=0)
138
+
139
+
140
+ class PRMonitor(Base):
141
+ """Monitoring persists independently of request membership and completed cycles."""
142
+
143
+ __tablename__ = "pr_monitor"
144
+ account_id: Mapped[UUID] = mapped_column(ForeignKey("account.id"), primary_key=True)
145
+ pull_request_id: Mapped[UUID] = mapped_column(ForeignKey("pull_request.id"), primary_key=True)
146
+ task_id: Mapped[UUID] = mapped_column(ForeignKey("review_task.id"))
147
+ following: Mapped[bool] = mapped_column(default=True)
148
+ sources: Mapped[list[str]] = mapped_column(JSON, default=list)
149
+ snapshot_json: Mapped[dict[str, object]] = mapped_column(JSON)
150
+ activity_json: Mapped[str | None] = mapped_column(Text)
151
+ refresh_pending: Mapped[bool] = mapped_column(default=False)
152
+ notice: Mapped[str] = mapped_column(String(255), default="")
153
+ request_key: Mapped[str] = mapped_column(String(1024), default="")
154
+ review_key: Mapped[str] = mapped_column(String(1024), default="")
155
+
156
+
157
+ class ReviewSession(Base):
158
+ """A conversation belongs to a task/account cycle, independently of its runs."""
159
+
160
+ __tablename__ = "review_session"
161
+ __table_args__ = (
162
+ UniqueConstraint("review_task_id", "reviewer_id", "generation"),
163
+ UniqueConstraint("id", "review_task_id", "reviewer_id"),
164
+ CheckConstraint("generation > 0", name="positive_generation"),
165
+ CheckConstraint("status IN ('ready', 'suspended', 'cleaned')", name="session_status"),
166
+ )
167
+ id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
168
+ review_task_id: Mapped[UUID] = mapped_column(ForeignKey("review_task.id"))
169
+ reviewer_id: Mapped[str] = mapped_column(String(255))
170
+ generation: Mapped[int] = mapped_column(default=1)
171
+ display_name: Mapped[str] = mapped_column(String(1024))
172
+ backend_session_id: Mapped[str | None] = mapped_column(String(1024))
173
+ repository_path: Mapped[str | None] = mapped_column(String(4096))
174
+ status: Mapped[str] = mapped_column(String(32), default="ready")
175
+ transcript_ref: Mapped[str | None] = mapped_column(String(4096))
176
+ resource_manifest_json: Mapped[list[dict[str, str]]] = mapped_column(JSON, default=list)
177
+ cleanup_status: Mapped[str] = mapped_column(String(32), default="not_requested")
178
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
179
+ resumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
180
+ cleaned_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
181
+
182
+
183
+ class AIRun(Base):
184
+ """Attempt metadata survives deletion of raw output and session resources."""
185
+
186
+ __tablename__ = "ai_run"
187
+ __table_args__ = (
188
+ UniqueConstraint("analysis_key", "attempt"),
189
+ ForeignKeyConstraint(
190
+ ["review_session_id", "review_task_id", "reviewer_id"],
191
+ ["review_session.id", "review_session.review_task_id", "review_session.reviewer_id"],
192
+ ),
193
+ CheckConstraint("attempt > 0", name="positive_attempt"),
194
+ CheckConstraint(
195
+ "status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled', 'stale')",
196
+ name="run_status",
197
+ ),
198
+ CheckConstraint("parse_status IN ('pending', 'succeeded', 'failed')", name="parse_status"),
199
+ )
200
+ id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
201
+ review_task_id: Mapped[UUID] = mapped_column(ForeignKey("review_task.id"))
202
+ review_session_id: Mapped[UUID]
203
+ reviewer_id: Mapped[str] = mapped_column(String(255))
204
+ prompt_version: Mapped[str] = mapped_column(String(255))
205
+ config_hash: Mapped[str] = mapped_column(String(64))
206
+ head_sha: Mapped[str] = mapped_column(String(64))
207
+ base_sha: Mapped[str] = mapped_column(String(64))
208
+ analysis_key: Mapped[str] = mapped_column(String(64))
209
+ attempt: Mapped[int]
210
+ status: Mapped[str] = mapped_column(String(32), default="queued")
211
+ parse_status: Mapped[str] = mapped_column(String(32), default="pending")
212
+ requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
213
+ started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
214
+ finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
215
+ exit_code: Mapped[int | None]
216
+ status_reason: Mapped[str | None] = mapped_column(Text)
217
+ stdout_blob: Mapped[bytes | None] = mapped_column(LargeBinary)
218
+ stderr_blob: Mapped[bytes | None] = mapped_column(LargeBinary)
219
+
220
+
221
+ class RunClaim(Base):
222
+ """Cancellation invalidates results without releasing a live worker's slot."""
223
+
224
+ __tablename__ = "run_claim"
225
+ token: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
226
+ run_id: Mapped[UUID] = mapped_column(ForeignKey("ai_run.id"), unique=True)
227
+
228
+
229
+ class ReviewResult(Base):
230
+ """Retained analysis is separate from cleanup-eligible raw AIRun output."""
231
+
232
+ __tablename__ = "review_result"
233
+ run_id: Mapped[UUID] = mapped_column(ForeignKey("ai_run.id"), primary_key=True)
234
+ document_json: Mapped[str | None] = mapped_column(Text)
235
+ anchors_json: Mapped[str | None] = mapped_column(Text)
236
+ parse_tier: Mapped[str | None] = mapped_column(String(32))
237
+ error: Mapped[str | None] = mapped_column(String(255))
238
+ notes: Mapped[list[str]] = mapped_column(JSON)
239
+ selected_indices: Mapped[list[int]] = mapped_column(JSON)
240
+ min_confidence: Mapped[float] = mapped_column(Float)
241
+ findings_threshold: Mapped[str] = mapped_column(String(32))
242
+
243
+
244
+ class ForegroundLease(Base):
245
+ __tablename__ = "foreground_lease"
246
+ token: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
247
+ session_id: Mapped[UUID] = mapped_column(ForeignKey("review_session.id"), unique=True)
248
+ backend_session_id: Mapped[str] = mapped_column(String(1024))
249
+ head_sha: Mapped[str] = mapped_column(String(128))
250
+ task_version: Mapped[int] = mapped_column()
251
+ acquired_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
252
+
253
+
254
+ class CleanupClaim(Base):
255
+ __tablename__ = "cleanup_claim"
256
+ token: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
257
+ job_id: Mapped[UUID] = mapped_column(ForeignKey("cleanup_job.id"), unique=True)
258
+ acquired_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
259
+
260
+
261
+ class ProcessLaunch(Base):
262
+ """Historical ownership metadata; token references one of three claim types."""
263
+
264
+ __tablename__ = "process_launch"
265
+ __table_args__ = (
266
+ CheckConstraint("kind IN ('background', 'foreground', 'cleanup')", name="operation_kind"),
267
+ CheckConstraint("launcher_pid IS NULL OR launcher_pid > 0", name="positive_launcher_pid"),
268
+ )
269
+ token: Mapped[UUID] = mapped_column(primary_key=True)
270
+ kind: Mapped[str] = mapped_column(String(32))
271
+ host_id: Mapped[UUID] = mapped_column()
272
+ boot_id: Mapped[UUID] = mapped_column()
273
+ launcher_pid: Mapped[int | None] = mapped_column()
274
+
275
+
276
+ class ObservedActivity(Base):
277
+ """Published forge evidence, deduplicated across polling and review cycles."""
278
+
279
+ __tablename__ = "observed_activity"
280
+ account_id: Mapped[UUID] = mapped_column(ForeignKey("account.id"), primary_key=True)
281
+ pull_request_id: Mapped[UUID] = mapped_column(ForeignKey("pull_request.id"), primary_key=True)
282
+ event_id: Mapped[str] = mapped_column(String(255), primary_key=True)
283
+ review_task_id: Mapped[UUID] = mapped_column(ForeignKey("review_task.id"))
284
+ payload_json: Mapped[str] = mapped_column(Text)
285
+
286
+
287
+ class UIPreference(Base):
288
+ __tablename__ = "ui_preference"
289
+ key: Mapped[str] = mapped_column(String(255), primary_key=True)
290
+ value: Mapped[str] = mapped_column(Text)
@@ -0,0 +1,75 @@
1
+ """Claim-protected launch metadata. Historical records never authorize a new launch."""
2
+
3
+ from typing import cast
4
+ from uuid import UUID
5
+
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from diffbutler.domain.process import HostBoot, OperationKind, ProcessLaunch
9
+ from diffbutler.domain.review import ReviewConflict
10
+ from diffbutler.store.database import Database
11
+ from diffbutler.store.models import CleanupClaim, ForegroundLease, RunClaim
12
+ from diffbutler.store.models import ProcessLaunch as LaunchModel
13
+
14
+
15
+ def record(row: LaunchModel) -> ProcessLaunch:
16
+ return ProcessLaunch(
17
+ row.token,
18
+ cast(OperationKind, row.kind),
19
+ HostBoot(row.host_id, row.boot_id),
20
+ row.launcher_pid,
21
+ )
22
+
23
+
24
+ class SQLiteProcessStore:
25
+ def __init__(self, database: Database) -> None:
26
+ self.database = database
27
+
28
+ async def _owned(self, db: AsyncSession, token: UUID, kind: OperationKind) -> None:
29
+ model = {"background": RunClaim, "foreground": ForegroundLease, "cleanup": CleanupClaim}[
30
+ kind
31
+ ]
32
+ if await db.get(model, token) is None:
33
+ raise ReviewConflict("Operation no longer owns its claim")
34
+
35
+ async def get_launch(self, token: UUID) -> ProcessLaunch | None:
36
+ async with self.database.sessions() as db:
37
+ row = await db.get(LaunchModel, token)
38
+ return None if row is None else record(row)
39
+
40
+ async def reserve_launch(self, launch: ProcessLaunch) -> None:
41
+ if launch.launcher_pid is not None:
42
+ raise ValueError("Launch intent must be saved before starting a process")
43
+ async with self.database.sessions() as db:
44
+ connection = await db.connection()
45
+ await connection.exec_driver_sql("BEGIN IMMEDIATE")
46
+ await self._owned(db, launch.token, launch.kind)
47
+ row = await db.get(LaunchModel, launch.token)
48
+ if row is not None:
49
+ # Never authorize another spawn on an existing intent, even if a
50
+ # crash prevented saving its PID. Recovery must resolve that gap.
51
+ raise ReviewConflict("Launch intent already exists")
52
+ db.add(
53
+ LaunchModel(
54
+ token=launch.token,
55
+ kind=launch.kind,
56
+ host_id=launch.origin.host_id,
57
+ boot_id=launch.origin.boot_id,
58
+ )
59
+ )
60
+ await db.commit()
61
+
62
+ async def bind_launcher(self, token: UUID, pid: int) -> None:
63
+ if pid < 1:
64
+ raise ValueError("Launcher PID must be positive")
65
+ async with self.database.sessions() as db:
66
+ connection = await db.connection()
67
+ await connection.exec_driver_sql("BEGIN IMMEDIATE")
68
+ row = await db.get(LaunchModel, token)
69
+ if row is None:
70
+ raise ReviewConflict("Launch intent does not exist")
71
+ await self._owned(db, token, cast(OperationKind, row.kind))
72
+ if row.launcher_pid not in {None, pid}:
73
+ raise ReviewConflict("Launcher identity cannot be replaced")
74
+ row.launcher_pid = pid
75
+ await db.commit()
@@ -0,0 +1,289 @@
1
+ """SQLite queue repository. Network reads happen before short atomic commits."""
2
+
3
+ from datetime import UTC
4
+ from typing import cast
5
+
6
+ from pydantic import TypeAdapter
7
+ from sqlalchemy import func, select, text
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from diffbutler.domain.forge import Account as ForgeIdentity
11
+ from diffbutler.domain.forge import PRRef
12
+ from diffbutler.domain.forge import PullRequest as ForgePR
13
+ from diffbutler.domain.sync import (
14
+ CommentEvent,
15
+ QueueSnapshot,
16
+ ReviewActivity,
17
+ ReviewEvent,
18
+ TrackedPR,
19
+ )
20
+ from diffbutler.store.base import SyncConflict
21
+ from diffbutler.store.database import Database
22
+ from diffbutler.store.models import (
23
+ Account,
24
+ AIRun,
25
+ CleanupJob,
26
+ ForgeAccount,
27
+ ObservedActivity,
28
+ PRMonitor,
29
+ PullRequest,
30
+ Repository,
31
+ ReviewTask,
32
+ utc_now,
33
+ )
34
+
35
+ PR_ADAPTER = TypeAdapter(ForgePR)
36
+ ACTIVITY_ADAPTER = TypeAdapter(ReviewActivity)
37
+ EVENT_ADAPTER: TypeAdapter[ReviewEvent | CommentEvent] = TypeAdapter(ReviewEvent | CommentEvent)
38
+
39
+
40
+ class SQLiteQueueStore:
41
+ def __init__(self, database: Database) -> None:
42
+ self.database = database
43
+
44
+ async def accounts(self) -> tuple[ForgeIdentity, ...]:
45
+ async with self.database.sessions() as session:
46
+ rows = await session.execute(select(ForgeAccount, Account).join(Account))
47
+ return tuple(
48
+ ForgeIdentity(identity.forge, identity.login, account.display_name)
49
+ for identity, account in rows
50
+ )
51
+
52
+ async def snapshot(self, account: ForgeIdentity) -> QueueSnapshot:
53
+ async with self.database.sessions() as session:
54
+ # One read transaction pairs the generation with all its monitor rows.
55
+ await session.execute(text("BEGIN"))
56
+ identity = await session.scalar(
57
+ select(ForgeAccount).where(
58
+ ForgeAccount.forge == account.forge, ForgeAccount.login == account.login.lower()
59
+ )
60
+ )
61
+ if identity is None:
62
+ return QueueSnapshot(0, ())
63
+ rows = await session.execute(
64
+ select(
65
+ PRMonitor,
66
+ ReviewTask,
67
+ select(func.max(AIRun.requested_at))
68
+ .where(AIRun.review_task_id == ReviewTask.id)
69
+ .correlate(ReviewTask)
70
+ .scalar_subquery(),
71
+ )
72
+ .join(ReviewTask, PRMonitor.task_id == ReviewTask.id)
73
+ .where(PRMonitor.account_id == identity.account_id)
74
+ )
75
+ return QueueSnapshot(
76
+ identity.version,
77
+ tuple(
78
+ TrackedPR(
79
+ PR_ADAPTER.validate_python(monitor.snapshot_json),
80
+ monitor.following,
81
+ tuple(monitor.sources),
82
+ task.cycle_number,
83
+ task.id,
84
+ task.state,
85
+ monitor.request_key,
86
+ monitor.review_key,
87
+ task.completion_reason,
88
+ task.completion_event_watermark,
89
+ ACTIVITY_ADAPTER.validate_json(monitor.activity_json)
90
+ if monitor.activity_json
91
+ else None,
92
+ monitor.refresh_pending,
93
+ task.state_event_watermark or "",
94
+ task.opened_at,
95
+ monitor.notice,
96
+ task.posted_at,
97
+ requested_at.replace(tzinfo=UTC) if requested_at else None,
98
+ )
99
+ for monitor, task, requested_at in rows
100
+ ),
101
+ )
102
+
103
+ async def apply(
104
+ self,
105
+ account: ForgeIdentity,
106
+ version: int,
107
+ items: tuple[TrackedPR, ...],
108
+ *,
109
+ refreshed: frozenset[PRRef] = frozenset(),
110
+ ) -> None:
111
+ async with self.database.sessions() as session:
112
+ connection = await session.connection()
113
+ await connection.exec_driver_sql("BEGIN IMMEDIATE")
114
+ identity = await session.scalar(
115
+ select(ForgeAccount).where(
116
+ ForgeAccount.forge == account.forge, ForgeAccount.login == account.login.lower()
117
+ )
118
+ )
119
+ if identity is None:
120
+ if version != 0:
121
+ raise SyncConflict()
122
+ ledger = Account(
123
+ forge=account.forge.split(":", 1)[0],
124
+ login=account.login.lower(),
125
+ display_name=account.display_name,
126
+ )
127
+ session.add(ledger)
128
+ await session.flush()
129
+ identity = ForgeAccount(
130
+ account_id=ledger.id,
131
+ forge=account.forge,
132
+ login=account.login.lower(),
133
+ version=0,
134
+ )
135
+ session.add(identity)
136
+ if identity.version != version:
137
+ raise SyncConflict()
138
+ for item in items:
139
+ if item.pull_request.ref.forge != account.forge:
140
+ raise ValueError("Cannot persist a PR belonging to a different account forge")
141
+ await self._save(
142
+ session, identity, item, refresh=item.pull_request.ref in refreshed
143
+ )
144
+ identity.version += 1
145
+ await session.commit()
146
+
147
+ async def _save(
148
+ self,
149
+ session: AsyncSession,
150
+ identity: ForgeAccount,
151
+ item: TrackedPR,
152
+ *,
153
+ refresh: bool,
154
+ ) -> None:
155
+ remote = item.pull_request
156
+ ref = remote.ref
157
+ repo = await session.scalar(
158
+ select(Repository).where(
159
+ Repository.forge == ref.forge,
160
+ Repository.namespace == ref.namespace,
161
+ Repository.name == ref.repository,
162
+ )
163
+ )
164
+ if repo is None:
165
+ # The PR base branch is not necessarily the repository default branch.
166
+ repo = Repository(
167
+ forge=ref.forge, namespace=ref.namespace, name=ref.repository, default_branch=""
168
+ )
169
+ session.add(repo)
170
+ await session.flush()
171
+ pr = await session.scalar(
172
+ select(PullRequest).where(
173
+ PullRequest.repository_id == repo.id, PullRequest.number == ref.number
174
+ )
175
+ )
176
+ if pr is None:
177
+ refresh = True
178
+ pr = PullRequest(repository_id=repo.id, number=ref.number)
179
+ session.add(pr)
180
+ if refresh:
181
+ for name in (
182
+ "title",
183
+ "author",
184
+ "url",
185
+ "state",
186
+ "head_sha",
187
+ "base_sha",
188
+ "head_ref",
189
+ "base_ref",
190
+ "is_draft",
191
+ "created_at",
192
+ "updated_at",
193
+ ):
194
+ setattr(pr, name, getattr(remote, name))
195
+ pr.fetched_at = utc_now()
196
+ await session.flush()
197
+ monitor = await session.get(PRMonitor, (identity.account_id, pr.id))
198
+ task = await session.get(ReviewTask, item.task_id) if item.task_id else None
199
+ if task is None:
200
+ last_cycle = await session.scalar(
201
+ select(func.max(ReviewTask.cycle_number)).where(
202
+ ReviewTask.pull_request_id == pr.id,
203
+ ReviewTask.account_id == identity.account_id,
204
+ )
205
+ )
206
+ task = ReviewTask(
207
+ pull_request_id=pr.id,
208
+ account_id=identity.account_id,
209
+ cycle_number=max(item.cycle, (last_cycle or 0) + 1),
210
+ )
211
+ session.add(task)
212
+ await session.flush()
213
+ if task.account_id != identity.account_id or task.pull_request_id != pr.id:
214
+ raise ValueError("Review task belongs to a different account or PR")
215
+ if monitor is not None and (
216
+ item.state == "DONE"
217
+ or remote.head_sha != monitor.snapshot_json.get("head_sha")
218
+ or (item.request_key > monitor.request_key and item.state == "PENDING_AI_REVIEW")
219
+ ):
220
+ # Invalidate analysis in the same commit as the observed change. A
221
+ # worker's separate claim remains held until it acknowledges exit.
222
+ runs = await session.scalars(
223
+ select(AIRun).where(
224
+ AIRun.review_task_id == monitor.task_id,
225
+ AIRun.status.in_(["queued", "running"]),
226
+ )
227
+ )
228
+ for run in runs:
229
+ run.status = "cancelled" if item.state == "DONE" else "stale"
230
+ run.status_reason = (
231
+ "Review task completed"
232
+ if item.state == "DONE"
233
+ else "PR commits changed during review"
234
+ if remote.head_sha != run.head_sha
235
+ else "Review requested again"
236
+ )
237
+ run.finished_at = utc_now()
238
+ if task.completed_at is None:
239
+ if task.state != item.state:
240
+ task.action_version += 1
241
+ task.updated_at = utc_now()
242
+ task.snapshot_json = cast(
243
+ dict[str, object], PR_ADAPTER.dump_python(remote, mode="json")
244
+ )
245
+ if task.state != item.state:
246
+ task.state_source = "forge"
247
+ task.posted_at = item.posted_at
248
+ task.state = item.state
249
+ task.following = item.following
250
+ task.request_sources_json = list(item.sources)
251
+ task.last_request_event = item.request_key or None
252
+ task.last_activity_cursor = item.review_key or None
253
+ task.state_event_watermark = item.manual_after or None
254
+ if item.state == "DONE" and task.completed_at is None:
255
+ task.completed_at = utc_now()
256
+ task.completed_head_sha = remote.head_sha
257
+ task.completion_reason = item.completion_reason
258
+ task.completion_source = "forge"
259
+ task.completion_event_watermark = item.completion_event
260
+ task.approved_sha = (
261
+ remote.head_sha if item.completion_reason == "own_approval" else None
262
+ )
263
+ session.add(CleanupJob(review_task_id=task.id))
264
+ if monitor is None:
265
+ monitor = PRMonitor(account_id=identity.account_id, pull_request_id=pr.id)
266
+ session.add(monitor)
267
+ monitor.task_id = task.id
268
+ monitor.following = item.following
269
+ monitor.sources = list(item.sources)
270
+ monitor.snapshot_json = cast(dict[str, object], PR_ADAPTER.dump_python(remote, mode="json"))
271
+ monitor.request_key = item.request_key
272
+ monitor.review_key = item.review_key
273
+
274
+ monitor.refresh_pending = item.refresh_pending
275
+ monitor.notice = item.notice
276
+ if item.activity is not None:
277
+ monitor.activity_json = ACTIVITY_ADAPTER.dump_json(item.activity).decode()
278
+ for event in (*item.activity.reviews, *item.activity.comments):
279
+ key = (identity.account_id, pr.id, event.id)
280
+ receipt = await session.get(ObservedActivity, key)
281
+ if receipt is None:
282
+ receipt = ObservedActivity(
283
+ account_id=identity.account_id,
284
+ pull_request_id=pr.id,
285
+ event_id=event.id,
286
+ review_task_id=task.id,
287
+ )
288
+ session.add(receipt)
289
+ receipt.payload_json = EVENT_ADAPTER.dump_json(event).decode()