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,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()
|