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,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."""