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.
- diffbutler/__init__.py +1 -0
- diffbutler/__main__.py +6 -0
- diffbutler/config/__init__.py +1 -0
- diffbutler/config/default.toml +91 -0
- diffbutler/config/diagnostics.py +90 -0
- diffbutler/config/file.py +30 -0
- diffbutler/config/instance.py +55 -0
- diffbutler/config/settings.py +297 -0
- diffbutler/config/setup.py +92 -0
- diffbutler/config/transport.py +65 -0
- diffbutler/core/__init__.py +1 -0
- diffbutler/core/application.py +103 -0
- diffbutler/core/context.py +54 -0
- diffbutler/core/dashboard.py +393 -0
- diffbutler/core/doctor.py +200 -0
- diffbutler/core/lifecycle.py +70 -0
- diffbutler/core/managed.py +441 -0
- diffbutler/core/native.py +301 -0
- diffbutler/core/polling.py +118 -0
- diffbutler/core/process.py +64 -0
- diffbutler/core/repositories.py +260 -0
- diffbutler/core/reset.py +34 -0
- diffbutler/core/resources.py +50 -0
- diffbutler/core/review.py +331 -0
- diffbutler/core/setup.py +232 -0
- diffbutler/core/snapshot.py +70 -0
- diffbutler/core/sync.py +259 -0
- diffbutler/core/worker.py +205 -0
- diffbutler/core/workspaces.py +139 -0
- diffbutler/domain/__init__.py +1 -0
- diffbutler/domain/dashboard.py +63 -0
- diffbutler/domain/diff.py +285 -0
- diffbutler/domain/forge.py +123 -0
- diffbutler/domain/lifecycle.py +51 -0
- diffbutler/domain/polling.py +92 -0
- diffbutler/domain/process.py +29 -0
- diffbutler/domain/redaction.py +61 -0
- diffbutler/domain/resources.py +50 -0
- diffbutler/domain/review.py +167 -0
- diffbutler/domain/sync.py +196 -0
- diffbutler/forges/__init__.py +1 -0
- diffbutler/forges/base.py +39 -0
- diffbutler/forges/diagnostics.py +104 -0
- diffbutler/forges/github/__init__.py +1 -0
- diffbutler/forges/github/adapter.py +352 -0
- diffbutler/forges/github/auth.py +49 -0
- diffbutler/forges/github/http.py +326 -0
- diffbutler/forges/github/mapping.py +157 -0
- diffbutler/forges/github/setup.py +86 -0
- diffbutler/forges/registry.py +23 -0
- diffbutler/py.typed +0 -0
- diffbutler/reviewers/__init__.py +1 -0
- diffbutler/reviewers/base.py +101 -0
- diffbutler/reviewers/extraction.py +198 -0
- diffbutler/reviewers/native.py +238 -0
- diffbutler/reviewers/parsing.py +247 -0
- diffbutler/reviewers/presets.toml +12 -0
- diffbutler/reviewers/process.py +78 -0
- diffbutler/reviewers/prompt.py +148 -0
- diffbutler/reviewers/redaction.py +30 -0
- diffbutler/reviewers/resources.py +162 -0
- diffbutler/sources/__init__.py +1 -0
- diffbutler/sources/diagnostics.py +100 -0
- diffbutler/sources/fetch.py +285 -0
- diffbutler/sources/git.py +461 -0
- diffbutler/sources/remotes.py +82 -0
- diffbutler/store/__init__.py +1 -0
- diffbutler/store/base.py +88 -0
- diffbutler/store/dashboard.py +201 -0
- diffbutler/store/database.py +172 -0
- diffbutler/store/lifecycle.py +247 -0
- diffbutler/store/migrations/__init__.py +1 -0
- diffbutler/store/migrations/env.py +14 -0
- diffbutler/store/migrations/versions/0001_initial.py +147 -0
- diffbutler/store/migrations/versions/0002_monitoring.py +38 -0
- diffbutler/store/migrations/versions/0003_reviews.py +72 -0
- diffbutler/store/migrations/versions/0004_claims.py +22 -0
- diffbutler/store/migrations/versions/0005_results.py +27 -0
- diffbutler/store/migrations/versions/0006_anchors.py +17 -0
- diffbutler/store/migrations/versions/0007_lifecycle.py +36 -0
- diffbutler/store/migrations/versions/0008_processes.py +30 -0
- diffbutler/store/migrations/versions/0009_dashboard.py +43 -0
- diffbutler/store/migrations/versions/0010_run_reasons.py +17 -0
- diffbutler/store/migrations/versions/__init__.py +1 -0
- diffbutler/store/models.py +290 -0
- diffbutler/store/process.py +75 -0
- diffbutler/store/queue.py +289 -0
- diffbutler/store/review.py +583 -0
- diffbutler/ui/__init__.py +1 -0
- diffbutler/ui/cli/__init__.py +532 -0
- diffbutler/ui/cli/editor.py +33 -0
- diffbutler/ui/tui/__init__.py +1 -0
- diffbutler/ui/tui/_fake_agent.py +47 -0
- diffbutler/ui/tui/_foreground_exec.py +23 -0
- diffbutler/ui/tui/app.py +34 -0
- diffbutler/ui/tui/browse.py +119 -0
- diffbutler/ui/tui/browser.py +42 -0
- diffbutler/ui/tui/dashboard.py +926 -0
- diffbutler/ui/tui/foreground.py +103 -0
- diffbutler/ui/tui/handoff_probe.py +184 -0
- diffbutler-0.5.0.dist-info/METADATA +170 -0
- diffbutler-0.5.0.dist-info/RECORD +105 -0
- diffbutler-0.5.0.dist-info/WHEEL +4 -0
- diffbutler-0.5.0.dist-info/entry_points.txt +3 -0
- diffbutler-0.5.0.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
"""Short SQLite transactions serialize explicit starts, sync, and worker claims."""
|
|
2
|
+
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import func, select
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
|
|
8
|
+
from diffbutler.domain.resources import OwnedDirectory
|
|
9
|
+
from diffbutler.domain.review import (
|
|
10
|
+
AnalysisIdentity,
|
|
11
|
+
ComparisonChanged,
|
|
12
|
+
ComparisonCheck,
|
|
13
|
+
ReviewConflict,
|
|
14
|
+
ReviewPolicy,
|
|
15
|
+
ReviewResultRecord,
|
|
16
|
+
RunRecord,
|
|
17
|
+
RunStatus,
|
|
18
|
+
SessionRecord,
|
|
19
|
+
SessionStatus,
|
|
20
|
+
)
|
|
21
|
+
from diffbutler.domain.review import (
|
|
22
|
+
RunClaim as Claim,
|
|
23
|
+
)
|
|
24
|
+
from diffbutler.store.database import Database
|
|
25
|
+
from diffbutler.store.models import (
|
|
26
|
+
AIRun,
|
|
27
|
+
CleanupJob,
|
|
28
|
+
ForegroundLease,
|
|
29
|
+
ForgeAccount,
|
|
30
|
+
PRMonitor,
|
|
31
|
+
PullRequest,
|
|
32
|
+
Repository,
|
|
33
|
+
ReviewResult,
|
|
34
|
+
ReviewSession,
|
|
35
|
+
ReviewTask,
|
|
36
|
+
RunClaim,
|
|
37
|
+
utc_now,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def record(run: AIRun) -> RunRecord:
|
|
42
|
+
return RunRecord(
|
|
43
|
+
run.id,
|
|
44
|
+
run.review_task_id,
|
|
45
|
+
run.review_session_id,
|
|
46
|
+
run.reviewer_id,
|
|
47
|
+
run.head_sha,
|
|
48
|
+
run.base_sha,
|
|
49
|
+
run.prompt_version,
|
|
50
|
+
run.config_hash,
|
|
51
|
+
run.analysis_key,
|
|
52
|
+
run.attempt,
|
|
53
|
+
RunStatus(run.status),
|
|
54
|
+
run.requested_at,
|
|
55
|
+
run.started_at,
|
|
56
|
+
run.finished_at,
|
|
57
|
+
run.status_reason,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def session_record(session: ReviewSession) -> SessionRecord:
|
|
62
|
+
return SessionRecord(
|
|
63
|
+
session.id,
|
|
64
|
+
session.review_task_id,
|
|
65
|
+
session.reviewer_id,
|
|
66
|
+
session.generation,
|
|
67
|
+
session.display_name,
|
|
68
|
+
session.backend_session_id,
|
|
69
|
+
SessionStatus(session.status),
|
|
70
|
+
session.created_at,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def transition(db: AsyncSession, task: ReviewTask, state: str) -> None:
|
|
75
|
+
task.state = state
|
|
76
|
+
task.action_version += 1
|
|
77
|
+
task.updated_at = utc_now()
|
|
78
|
+
task.state_source = "local"
|
|
79
|
+
identity = await db.get(ForgeAccount, task.account_id)
|
|
80
|
+
if identity is not None:
|
|
81
|
+
# A network refresh read before this local action must retry its snapshot.
|
|
82
|
+
identity.version += 1
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SQLiteReviewStore:
|
|
86
|
+
def __init__(self, database: Database) -> None:
|
|
87
|
+
self.database = database
|
|
88
|
+
|
|
89
|
+
async def _begin(self, db: AsyncSession) -> None:
|
|
90
|
+
connection = await db.connection()
|
|
91
|
+
await connection.exec_driver_sql("BEGIN IMMEDIATE")
|
|
92
|
+
|
|
93
|
+
async def _task(self, db: AsyncSession, task_id: UUID) -> tuple[ReviewTask, PRMonitor]:
|
|
94
|
+
task = await db.get(ReviewTask, task_id)
|
|
95
|
+
if task is None or task.completed_at is not None:
|
|
96
|
+
raise ReviewConflict("Review task is missing or completed")
|
|
97
|
+
monitor = await db.get(PRMonitor, (task.account_id, task.pull_request_id))
|
|
98
|
+
if (
|
|
99
|
+
monitor is None
|
|
100
|
+
or monitor.task_id != task.id
|
|
101
|
+
or monitor.snapshot_json["state"] != "open"
|
|
102
|
+
):
|
|
103
|
+
raise ReviewConflict("Review task is no longer current and open")
|
|
104
|
+
return task, monitor
|
|
105
|
+
|
|
106
|
+
async def _run(self, db: AsyncSession, run_id: UUID) -> AIRun:
|
|
107
|
+
run = await db.get(AIRun, run_id)
|
|
108
|
+
if run is None:
|
|
109
|
+
raise ReviewConflict("Review run does not exist")
|
|
110
|
+
return run
|
|
111
|
+
|
|
112
|
+
async def _check_foreground(self, db: AsyncSession, task_id: UUID) -> None:
|
|
113
|
+
held = await db.scalar(
|
|
114
|
+
select(ForegroundLease.token)
|
|
115
|
+
.join(ReviewSession)
|
|
116
|
+
.where(ReviewSession.review_task_id == task_id)
|
|
117
|
+
)
|
|
118
|
+
if held is not None:
|
|
119
|
+
raise ReviewConflict("Foreground process still owns this task")
|
|
120
|
+
|
|
121
|
+
async def latest_run(self, task_id: UUID) -> RunRecord | None:
|
|
122
|
+
async with self.database.sessions() as db:
|
|
123
|
+
run = await db.scalar(
|
|
124
|
+
select(AIRun)
|
|
125
|
+
.where(AIRun.review_task_id == task_id)
|
|
126
|
+
.order_by(AIRun.requested_at.desc(), AIRun.attempt.desc())
|
|
127
|
+
.limit(1)
|
|
128
|
+
)
|
|
129
|
+
return None if run is None else record(run)
|
|
130
|
+
|
|
131
|
+
async def mark_approved(self, task_id: UUID) -> None:
|
|
132
|
+
async with self.database.sessions() as db:
|
|
133
|
+
await self._begin(db)
|
|
134
|
+
task, monitor = await self._task(db, task_id)
|
|
135
|
+
await transition(db, task, "DONE")
|
|
136
|
+
task.completed_at = utc_now()
|
|
137
|
+
task.completion_reason = "local_approval"
|
|
138
|
+
task.completion_source = "local"
|
|
139
|
+
task.completed_head_sha = str(monitor.snapshot_json["head_sha"])
|
|
140
|
+
task.approved_sha = task.completed_head_sha
|
|
141
|
+
task.completion_event_watermark = monitor.review_key
|
|
142
|
+
runs = await db.scalars(
|
|
143
|
+
select(AIRun).where(
|
|
144
|
+
AIRun.review_task_id == task_id, AIRun.status.in_(["queued", "running"])
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
for run in runs:
|
|
148
|
+
run.status = "cancelled"
|
|
149
|
+
run.status_reason = "Review cancelled"
|
|
150
|
+
run.finished_at = utc_now()
|
|
151
|
+
db.add(CleanupJob(review_task_id=task_id))
|
|
152
|
+
await db.commit()
|
|
153
|
+
|
|
154
|
+
async def get_run(self, run_id: UUID) -> RunRecord:
|
|
155
|
+
async with self.database.sessions() as db:
|
|
156
|
+
return record(await self._run(db, run_id))
|
|
157
|
+
|
|
158
|
+
async def _session(self, db: AsyncSession, session_id: UUID) -> ReviewSession:
|
|
159
|
+
session = await db.get(ReviewSession, session_id)
|
|
160
|
+
if session is None:
|
|
161
|
+
raise ReviewConflict("Review session does not exist")
|
|
162
|
+
return session
|
|
163
|
+
|
|
164
|
+
async def get_session(self, session_id: UUID) -> SessionRecord:
|
|
165
|
+
async with self.database.sessions() as db:
|
|
166
|
+
return session_record(await self._session(db, session_id))
|
|
167
|
+
|
|
168
|
+
async def session_resources(self, session_id: UUID) -> tuple[OwnedDirectory, ...]:
|
|
169
|
+
async with self.database.sessions() as db:
|
|
170
|
+
session = await self._session(db, session_id)
|
|
171
|
+
return tuple(
|
|
172
|
+
OwnedDirectory.from_manifest(item) for item in session.resource_manifest_json
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
async def register_resource(self, token: UUID, resource: OwnedDirectory) -> None:
|
|
176
|
+
"""Persist ownership before creation; cancellation/completion still allow cleanup."""
|
|
177
|
+
async with self.database.sessions() as db:
|
|
178
|
+
await self._begin(db)
|
|
179
|
+
claim = await db.get(RunClaim, token)
|
|
180
|
+
if claim is None:
|
|
181
|
+
raise ReviewConflict("Worker claim does not exist")
|
|
182
|
+
run = await self._run(db, claim.run_id)
|
|
183
|
+
session = await self._session(db, run.review_session_id)
|
|
184
|
+
if session.id != resource.session_id or session.status == "cleaned":
|
|
185
|
+
raise ReviewConflict("Resource does not belong to an owned session")
|
|
186
|
+
entry = resource.manifest()
|
|
187
|
+
if entry not in session.resource_manifest_json:
|
|
188
|
+
session.resource_manifest_json = [*session.resource_manifest_json, entry]
|
|
189
|
+
await db.commit()
|
|
190
|
+
|
|
191
|
+
async def bind_backend_session(self, token: UUID, backend_session_id: str) -> SessionRecord:
|
|
192
|
+
async with self.database.sessions() as db:
|
|
193
|
+
await self._begin(db)
|
|
194
|
+
claim = await db.get(RunClaim, token)
|
|
195
|
+
if claim is None:
|
|
196
|
+
raise ReviewConflict("Worker claim does not exist")
|
|
197
|
+
run = await self._run(db, claim.run_id)
|
|
198
|
+
session = await self._session(db, run.review_session_id)
|
|
199
|
+
self._bind_backend_session(session, backend_session_id)
|
|
200
|
+
result = session_record(session)
|
|
201
|
+
await db.commit()
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def _bind_backend_session(session: ReviewSession, backend_session_id: str) -> None:
|
|
206
|
+
# Opaque identifiers may contain spaces or shell metacharacters. They are
|
|
207
|
+
# data, never command templates or paths. Bound their stored size and reject
|
|
208
|
+
# terminal/control characters without including the value in diagnostics.
|
|
209
|
+
if (
|
|
210
|
+
not backend_session_id.strip()
|
|
211
|
+
or len(backend_session_id) > 1024
|
|
212
|
+
or any(ord(char) < 32 or 127 <= ord(char) <= 159 for char in backend_session_id)
|
|
213
|
+
):
|
|
214
|
+
raise ValueError("Backend session ID must be nonblank, bounded, and control-free")
|
|
215
|
+
if session.status == "cleaned":
|
|
216
|
+
raise ReviewConflict("Review session was cleaned")
|
|
217
|
+
if session.backend_session_id not in {None, backend_session_id}:
|
|
218
|
+
raise ReviewConflict("Backend session identity cannot be replaced")
|
|
219
|
+
session.backend_session_id = backend_session_id
|
|
220
|
+
|
|
221
|
+
async def suspend_session(self, session_id: UUID) -> SessionRecord:
|
|
222
|
+
async with self.database.sessions() as db:
|
|
223
|
+
await self._begin(db)
|
|
224
|
+
session = await self._session(db, session_id)
|
|
225
|
+
await self._task(db, session.review_task_id)
|
|
226
|
+
await self._check_foreground(db, session.review_task_id)
|
|
227
|
+
if session.status == "cleaned" or session.cleanup_status != "not_requested":
|
|
228
|
+
raise ReviewConflict("Review session is cleaned or awaiting cleanup")
|
|
229
|
+
active = await db.scalar(
|
|
230
|
+
select(AIRun.id).where(
|
|
231
|
+
AIRun.review_session_id == session.id,
|
|
232
|
+
AIRun.status.in_(["queued", "running"]),
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
held = await db.scalar(
|
|
236
|
+
select(RunClaim.token).join(AIRun).where(AIRun.review_session_id == session.id)
|
|
237
|
+
)
|
|
238
|
+
if active is not None or held is not None:
|
|
239
|
+
raise ReviewConflict("Review session still has queued work or worker ownership")
|
|
240
|
+
session.status = "suspended"
|
|
241
|
+
result = session_record(session)
|
|
242
|
+
await db.commit()
|
|
243
|
+
return result
|
|
244
|
+
|
|
245
|
+
async def request_review(self, identity: AnalysisIdentity, policy: ReviewPolicy) -> RunRecord:
|
|
246
|
+
async with self.database.sessions() as db:
|
|
247
|
+
await self._begin(db)
|
|
248
|
+
task, monitor = await self._task(db, identity.task_id)
|
|
249
|
+
await self._check_foreground(db, task.id)
|
|
250
|
+
if monitor.snapshot_json["head_sha"] != identity.head_sha:
|
|
251
|
+
raise ReviewConflict("PR head changed; refresh before starting")
|
|
252
|
+
pr = await db.get(PullRequest, task.pull_request_id)
|
|
253
|
+
assert pr is not None
|
|
254
|
+
repo = await db.get(Repository, pr.repository_id)
|
|
255
|
+
assert repo is not None
|
|
256
|
+
if (
|
|
257
|
+
not repo.enabled
|
|
258
|
+
or f"{repo.namespace}/{repo.name}" in policy.disabled_repositories
|
|
259
|
+
or identity.reviewer_id not in policy.reviewer_ids
|
|
260
|
+
):
|
|
261
|
+
raise ReviewConflict("Repository or reviewer is disabled by review policy")
|
|
262
|
+
active = await db.scalar(
|
|
263
|
+
select(AIRun).where(
|
|
264
|
+
AIRun.review_task_id == task.id, AIRun.status.in_(["queued", "running"])
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
if active is not None:
|
|
268
|
+
if active.analysis_key != identity.key:
|
|
269
|
+
raise ReviewConflict("Another analysis is already queued or running")
|
|
270
|
+
return record(active)
|
|
271
|
+
held = await db.scalar(
|
|
272
|
+
select(RunClaim).join(AIRun).where(AIRun.review_task_id == task.id)
|
|
273
|
+
)
|
|
274
|
+
if held is not None:
|
|
275
|
+
raise ReviewConflict("Previous worker has not released ownership")
|
|
276
|
+
conversation = await db.scalar(
|
|
277
|
+
select(ReviewSession)
|
|
278
|
+
.where(
|
|
279
|
+
ReviewSession.review_task_id == task.id,
|
|
280
|
+
ReviewSession.reviewer_id == identity.reviewer_id,
|
|
281
|
+
)
|
|
282
|
+
.order_by(ReviewSession.generation.desc())
|
|
283
|
+
.limit(1)
|
|
284
|
+
)
|
|
285
|
+
if (
|
|
286
|
+
conversation is not None
|
|
287
|
+
and conversation.cleanup_status != "not_requested"
|
|
288
|
+
and conversation.status != "cleaned"
|
|
289
|
+
):
|
|
290
|
+
raise ReviewConflict("Session cleanup is pending")
|
|
291
|
+
if conversation is None or conversation.status == "cleaned":
|
|
292
|
+
conversation = ReviewSession(
|
|
293
|
+
review_task_id=task.id,
|
|
294
|
+
reviewer_id=identity.reviewer_id,
|
|
295
|
+
generation=conversation.generation + 1 if conversation else 1,
|
|
296
|
+
display_name=f"{repo.namespace}/{repo.name}#{pr.number}",
|
|
297
|
+
)
|
|
298
|
+
db.add(conversation)
|
|
299
|
+
await db.flush()
|
|
300
|
+
attempt = await db.scalar(
|
|
301
|
+
select(func.max(AIRun.attempt)).where(AIRun.analysis_key == identity.key)
|
|
302
|
+
)
|
|
303
|
+
run = AIRun(
|
|
304
|
+
review_task_id=task.id,
|
|
305
|
+
review_session_id=conversation.id,
|
|
306
|
+
reviewer_id=identity.reviewer_id,
|
|
307
|
+
head_sha=identity.head_sha,
|
|
308
|
+
base_sha=str(monitor.snapshot_json["base_sha"]),
|
|
309
|
+
prompt_version=identity.prompt_version,
|
|
310
|
+
config_hash=identity.config_hash,
|
|
311
|
+
analysis_key=identity.key,
|
|
312
|
+
attempt=(attempt or 0) + 1,
|
|
313
|
+
)
|
|
314
|
+
db.add(run)
|
|
315
|
+
await transition(db, task, "QUEUED")
|
|
316
|
+
await db.flush()
|
|
317
|
+
result = record(run)
|
|
318
|
+
await db.commit()
|
|
319
|
+
return result
|
|
320
|
+
|
|
321
|
+
async def claim_review(
|
|
322
|
+
self, run_id: UUID, *, capacity: int, policy: ReviewPolicy
|
|
323
|
+
) -> Claim | None:
|
|
324
|
+
if capacity < 1:
|
|
325
|
+
raise ValueError("capacity must be positive")
|
|
326
|
+
async with self.database.sessions() as db:
|
|
327
|
+
await self._begin(db)
|
|
328
|
+
run = await self._run(db, run_id)
|
|
329
|
+
if run.status != "queued":
|
|
330
|
+
return None
|
|
331
|
+
task, monitor = await self._task(db, run.review_task_id)
|
|
332
|
+
await self._check_foreground(db, task.id)
|
|
333
|
+
if task.state != "QUEUED" or monitor.snapshot_json["head_sha"] != run.head_sha:
|
|
334
|
+
raise ReviewConflict("Queued analysis is no longer current")
|
|
335
|
+
pr = await db.get(PullRequest, task.pull_request_id)
|
|
336
|
+
assert pr is not None
|
|
337
|
+
repo = await db.get(Repository, pr.repository_id)
|
|
338
|
+
assert repo is not None
|
|
339
|
+
if (
|
|
340
|
+
not repo.enabled
|
|
341
|
+
or f"{repo.namespace}/{repo.name}" in policy.disabled_repositories
|
|
342
|
+
or run.reviewer_id not in policy.reviewer_ids
|
|
343
|
+
):
|
|
344
|
+
return None
|
|
345
|
+
count = await db.scalar(select(func.count()).select_from(RunClaim))
|
|
346
|
+
if (count or 0) >= capacity:
|
|
347
|
+
return None
|
|
348
|
+
session = await self._session(db, run.review_session_id)
|
|
349
|
+
if session.status == "cleaned" or session.cleanup_status != "not_requested":
|
|
350
|
+
raise ReviewConflict("Review session is cleaned or awaiting cleanup")
|
|
351
|
+
session.status = "ready"
|
|
352
|
+
claim = RunClaim(run_id=run.id)
|
|
353
|
+
db.add(claim)
|
|
354
|
+
run.status = "running"
|
|
355
|
+
run.started_at = utc_now()
|
|
356
|
+
await transition(db, task, "AI_RUNNING")
|
|
357
|
+
await db.flush()
|
|
358
|
+
result = Claim(claim.token, record(run))
|
|
359
|
+
await db.commit()
|
|
360
|
+
return result
|
|
361
|
+
|
|
362
|
+
async def comparison_snapshot(self, token: UUID) -> tuple[RunRecord, str]:
|
|
363
|
+
async with self.database.sessions() as db:
|
|
364
|
+
claim = await db.get(RunClaim, token)
|
|
365
|
+
if claim is None:
|
|
366
|
+
raise ReviewConflict("Worker claim does not exist")
|
|
367
|
+
run = await self._run(db, claim.run_id)
|
|
368
|
+
task = await db.get(ReviewTask, run.review_task_id)
|
|
369
|
+
assert task is not None
|
|
370
|
+
monitor = await db.get(PRMonitor, (task.account_id, task.pull_request_id))
|
|
371
|
+
base = str(monitor.snapshot_json["base_sha"]) if monitor else run.base_sha
|
|
372
|
+
return record(run), base
|
|
373
|
+
|
|
374
|
+
@staticmethod
|
|
375
|
+
def _comparison_matches(
|
|
376
|
+
run: AIRun, monitor: PRMonitor, comparison: ComparisonCheck | None
|
|
377
|
+
) -> bool:
|
|
378
|
+
base = str(monitor.snapshot_json["base_sha"])
|
|
379
|
+
if comparison is None:
|
|
380
|
+
return base == run.base_sha
|
|
381
|
+
if comparison.base_sha != base:
|
|
382
|
+
raise ComparisonChanged()
|
|
383
|
+
return comparison.matches
|
|
384
|
+
|
|
385
|
+
async def claim_is_current(
|
|
386
|
+
self, token: UUID, policy: ReviewPolicy, comparison: ComparisonCheck | None = None
|
|
387
|
+
) -> bool:
|
|
388
|
+
"""Recheck ownership, live revisions and policy before/during execution."""
|
|
389
|
+
async with self.database.sessions() as db:
|
|
390
|
+
await self._begin(db)
|
|
391
|
+
claim = await db.get(RunClaim, token)
|
|
392
|
+
if claim is None:
|
|
393
|
+
raise ReviewConflict("Worker claim does not exist")
|
|
394
|
+
run = await self._run(db, claim.run_id)
|
|
395
|
+
if run.status != "running":
|
|
396
|
+
return False
|
|
397
|
+
task, monitor = await self._task(db, run.review_task_id)
|
|
398
|
+
if monitor.snapshot_json["head_sha"] != run.head_sha or not self._comparison_matches(
|
|
399
|
+
run, monitor, comparison
|
|
400
|
+
):
|
|
401
|
+
run.status = "stale"
|
|
402
|
+
run.status_reason = "PR comparison changed during review"
|
|
403
|
+
run.finished_at = utc_now()
|
|
404
|
+
await transition(db, task, "PENDING_AI_REVIEW")
|
|
405
|
+
await db.commit()
|
|
406
|
+
return False
|
|
407
|
+
session = await self._session(db, run.review_session_id)
|
|
408
|
+
pr = await db.get(PullRequest, task.pull_request_id)
|
|
409
|
+
assert pr is not None
|
|
410
|
+
repo = await db.get(Repository, pr.repository_id)
|
|
411
|
+
assert repo is not None
|
|
412
|
+
await self._check_foreground(db, task.id)
|
|
413
|
+
return (
|
|
414
|
+
task.state == "AI_RUNNING"
|
|
415
|
+
and session.status != "cleaned"
|
|
416
|
+
and session.cleanup_status == "not_requested"
|
|
417
|
+
and repo.enabled
|
|
418
|
+
and f"{repo.namespace}/{repo.name}" not in policy.disabled_repositories
|
|
419
|
+
and run.reviewer_id in policy.reviewer_ids
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
async def cancel_review(self, run_id: UUID) -> RunRecord:
|
|
423
|
+
async with self.database.sessions() as db:
|
|
424
|
+
await self._begin(db)
|
|
425
|
+
run = await self._run(db, run_id)
|
|
426
|
+
if run.status in {"queued", "running"}:
|
|
427
|
+
run.status = "cancelled"
|
|
428
|
+
run.status_reason = "Review cancelled"
|
|
429
|
+
run.finished_at = utc_now()
|
|
430
|
+
task = await db.get(ReviewTask, run.review_task_id)
|
|
431
|
+
assert task is not None
|
|
432
|
+
if task.completed_at is None and task.state in {"QUEUED", "AI_RUNNING"}:
|
|
433
|
+
await transition(db, task, "PENDING_AI_REVIEW")
|
|
434
|
+
result = record(run)
|
|
435
|
+
await db.commit()
|
|
436
|
+
return result
|
|
437
|
+
|
|
438
|
+
async def release_review(
|
|
439
|
+
self, token: UUID, failure: ReviewResultRecord | None = None
|
|
440
|
+
) -> RunRecord:
|
|
441
|
+
async with self.database.sessions() as db:
|
|
442
|
+
await self._begin(db)
|
|
443
|
+
claim = await db.get(RunClaim, token)
|
|
444
|
+
if claim is None:
|
|
445
|
+
raise ReviewConflict("Worker claim does not exist")
|
|
446
|
+
run = await self._run(db, claim.run_id)
|
|
447
|
+
if run.status == "running":
|
|
448
|
+
run.status = "failed"
|
|
449
|
+
run.status_reason = (
|
|
450
|
+
failure.error if failure else "Review interrupted before completion"
|
|
451
|
+
)
|
|
452
|
+
run.finished_at = utc_now()
|
|
453
|
+
task = await db.get(ReviewTask, run.review_task_id)
|
|
454
|
+
assert task is not None
|
|
455
|
+
if task.completed_at is None and task.state == "AI_RUNNING":
|
|
456
|
+
await transition(db, task, "PENDING_AI_REVIEW")
|
|
457
|
+
if failure is not None and await db.get(ReviewResult, run.id) is None:
|
|
458
|
+
run.exit_code = failure.exit_code
|
|
459
|
+
run.stdout_blob = failure.stdout
|
|
460
|
+
run.stderr_blob = failure.stderr
|
|
461
|
+
run.parse_status = "failed"
|
|
462
|
+
db.add(
|
|
463
|
+
ReviewResult(
|
|
464
|
+
run_id=run.id,
|
|
465
|
+
document_json=None,
|
|
466
|
+
anchors_json=None,
|
|
467
|
+
parse_tier=None,
|
|
468
|
+
error=run.status_reason or failure.error,
|
|
469
|
+
notes=[],
|
|
470
|
+
selected_indices=[],
|
|
471
|
+
min_confidence=failure.min_confidence,
|
|
472
|
+
findings_threshold=failure.findings_threshold,
|
|
473
|
+
)
|
|
474
|
+
)
|
|
475
|
+
await db.delete(claim)
|
|
476
|
+
result = record(run)
|
|
477
|
+
await db.commit()
|
|
478
|
+
return result
|
|
479
|
+
|
|
480
|
+
async def get_result(self, run_id: UUID) -> ReviewResultRecord | None:
|
|
481
|
+
async with self.database.sessions() as db:
|
|
482
|
+
run = await self._run(db, run_id)
|
|
483
|
+
result = await db.get(ReviewResult, run_id)
|
|
484
|
+
if result is None:
|
|
485
|
+
return None
|
|
486
|
+
assert run.exit_code is not None
|
|
487
|
+
return ReviewResultRecord(
|
|
488
|
+
run.exit_code,
|
|
489
|
+
run.stdout_blob,
|
|
490
|
+
run.stderr_blob,
|
|
491
|
+
result.document_json,
|
|
492
|
+
result.parse_tier,
|
|
493
|
+
result.error,
|
|
494
|
+
tuple(result.notes),
|
|
495
|
+
tuple(result.selected_indices),
|
|
496
|
+
result.min_confidence,
|
|
497
|
+
result.findings_threshold,
|
|
498
|
+
result.anchors_json,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
async def finalize_review(
|
|
502
|
+
self,
|
|
503
|
+
token: UUID,
|
|
504
|
+
result: ReviewResultRecord,
|
|
505
|
+
*,
|
|
506
|
+
backend_session_id: str | None,
|
|
507
|
+
no_findings: bool,
|
|
508
|
+
comparison: ComparisonCheck | None = None,
|
|
509
|
+
) -> RunRecord:
|
|
510
|
+
"""Caller has established process-tree exit; persist and release atomically."""
|
|
511
|
+
async with self.database.sessions() as db:
|
|
512
|
+
await self._begin(db)
|
|
513
|
+
claim = await db.get(RunClaim, token)
|
|
514
|
+
if claim is None:
|
|
515
|
+
raise ReviewConflict("Worker claim does not exist")
|
|
516
|
+
run = await self._run(db, claim.run_id)
|
|
517
|
+
session = await self._session(db, run.review_session_id)
|
|
518
|
+
if result.anchors_json is not None and (
|
|
519
|
+
result.anchor_base_sha,
|
|
520
|
+
result.anchor_head_sha,
|
|
521
|
+
) != (run.base_sha, run.head_sha):
|
|
522
|
+
raise ReviewConflict("Anchor index revisions do not match the run")
|
|
523
|
+
if session.status == "cleaned":
|
|
524
|
+
raise ReviewConflict("Review session was cleaned")
|
|
525
|
+
if backend_session_id is not None:
|
|
526
|
+
self._bind_backend_session(session, backend_session_id)
|
|
527
|
+
if await db.get(ReviewResult, run.id) is not None:
|
|
528
|
+
raise ReviewConflict("Review result already exists")
|
|
529
|
+
run.exit_code = result.exit_code
|
|
530
|
+
run.stdout_blob = result.stdout
|
|
531
|
+
run.stderr_blob = result.stderr
|
|
532
|
+
run.parse_status = "succeeded" if result.document_json is not None else "failed"
|
|
533
|
+
db.add(
|
|
534
|
+
ReviewResult(
|
|
535
|
+
run_id=run.id,
|
|
536
|
+
document_json=result.document_json,
|
|
537
|
+
anchors_json=result.anchors_json,
|
|
538
|
+
parse_tier=result.parse_tier,
|
|
539
|
+
error=result.error,
|
|
540
|
+
notes=list(result.notes),
|
|
541
|
+
selected_indices=list(result.selected_indices),
|
|
542
|
+
min_confidence=result.min_confidence,
|
|
543
|
+
findings_threshold=result.findings_threshold,
|
|
544
|
+
)
|
|
545
|
+
)
|
|
546
|
+
if run.status == "running":
|
|
547
|
+
task = await db.get(ReviewTask, run.review_task_id)
|
|
548
|
+
assert task is not None
|
|
549
|
+
monitor = await db.get(PRMonitor, (task.account_id, task.pull_request_id))
|
|
550
|
+
current = (
|
|
551
|
+
task.completed_at is None
|
|
552
|
+
and task.state == "AI_RUNNING"
|
|
553
|
+
and monitor is not None
|
|
554
|
+
and monitor.task_id == task.id
|
|
555
|
+
and monitor.snapshot_json["state"] == "open"
|
|
556
|
+
and monitor.snapshot_json["head_sha"] == run.head_sha
|
|
557
|
+
and self._comparison_matches(run, monitor, comparison)
|
|
558
|
+
and session.cleanup_status == "not_requested"
|
|
559
|
+
)
|
|
560
|
+
if current:
|
|
561
|
+
run.status = "succeeded" if result.document_json is not None else "failed"
|
|
562
|
+
state = "PENDING_HUMAN_REVIEW" if no_findings else "FINDINGS_TRIAGE"
|
|
563
|
+
if run.status == "failed":
|
|
564
|
+
run.status_reason = result.error or "Agent failed"
|
|
565
|
+
# A normal exit with unreadable output needs human inspection;
|
|
566
|
+
# it cannot establish whether the review has findings.
|
|
567
|
+
state = "FINDINGS_TRIAGE" if result.exit_code == 0 else "PENDING_AI_REVIEW"
|
|
568
|
+
await transition(db, task, state)
|
|
569
|
+
else:
|
|
570
|
+
run.status = "cancelled" if task.completed_at is not None else "stale"
|
|
571
|
+
run.status_reason = (
|
|
572
|
+
"Review task completed"
|
|
573
|
+
if task.completed_at
|
|
574
|
+
else "PR comparison changed during review"
|
|
575
|
+
)
|
|
576
|
+
# A changed base may not yet have invalidated queue attention.
|
|
577
|
+
if task.completed_at is None and task.state == "AI_RUNNING":
|
|
578
|
+
await transition(db, task, "PENDING_AI_REVIEW")
|
|
579
|
+
run.finished_at = utc_now()
|
|
580
|
+
await db.delete(claim)
|
|
581
|
+
saved = record(run)
|
|
582
|
+
await db.commit()
|
|
583
|
+
return saved
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""User interfaces for DiffButler."""
|