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,201 @@
|
|
|
1
|
+
"""Atomic local actions and offline history projections."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import select, text
|
|
7
|
+
|
|
8
|
+
from diffbutler.domain.dashboard import CycleRecord
|
|
9
|
+
from diffbutler.domain.forge import Account
|
|
10
|
+
from diffbutler.domain.review import ReviewConflict
|
|
11
|
+
from diffbutler.store.database import Database
|
|
12
|
+
from diffbutler.store.models import (
|
|
13
|
+
AIRun,
|
|
14
|
+
CleanupJob,
|
|
15
|
+
ForgeAccount,
|
|
16
|
+
ObservedActivity,
|
|
17
|
+
PRMonitor,
|
|
18
|
+
ReviewSession,
|
|
19
|
+
ReviewTask,
|
|
20
|
+
UIPreference,
|
|
21
|
+
utc_now,
|
|
22
|
+
)
|
|
23
|
+
from diffbutler.store.queue import EVENT_ADAPTER, PR_ADAPTER
|
|
24
|
+
from diffbutler.store.review import record, session_record, transition
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SQLiteDashboardStore:
|
|
28
|
+
def __init__(self, database: Database) -> None:
|
|
29
|
+
self.database = database
|
|
30
|
+
|
|
31
|
+
async def reset_task(self, task_id: UUID) -> None:
|
|
32
|
+
"""Forget active monitoring and invalidate work, retaining history and cleanup ownership."""
|
|
33
|
+
async with self.database.sessions() as db:
|
|
34
|
+
await db.execute(text("BEGIN IMMEDIATE"))
|
|
35
|
+
task = await db.get(ReviewTask, task_id)
|
|
36
|
+
if task is None or task.completed_at is not None:
|
|
37
|
+
raise ReviewConflict("Review task is missing or completed")
|
|
38
|
+
monitor = await db.get(PRMonitor, (task.account_id, task.pull_request_id))
|
|
39
|
+
if monitor is None or monitor.task_id != task.id:
|
|
40
|
+
raise ReviewConflict("Review task is no longer current")
|
|
41
|
+
# Older migrated cycles may still rely on the monitor for their snapshot.
|
|
42
|
+
cycles = await db.scalars(
|
|
43
|
+
select(ReviewTask).where(
|
|
44
|
+
ReviewTask.account_id == task.account_id,
|
|
45
|
+
ReviewTask.pull_request_id == task.pull_request_id,
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
for cycle in cycles:
|
|
49
|
+
if not cycle.snapshot_json:
|
|
50
|
+
cycle.snapshot_json = monitor.snapshot_json
|
|
51
|
+
await transition(db, task, "DONE")
|
|
52
|
+
task.completed_at = utc_now()
|
|
53
|
+
task.completion_reason = "local_reset"
|
|
54
|
+
task.completion_source = "local"
|
|
55
|
+
task.completed_head_sha = str(monitor.snapshot_json["head_sha"])
|
|
56
|
+
task.following = False
|
|
57
|
+
task.request_sources_json = []
|
|
58
|
+
runs = await db.scalars(
|
|
59
|
+
select(AIRun).where(
|
|
60
|
+
AIRun.review_task_id == task_id, AIRun.status.in_(("queued", "running"))
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
for run in runs:
|
|
64
|
+
run.status = "cancelled"
|
|
65
|
+
run.status_reason = "PR reset locally"
|
|
66
|
+
run.finished_at = utc_now()
|
|
67
|
+
# Claims remain held until their supervisors establish process exit.
|
|
68
|
+
db.add(CleanupJob(review_task_id=task_id))
|
|
69
|
+
await db.delete(monitor)
|
|
70
|
+
await db.commit()
|
|
71
|
+
|
|
72
|
+
async def choose_state(self, task_id: UUID, *, waiting: bool) -> None:
|
|
73
|
+
async with self.database.sessions() as db:
|
|
74
|
+
await db.execute(text("BEGIN IMMEDIATE"))
|
|
75
|
+
task = await db.get(ReviewTask, task_id)
|
|
76
|
+
if task is None or task.completed_at is not None:
|
|
77
|
+
raise ReviewConflict("Review task is missing or completed")
|
|
78
|
+
monitor = await db.get(PRMonitor, (task.account_id, task.pull_request_id))
|
|
79
|
+
if (
|
|
80
|
+
monitor is None
|
|
81
|
+
or monitor.task_id != task.id
|
|
82
|
+
or monitor.snapshot_json["state"] != "open"
|
|
83
|
+
):
|
|
84
|
+
raise ReviewConflict("Review task is no longer current and open")
|
|
85
|
+
now = utc_now()
|
|
86
|
+
await transition(
|
|
87
|
+
db, task, "PENDING_AUTHOR_CHANGES" if waiting else "PENDING_HUMAN_REVIEW"
|
|
88
|
+
)
|
|
89
|
+
# Events predating this explicit choice cannot override it on a retry.
|
|
90
|
+
task.state_event_watermark = max(
|
|
91
|
+
task.state_event_watermark or "",
|
|
92
|
+
monitor.review_key,
|
|
93
|
+
now.isoformat(timespec="microseconds") + ":~",
|
|
94
|
+
)
|
|
95
|
+
if not waiting:
|
|
96
|
+
task.opened_at = now
|
|
97
|
+
monitor.notice = (
|
|
98
|
+
"Waiting for author chosen locally" if waiting else "Manual review chosen locally"
|
|
99
|
+
)
|
|
100
|
+
runs = await db.scalars(
|
|
101
|
+
select(AIRun).where(
|
|
102
|
+
AIRun.review_task_id == task_id, AIRun.status.in_(("queued", "running"))
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
for run in runs:
|
|
106
|
+
run.status = "cancelled"
|
|
107
|
+
run.finished_at = now
|
|
108
|
+
await db.commit()
|
|
109
|
+
|
|
110
|
+
async def refresh_pending(self, task_id: UUID) -> None:
|
|
111
|
+
async with self.database.sessions() as db:
|
|
112
|
+
await db.execute(text("BEGIN IMMEDIATE"))
|
|
113
|
+
task = await db.get(ReviewTask, task_id)
|
|
114
|
+
if task is None:
|
|
115
|
+
raise ReviewConflict("Task does not exist")
|
|
116
|
+
monitor = await db.get(PRMonitor, (task.account_id, task.pull_request_id))
|
|
117
|
+
identity = await db.get(ForgeAccount, task.account_id)
|
|
118
|
+
if monitor and identity:
|
|
119
|
+
monitor.refresh_pending = True
|
|
120
|
+
identity.version += 1
|
|
121
|
+
await db.commit()
|
|
122
|
+
|
|
123
|
+
async def preferences(self) -> dict[str, str]:
|
|
124
|
+
async with self.database.sessions() as db:
|
|
125
|
+
return {row.key: row.value for row in await db.scalars(select(UIPreference))}
|
|
126
|
+
|
|
127
|
+
async def set_preference(self, key: str, value: str) -> None:
|
|
128
|
+
async with self.database.sessions() as db:
|
|
129
|
+
await db.execute(text("BEGIN IMMEDIATE"))
|
|
130
|
+
row = await db.get(UIPreference, key)
|
|
131
|
+
if row is None:
|
|
132
|
+
db.add(UIPreference(key=key, value=value))
|
|
133
|
+
else:
|
|
134
|
+
row.value = value
|
|
135
|
+
await db.commit()
|
|
136
|
+
|
|
137
|
+
async def cycles(self) -> tuple[CycleRecord, ...]:
|
|
138
|
+
async with self.database.sessions() as db:
|
|
139
|
+
await db.execute(text("BEGIN"))
|
|
140
|
+
rows = await db.execute(
|
|
141
|
+
select(ReviewTask, ForgeAccount, PRMonitor)
|
|
142
|
+
.join(ForgeAccount, ReviewTask.account_id == ForgeAccount.account_id)
|
|
143
|
+
.outerjoin(
|
|
144
|
+
PRMonitor,
|
|
145
|
+
(PRMonitor.account_id == ReviewTask.account_id)
|
|
146
|
+
& (PRMonitor.pull_request_id == ReviewTask.pull_request_id),
|
|
147
|
+
)
|
|
148
|
+
.order_by(
|
|
149
|
+
ReviewTask.first_seen_at.desc(), ReviewTask.cycle_number.desc(), ReviewTask.id
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
result: list[CycleRecord] = []
|
|
153
|
+
for task, identity, monitor in rows:
|
|
154
|
+
runs = tuple(
|
|
155
|
+
record(run)
|
|
156
|
+
for run in await db.scalars(
|
|
157
|
+
select(AIRun)
|
|
158
|
+
.where(AIRun.review_task_id == task.id)
|
|
159
|
+
.order_by(AIRun.requested_at, AIRun.attempt)
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
sessions = tuple(
|
|
163
|
+
session_record(row)
|
|
164
|
+
for row in await db.scalars(
|
|
165
|
+
select(ReviewSession)
|
|
166
|
+
.where(ReviewSession.review_task_id == task.id)
|
|
167
|
+
.order_by(ReviewSession.created_at)
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
receipts = tuple(
|
|
171
|
+
EVENT_ADAPTER.validate_json(row.payload_json)
|
|
172
|
+
for row in await db.scalars(
|
|
173
|
+
select(ObservedActivity)
|
|
174
|
+
.where(ObservedActivity.review_task_id == task.id)
|
|
175
|
+
.order_by(ObservedActivity.event_id)
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
cleanup = await db.scalar(
|
|
179
|
+
select(CleanupJob).where(CleanupJob.review_task_id == task.id)
|
|
180
|
+
)
|
|
181
|
+
result.append(
|
|
182
|
+
CycleRecord(
|
|
183
|
+
task.id,
|
|
184
|
+
Account(identity.forge, identity.login, identity.login),
|
|
185
|
+
PR_ADAPTER.validate_python(
|
|
186
|
+
task.snapshot_json or (monitor.snapshot_json if monitor else {})
|
|
187
|
+
),
|
|
188
|
+
task.cycle_number,
|
|
189
|
+
task.state,
|
|
190
|
+
task.completed_at.replace(tzinfo=UTC) if task.completed_at else None,
|
|
191
|
+
task.completion_reason,
|
|
192
|
+
task.completed_head_sha,
|
|
193
|
+
cleanup.status if cleanup else "not_requested",
|
|
194
|
+
cleanup.last_error if cleanup else None,
|
|
195
|
+
task.opened_at.replace(tzinfo=UTC) if task.opened_at else None,
|
|
196
|
+
runs,
|
|
197
|
+
sessions,
|
|
198
|
+
receipts,
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
return tuple(result)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Async SQLite startup and packaged migrations, with consistent pre-upgrade backups."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sqlite3
|
|
5
|
+
from contextlib import closing
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
import aiosqlite
|
|
12
|
+
from alembic import command
|
|
13
|
+
from alembic.config import Config
|
|
14
|
+
from alembic.migration import MigrationContext
|
|
15
|
+
from alembic.script import ScriptDirectory
|
|
16
|
+
from sqlalchemy import Connection, event, inspect
|
|
17
|
+
from sqlalchemy.engine import URL, AdaptedConnection, make_url
|
|
18
|
+
from sqlalchemy.exc import ArgumentError
|
|
19
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DatabaseError(ValueError):
|
|
23
|
+
"""A safe diagnostic that does not expose connection credentials."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class DatabaseStatus:
|
|
28
|
+
revision: str
|
|
29
|
+
migrated: bool
|
|
30
|
+
backup: Path | None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def migration_config(connection: Connection | None = None) -> Config:
|
|
34
|
+
config = Config()
|
|
35
|
+
config.set_main_option("script_location", str(Path(__file__).parent / "migrations"))
|
|
36
|
+
if connection is not None:
|
|
37
|
+
config.attributes["connection"] = connection
|
|
38
|
+
return config
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def sqlite_url(value: str) -> URL:
|
|
42
|
+
try:
|
|
43
|
+
url = make_url(value)
|
|
44
|
+
except ArgumentError, ValueError:
|
|
45
|
+
raise DatabaseError("db.url: invalid SQLAlchemy URL") from None
|
|
46
|
+
if url.drivername != "sqlite+aiosqlite":
|
|
47
|
+
raise DatabaseError(
|
|
48
|
+
"db.url: Only sqlite+aiosqlite is supported; PostgreSQL/MySQL are not implemented"
|
|
49
|
+
)
|
|
50
|
+
if url.username or url.password or url.host or url.port or url.query:
|
|
51
|
+
raise DatabaseError(
|
|
52
|
+
"db.url: SQLite requires a local file path without credentials or query options"
|
|
53
|
+
)
|
|
54
|
+
if not url.database or url.database == ":memory:":
|
|
55
|
+
raise DatabaseError("db.url: use a persistent SQLite file")
|
|
56
|
+
return url.set(database=str(Path(url.database).expanduser().absolute()))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def configure_sqlite(connection: aiosqlite.Connection) -> None:
|
|
60
|
+
"""Enable WAL without blocking the event loop during startup contention."""
|
|
61
|
+
try:
|
|
62
|
+
async with connection.cursor() as cursor:
|
|
63
|
+
await cursor.execute("PRAGMA busy_timeout=5000")
|
|
64
|
+
await cursor.execute("PRAGMA foreign_keys=ON")
|
|
65
|
+
deadline = asyncio.get_running_loop().time() + 5
|
|
66
|
+
while True:
|
|
67
|
+
try:
|
|
68
|
+
await cursor.execute("PRAGMA journal_mode=WAL")
|
|
69
|
+
break
|
|
70
|
+
except sqlite3.OperationalError as exc:
|
|
71
|
+
# SQLite may return BUSY immediately on a journal-mode lock
|
|
72
|
+
# upgrade, even with busy_timeout set. Retry after yielding.
|
|
73
|
+
if (
|
|
74
|
+
getattr(exc, "sqlite_errorcode", None) != sqlite3.SQLITE_BUSY
|
|
75
|
+
or asyncio.get_running_loop().time() >= deadline
|
|
76
|
+
):
|
|
77
|
+
raise
|
|
78
|
+
await asyncio.sleep(0.05)
|
|
79
|
+
except BaseException:
|
|
80
|
+
# Connect-event failures can occur before SQLAlchemy owns a pool record.
|
|
81
|
+
await connection.close()
|
|
82
|
+
raise
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _configure_sqlite(connection: AdaptedConnection, _record: object) -> None:
|
|
86
|
+
connection.run_async(configure_sqlite)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def backup_sqlite(path: Path) -> Path:
|
|
90
|
+
"""Save a consistent snapshot beside the database before changing its schema."""
|
|
91
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
|
|
92
|
+
destination = path.with_name(f"{path.name}.{stamp}.{uuid4().hex}.bak")
|
|
93
|
+
# SQLite's backup API includes committed WAL contents, unlike copying the file.
|
|
94
|
+
try:
|
|
95
|
+
with (
|
|
96
|
+
closing(sqlite3.connect(path)) as source,
|
|
97
|
+
closing(sqlite3.connect(destination)) as target,
|
|
98
|
+
):
|
|
99
|
+
source.backup(target)
|
|
100
|
+
except sqlite3.Error:
|
|
101
|
+
destination.unlink(missing_ok=True)
|
|
102
|
+
raise DatabaseError(
|
|
103
|
+
"database backup failed; check file permissions and SQLite integrity"
|
|
104
|
+
) from None
|
|
105
|
+
except BaseException:
|
|
106
|
+
destination.unlink(missing_ok=True)
|
|
107
|
+
raise
|
|
108
|
+
return destination
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Database:
|
|
112
|
+
"""Owns the engine/session factory; callers must close it after use."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, url: str) -> None:
|
|
115
|
+
self.url = sqlite_url(url)
|
|
116
|
+
assert self.url.database is not None
|
|
117
|
+
self.path = Path(self.url.database)
|
|
118
|
+
self.engine: AsyncEngine = create_async_engine(self.url)
|
|
119
|
+
event.listen(self.engine.sync_engine, "connect", _configure_sqlite)
|
|
120
|
+
self.sessions = async_sessionmaker(self.engine, expire_on_commit=False)
|
|
121
|
+
|
|
122
|
+
async def initialize(self, *, auto_migrate: bool = True) -> DatabaseStatus:
|
|
123
|
+
existed = self.path.exists()
|
|
124
|
+
if not auto_migrate and not existed:
|
|
125
|
+
raise DatabaseError("database is missing; enable db.auto_migrate to initialize it")
|
|
126
|
+
if auto_migrate:
|
|
127
|
+
await asyncio.to_thread(self.path.parent.mkdir, parents=True, exist_ok=True)
|
|
128
|
+
async with self.engine.connect() as connection:
|
|
129
|
+
# Serialize startup migrations across processes. No tables are changed
|
|
130
|
+
# until a consistent backup has completed successfully.
|
|
131
|
+
await connection.exec_driver_sql("BEGIN IMMEDIATE")
|
|
132
|
+
current = await connection.run_sync(
|
|
133
|
+
lambda conn: MigrationContext.configure(conn).get_current_heads()
|
|
134
|
+
)
|
|
135
|
+
scripts = ScriptDirectory.from_config(migration_config())
|
|
136
|
+
head = scripts.get_current_head()
|
|
137
|
+
assert head is not None
|
|
138
|
+
if current == (head,):
|
|
139
|
+
await connection.commit()
|
|
140
|
+
return DatabaseStatus(head, False, None)
|
|
141
|
+
known = {revision.revision for revision in scripts.walk_revisions()}
|
|
142
|
+
if current and (len(current) != 1 or current[0] not in known):
|
|
143
|
+
raise DatabaseError(
|
|
144
|
+
"database revision is unknown to this version; "
|
|
145
|
+
"use the matching DiffButler release"
|
|
146
|
+
)
|
|
147
|
+
tables = await connection.run_sync(lambda conn: inspect(conn).get_table_names())
|
|
148
|
+
if not current and set(tables) - {"alembic_version"}:
|
|
149
|
+
raise DatabaseError(
|
|
150
|
+
"database has unversioned tables; refusing to adopt or overwrite it"
|
|
151
|
+
)
|
|
152
|
+
if not auto_migrate:
|
|
153
|
+
raise DatabaseError("database migrations are not current; enable db.auto_migrate")
|
|
154
|
+
backup = await asyncio.to_thread(backup_sqlite, self.path) if existed else None
|
|
155
|
+
await connection.run_sync(lambda conn: command.upgrade(migration_config(conn), "head"))
|
|
156
|
+
await connection.commit()
|
|
157
|
+
return DatabaseStatus(head, True, backup)
|
|
158
|
+
|
|
159
|
+
async def close(self) -> None:
|
|
160
|
+
# Cancellation during disposal can interrupt aiosqlite.close after the
|
|
161
|
+
# pool has discarded the connection. Finish closing it before propagating
|
|
162
|
+
# cancellation, including repeated cancellation during app shutdown.
|
|
163
|
+
closing = asyncio.create_task(self.engine.dispose())
|
|
164
|
+
cancelled = False
|
|
165
|
+
while not closing.done():
|
|
166
|
+
try:
|
|
167
|
+
await asyncio.shield(closing)
|
|
168
|
+
except asyncio.CancelledError:
|
|
169
|
+
cancelled = True
|
|
170
|
+
closing.result()
|
|
171
|
+
if cancelled:
|
|
172
|
+
raise asyncio.CancelledError
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Short transactions for foreground leases and completion-triggered cleanup."""
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import func, select
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
|
+
|
|
9
|
+
from diffbutler.domain.lifecycle import (
|
|
10
|
+
CleanupClaim,
|
|
11
|
+
CleanupSession,
|
|
12
|
+
CleanupStatus,
|
|
13
|
+
ForegroundLease,
|
|
14
|
+
HeldOperation,
|
|
15
|
+
)
|
|
16
|
+
from diffbutler.domain.review import ReviewConflict
|
|
17
|
+
from diffbutler.store.database import Database
|
|
18
|
+
from diffbutler.store.models import (
|
|
19
|
+
AIRun,
|
|
20
|
+
CleanupJob,
|
|
21
|
+
PRMonitor,
|
|
22
|
+
ReviewSession,
|
|
23
|
+
ReviewTask,
|
|
24
|
+
RunClaim,
|
|
25
|
+
utc_now,
|
|
26
|
+
)
|
|
27
|
+
from diffbutler.store.models import CleanupClaim as ClaimModel
|
|
28
|
+
from diffbutler.store.models import ForegroundLease as LeaseModel
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def _background_owned(db: AsyncSession, task_id: UUID) -> bool:
|
|
32
|
+
active = await db.scalar(
|
|
33
|
+
select(AIRun.id).where(
|
|
34
|
+
AIRun.review_task_id == task_id, AIRun.status.in_(("queued", "running"))
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
held = await db.scalar(
|
|
38
|
+
select(RunClaim.token).join(AIRun).where(AIRun.review_task_id == task_id)
|
|
39
|
+
)
|
|
40
|
+
return active is not None or held is not None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SQLiteLifecycleStore:
|
|
44
|
+
def __init__(self, database: Database) -> None:
|
|
45
|
+
self.database = database
|
|
46
|
+
|
|
47
|
+
async def _begin(self, db: AsyncSession) -> None:
|
|
48
|
+
connection = await db.connection()
|
|
49
|
+
await connection.exec_driver_sql("BEGIN IMMEDIATE")
|
|
50
|
+
|
|
51
|
+
async def cleanup_status(self) -> CleanupStatus:
|
|
52
|
+
async with self.database.sessions() as db:
|
|
53
|
+
rows = await db.execute(
|
|
54
|
+
select(CleanupJob.status, func.count()).group_by(CleanupJob.status)
|
|
55
|
+
)
|
|
56
|
+
counts = dict(rows.all())
|
|
57
|
+
return CleanupStatus(
|
|
58
|
+
succeeded=counts.get("succeeded", 0),
|
|
59
|
+
pending=counts.get("pending", 0),
|
|
60
|
+
running=counts.get("running", 0),
|
|
61
|
+
failed=counts.get("failed", 0),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def held_operations(self) -> tuple[HeldOperation, ...]:
|
|
65
|
+
"""Expose durable ownership for recovery; ages are not evidence of exit."""
|
|
66
|
+
async with self.database.sessions() as db:
|
|
67
|
+
foreground = await db.scalars(select(LeaseModel).order_by(LeaseModel.acquired_at))
|
|
68
|
+
result = [
|
|
69
|
+
HeldOperation(row.token, "foreground", row.session_id, row.acquired_at)
|
|
70
|
+
for row in foreground
|
|
71
|
+
]
|
|
72
|
+
cleanup = await db.scalars(select(ClaimModel).order_by(ClaimModel.acquired_at))
|
|
73
|
+
result.extend(
|
|
74
|
+
HeldOperation(row.token, "cleanup", row.job_id, row.acquired_at) for row in cleanup
|
|
75
|
+
)
|
|
76
|
+
background = await db.execute(select(RunClaim, AIRun).join(AIRun))
|
|
77
|
+
result.extend(
|
|
78
|
+
HeldOperation(claim.token, "background", run.id, run.started_at or run.requested_at)
|
|
79
|
+
for claim, run in background
|
|
80
|
+
)
|
|
81
|
+
return tuple(result)
|
|
82
|
+
|
|
83
|
+
async def acquire_foreground(self, session_id: UUID) -> ForegroundLease:
|
|
84
|
+
async with self.database.sessions() as db:
|
|
85
|
+
await self._begin(db)
|
|
86
|
+
session = await db.get(ReviewSession, session_id)
|
|
87
|
+
if (
|
|
88
|
+
session is None
|
|
89
|
+
or session.status == "cleaned"
|
|
90
|
+
or session.cleanup_status != "not_requested"
|
|
91
|
+
):
|
|
92
|
+
raise ReviewConflict("Session is missing, cleaned, or awaiting cleanup")
|
|
93
|
+
task = await db.get(ReviewTask, session.review_task_id)
|
|
94
|
+
if task is None or task.completed_at is not None:
|
|
95
|
+
raise ReviewConflict("Session task is completed")
|
|
96
|
+
monitor = await db.get(PRMonitor, (task.account_id, task.pull_request_id))
|
|
97
|
+
if (
|
|
98
|
+
monitor is None
|
|
99
|
+
or monitor.task_id != task.id
|
|
100
|
+
or monitor.snapshot_json["state"] != "open"
|
|
101
|
+
):
|
|
102
|
+
raise ReviewConflict("Session task is no longer current and open")
|
|
103
|
+
if not session.backend_session_id:
|
|
104
|
+
raise ReviewConflict(
|
|
105
|
+
"No agent conversation was created. Request another review before resuming."
|
|
106
|
+
)
|
|
107
|
+
if await _background_owned(db, task.id):
|
|
108
|
+
raise ReviewConflict("Background work still owns this task")
|
|
109
|
+
if await db.scalar(select(LeaseModel.token).where(LeaseModel.session_id == session.id)):
|
|
110
|
+
raise ReviewConflict("Foreground process still owns this session")
|
|
111
|
+
lease = LeaseModel(
|
|
112
|
+
session_id=session.id,
|
|
113
|
+
backend_session_id=session.backend_session_id,
|
|
114
|
+
head_sha=str(monitor.snapshot_json["head_sha"]),
|
|
115
|
+
task_version=task.action_version,
|
|
116
|
+
)
|
|
117
|
+
db.add(lease)
|
|
118
|
+
session.status = "ready"
|
|
119
|
+
session.resumed_at = utc_now()
|
|
120
|
+
await db.flush()
|
|
121
|
+
result = ForegroundLease(
|
|
122
|
+
lease.token,
|
|
123
|
+
session.id,
|
|
124
|
+
lease.backend_session_id,
|
|
125
|
+
lease.head_sha,
|
|
126
|
+
lease.task_version,
|
|
127
|
+
lease.acquired_at,
|
|
128
|
+
)
|
|
129
|
+
await db.commit()
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
async def release_foreground(self, token: UUID) -> None:
|
|
133
|
+
"""Caller has established exit (or that launch never occurred), including descendants."""
|
|
134
|
+
async with self.database.sessions() as db:
|
|
135
|
+
await self._begin(db)
|
|
136
|
+
lease = await db.get(LeaseModel, token)
|
|
137
|
+
if lease is None:
|
|
138
|
+
raise ReviewConflict("Foreground lease does not exist")
|
|
139
|
+
session = await db.get(ReviewSession, lease.session_id)
|
|
140
|
+
assert session is not None
|
|
141
|
+
session.status = "suspended"
|
|
142
|
+
await db.delete(lease)
|
|
143
|
+
await db.commit()
|
|
144
|
+
|
|
145
|
+
async def claim_cleanup(self) -> CleanupClaim | None:
|
|
146
|
+
"""Skip busy jobs; no timeout can steal ownership from a living process."""
|
|
147
|
+
async with self.database.sessions() as db:
|
|
148
|
+
await self._begin(db)
|
|
149
|
+
jobs = await db.scalars(
|
|
150
|
+
select(CleanupJob)
|
|
151
|
+
.where(
|
|
152
|
+
CleanupJob.status.in_(("pending", "failed")),
|
|
153
|
+
(CleanupJob.next_retry_at.is_(None)) | (CleanupJob.next_retry_at <= utc_now()),
|
|
154
|
+
)
|
|
155
|
+
.order_by(CleanupJob.created_at)
|
|
156
|
+
)
|
|
157
|
+
for job in jobs:
|
|
158
|
+
task = await db.get(ReviewTask, job.review_task_id)
|
|
159
|
+
if task is None or task.completed_at is None:
|
|
160
|
+
continue
|
|
161
|
+
if await _background_owned(db, task.id):
|
|
162
|
+
continue
|
|
163
|
+
foreground = await db.scalar(
|
|
164
|
+
select(LeaseModel.token)
|
|
165
|
+
.join(ReviewSession)
|
|
166
|
+
.where(ReviewSession.review_task_id == task.id)
|
|
167
|
+
)
|
|
168
|
+
if foreground is not None:
|
|
169
|
+
continue
|
|
170
|
+
sessions = tuple(
|
|
171
|
+
await db.scalars(
|
|
172
|
+
select(ReviewSession).where(
|
|
173
|
+
ReviewSession.review_task_id == task.id,
|
|
174
|
+
ReviewSession.status != "cleaned",
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
claim = ClaimModel(job_id=job.id)
|
|
179
|
+
db.add(claim)
|
|
180
|
+
job.status = "running"
|
|
181
|
+
job.attempts += 1
|
|
182
|
+
job.last_error = None
|
|
183
|
+
for session in sessions:
|
|
184
|
+
session.cleanup_status = "running"
|
|
185
|
+
await db.flush()
|
|
186
|
+
result = CleanupClaim(
|
|
187
|
+
claim.token,
|
|
188
|
+
job.id,
|
|
189
|
+
tuple(
|
|
190
|
+
CleanupSession(
|
|
191
|
+
session.id,
|
|
192
|
+
session.reviewer_id,
|
|
193
|
+
session.backend_session_id,
|
|
194
|
+
session.transcript_ref,
|
|
195
|
+
tuple(
|
|
196
|
+
tuple(sorted(item.items()))
|
|
197
|
+
for item in session.resource_manifest_json
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
for session in sessions
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
await db.commit()
|
|
204
|
+
return result
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
async def finish_cleanup(self, token: UUID, *, succeeded: bool) -> None:
|
|
208
|
+
"""Commit deletion evidence, or retain resources for an idempotent retry."""
|
|
209
|
+
async with self.database.sessions() as db:
|
|
210
|
+
await self._begin(db)
|
|
211
|
+
claim = await db.get(ClaimModel, token)
|
|
212
|
+
if claim is None:
|
|
213
|
+
raise ReviewConflict("Cleanup claim does not exist")
|
|
214
|
+
job = await db.get(CleanupJob, claim.job_id)
|
|
215
|
+
assert job is not None
|
|
216
|
+
sessions = await db.scalars(
|
|
217
|
+
select(ReviewSession).where(
|
|
218
|
+
ReviewSession.review_task_id == job.review_task_id,
|
|
219
|
+
ReviewSession.status != "cleaned",
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
for session in sessions:
|
|
223
|
+
session.cleanup_status = "succeeded" if succeeded else "failed"
|
|
224
|
+
if succeeded:
|
|
225
|
+
session.status = "cleaned"
|
|
226
|
+
session.cleaned_at = utc_now()
|
|
227
|
+
session.backend_session_id = None
|
|
228
|
+
session.transcript_ref = None
|
|
229
|
+
session.resource_manifest_json = []
|
|
230
|
+
if succeeded:
|
|
231
|
+
runs = await db.scalars(
|
|
232
|
+
select(AIRun).where(AIRun.review_task_id == job.review_task_id)
|
|
233
|
+
)
|
|
234
|
+
for run in runs:
|
|
235
|
+
run.stdout_blob = run.stderr_blob = None
|
|
236
|
+
job.status = "succeeded"
|
|
237
|
+
job.completed_at = utc_now()
|
|
238
|
+
job.next_retry_at = None
|
|
239
|
+
job.last_error = None
|
|
240
|
+
else:
|
|
241
|
+
job.status = "failed"
|
|
242
|
+
job.last_error = "Session cleanup failed; resources retained for retry"
|
|
243
|
+
job.next_retry_at = utc_now() + timedelta(
|
|
244
|
+
seconds=min(3600, 30 * 2 ** min(job.attempts, 7))
|
|
245
|
+
)
|
|
246
|
+
await db.delete(claim)
|
|
247
|
+
await db.commit()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Packaged Alembic migrations."""
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Run migrations on the caller's connection, including async run_sync bridges."""
|
|
2
|
+
|
|
3
|
+
from alembic import context
|
|
4
|
+
|
|
5
|
+
from diffbutler.store.models import Base
|
|
6
|
+
|
|
7
|
+
context.configure(
|
|
8
|
+
connection=context.config.attributes["connection"],
|
|
9
|
+
target_metadata=Base.metadata,
|
|
10
|
+
render_as_batch=True,
|
|
11
|
+
compare_type=True,
|
|
12
|
+
)
|
|
13
|
+
with context.begin_transaction():
|
|
14
|
+
context.run_migrations()
|