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,260 @@
|
|
|
1
|
+
"""Persistent, owned Git object storage with cross-process serialization and pins."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import fcntl
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import AsyncGenerator
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Literal
|
|
14
|
+
from uuid import NAMESPACE_URL, UUID, uuid5
|
|
15
|
+
|
|
16
|
+
from diffbutler.core.resources import save_json
|
|
17
|
+
from diffbutler.domain.forge import PRRef
|
|
18
|
+
from diffbutler.domain.resources import OwnedDirectory
|
|
19
|
+
from diffbutler.reviewers.resources import OwnedDirectories, settled_io
|
|
20
|
+
from diffbutler.sources.diagnostics import debug_event
|
|
21
|
+
from diffbutler.sources.fetch import fetch_repository, git_fetch_command
|
|
22
|
+
from diffbutler.sources.git import GitSourceReader, SourceError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@asynccontextmanager
|
|
26
|
+
async def file_lock(path: Path) -> AsyncGenerator[None]:
|
|
27
|
+
"""Cancellation never leaves a background thread waiting on an advisory lock."""
|
|
28
|
+
await settled_io(lambda: path.parent.mkdir(parents=True, exist_ok=True))
|
|
29
|
+
fd = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
|
30
|
+
try:
|
|
31
|
+
info = os.fstat(fd)
|
|
32
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid():
|
|
33
|
+
raise ValueError("Invalid managed storage lock")
|
|
34
|
+
while True:
|
|
35
|
+
try:
|
|
36
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
37
|
+
break
|
|
38
|
+
except BlockingIOError:
|
|
39
|
+
await asyncio.sleep(0.05)
|
|
40
|
+
yield
|
|
41
|
+
finally:
|
|
42
|
+
os.close(fd)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def open_directories(path: Path) -> OwnedDirectories:
|
|
46
|
+
marker = path / ".diffbutler-repositories"
|
|
47
|
+
if path.exists() or path.is_symlink():
|
|
48
|
+
directories = await OwnedDirectories.reopen(path)
|
|
49
|
+
if marker.is_symlink() or await settled_io(marker.read_text) != "1\n":
|
|
50
|
+
raise ValueError("Invalid shared repository root")
|
|
51
|
+
return directories
|
|
52
|
+
await settled_io(lambda: path.parent.mkdir(parents=True, exist_ok=True))
|
|
53
|
+
directories = await OwnedDirectories.initialize(path)
|
|
54
|
+
await settled_io(lambda: marker.write_text("1\n"))
|
|
55
|
+
return directories
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RepositoryCache:
|
|
59
|
+
def __init__(self, path: Path, retention_days: int = 30) -> None:
|
|
60
|
+
self.path = path
|
|
61
|
+
self.retention_seconds = retention_days * 86400
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def lock_path(self) -> Path:
|
|
65
|
+
# Outside the root so initialization, deletion, and replacement share a lock.
|
|
66
|
+
return self.path.parent / f".{self.path.name}.lock"
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def resource(directories: OwnedDirectories, ref: PRRef) -> OwnedDirectory:
|
|
70
|
+
identity = json.dumps([ref.forge, ref.namespace, ref.repository])
|
|
71
|
+
key = uuid5(NAMESPACE_URL, identity)
|
|
72
|
+
return OwnedDirectory(directories.root_id, key, key)
|
|
73
|
+
|
|
74
|
+
async def existing(self, ref: PRRef) -> Path | None:
|
|
75
|
+
if not self.path.exists():
|
|
76
|
+
return None
|
|
77
|
+
directories = await open_directories(self.path)
|
|
78
|
+
path = self.path / self.resource(directories, ref).name
|
|
79
|
+
return path if path.is_dir() and not path.is_symlink() else None
|
|
80
|
+
|
|
81
|
+
async def prepare(
|
|
82
|
+
self,
|
|
83
|
+
ref: PRRef,
|
|
84
|
+
session_id: UUID,
|
|
85
|
+
run_id: UUID,
|
|
86
|
+
base_sha: str,
|
|
87
|
+
head_sha: str,
|
|
88
|
+
*,
|
|
89
|
+
local_path: Path | None = None,
|
|
90
|
+
transport: Literal["auto", "https", "ssh"] = "auto",
|
|
91
|
+
) -> tuple[Path, Literal["https", "ssh"] | None]:
|
|
92
|
+
async with file_lock(self.lock_path):
|
|
93
|
+
directories = await open_directories(self.path)
|
|
94
|
+
resource = self.resource(directories, ref)
|
|
95
|
+
path = self.path / resource.name
|
|
96
|
+
debug_event(
|
|
97
|
+
logging.getLogger(__name__),
|
|
98
|
+
"Preparing source context",
|
|
99
|
+
repository=path,
|
|
100
|
+
local_path=local_path,
|
|
101
|
+
ref=ref,
|
|
102
|
+
base_sha=base_sha,
|
|
103
|
+
head_sha=head_sha,
|
|
104
|
+
transport=transport,
|
|
105
|
+
session_id=session_id,
|
|
106
|
+
run_id=run_id,
|
|
107
|
+
)
|
|
108
|
+
if not path.exists():
|
|
109
|
+
await directories.create(resource)
|
|
110
|
+
if path.is_symlink():
|
|
111
|
+
raise SourceError("Managed repository was replaced by a symlink")
|
|
112
|
+
# Register before any fetch, including failed/interrupted preparations.
|
|
113
|
+
pin = path / "pins" / f"{session_id.hex}-{run_id.hex}.json"
|
|
114
|
+
|
|
115
|
+
def register() -> None:
|
|
116
|
+
pin.parent.mkdir(mode=0o700, exist_ok=True)
|
|
117
|
+
(path / "last-used").touch()
|
|
118
|
+
|
|
119
|
+
await settled_io(register)
|
|
120
|
+
await save_json(pin, {"base": base_sha, "head": head_sha})
|
|
121
|
+
learned: Literal["https", "ssh"] | None = None
|
|
122
|
+
try:
|
|
123
|
+
await GitSourceReader.open(path, base_sha=base_sha, head_sha=head_sha)
|
|
124
|
+
except SourceError as exc:
|
|
125
|
+
debug_event(logging.getLogger(__name__), "Cached source unavailable", reason=exc)
|
|
126
|
+
imported = False
|
|
127
|
+
if local_path is not None:
|
|
128
|
+
try:
|
|
129
|
+
await GitSourceReader.open(local_path, base_sha=base_sha, head_sha=head_sha)
|
|
130
|
+
except SourceError as exc:
|
|
131
|
+
debug_event(
|
|
132
|
+
logging.getLogger(__name__), "Local source unavailable", reason=exc
|
|
133
|
+
)
|
|
134
|
+
# Even without the PR head, a local commit can supply most blobs.
|
|
135
|
+
# Retain a ref so the remote fetch can negotiate from this commit.
|
|
136
|
+
await git_fetch_command(path, "init", "--bare", "--template=")
|
|
137
|
+
logging.getLogger(__name__).info(
|
|
138
|
+
"Seeding PR object cache from local commits"
|
|
139
|
+
)
|
|
140
|
+
for revision in (base_sha, "HEAD"):
|
|
141
|
+
try:
|
|
142
|
+
await git_fetch_command(
|
|
143
|
+
path,
|
|
144
|
+
"fetch",
|
|
145
|
+
*(("--unshallow",) if (path / "shallow").is_file() else ()),
|
|
146
|
+
"--no-tags",
|
|
147
|
+
"--no-recurse-submodules",
|
|
148
|
+
"--",
|
|
149
|
+
str(local_path.absolute()),
|
|
150
|
+
f"+{revision}:refs/diffbutler/local-seed",
|
|
151
|
+
local=True,
|
|
152
|
+
)
|
|
153
|
+
break
|
|
154
|
+
except SourceError as seed_error:
|
|
155
|
+
logging.getLogger(__name__).info(
|
|
156
|
+
"Local cache seed unavailable: %s", seed_error
|
|
157
|
+
)
|
|
158
|
+
else:
|
|
159
|
+
await git_fetch_command(path, "init", "--bare", "--template=")
|
|
160
|
+
await git_fetch_command(
|
|
161
|
+
path,
|
|
162
|
+
"fetch",
|
|
163
|
+
*(("--unshallow",) if (path / "shallow").is_file() else ()),
|
|
164
|
+
"--no-tags",
|
|
165
|
+
"--no-recurse-submodules",
|
|
166
|
+
"--",
|
|
167
|
+
str(local_path.absolute()),
|
|
168
|
+
"+refs/heads/*:refs/remotes/local/*",
|
|
169
|
+
base_sha,
|
|
170
|
+
head_sha,
|
|
171
|
+
local=True,
|
|
172
|
+
)
|
|
173
|
+
imported = True
|
|
174
|
+
if not imported:
|
|
175
|
+
learned = await fetch_repository(
|
|
176
|
+
path, ref, base_sha, head_sha, transport=transport
|
|
177
|
+
)
|
|
178
|
+
reader = await GitSourceReader.open(path, base_sha=base_sha, head_sha=head_sha)
|
|
179
|
+
await save_json(
|
|
180
|
+
pin,
|
|
181
|
+
{
|
|
182
|
+
"base": base_sha,
|
|
183
|
+
"head": head_sha,
|
|
184
|
+
"merge-base": reader.comparison_base_sha,
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
for side, sha in (
|
|
188
|
+
("base", base_sha),
|
|
189
|
+
("head", head_sha),
|
|
190
|
+
("merge-base", reader.comparison_base_sha),
|
|
191
|
+
):
|
|
192
|
+
await git_fetch_command(
|
|
193
|
+
path, "update-ref", f"refs/diffbutler/{session_id.hex}/{run_id.hex}/{side}", sha
|
|
194
|
+
)
|
|
195
|
+
return path, learned
|
|
196
|
+
|
|
197
|
+
async def release(self, session_id: UUID) -> None:
|
|
198
|
+
if not self.path.exists():
|
|
199
|
+
return
|
|
200
|
+
async with file_lock(self.lock_path):
|
|
201
|
+
directories = await open_directories(self.path)
|
|
202
|
+
for resource in await self._resources(directories):
|
|
203
|
+
path = self.path / resource.name
|
|
204
|
+
pins = await settled_io(
|
|
205
|
+
lambda path=path: list((path / "pins").glob(f"{session_id.hex}-*.json"))
|
|
206
|
+
)
|
|
207
|
+
for pin in pins:
|
|
208
|
+
run_id = UUID(pin.stem.split("-")[1])
|
|
209
|
+
if (path / "HEAD").exists():
|
|
210
|
+
for side in ("base", "head", "merge-base"):
|
|
211
|
+
await git_fetch_command(
|
|
212
|
+
path,
|
|
213
|
+
"update-ref",
|
|
214
|
+
"-d",
|
|
215
|
+
f"refs/diffbutler/{session_id.hex}/{run_id.hex}/{side}",
|
|
216
|
+
)
|
|
217
|
+
await settled_io(pin.unlink)
|
|
218
|
+
await settled_io(lambda path=path: (path / "last-used").touch())
|
|
219
|
+
|
|
220
|
+
async def prune(self) -> None:
|
|
221
|
+
"""Evict whole inactive repositories; never expire a live session's pins."""
|
|
222
|
+
if not self.path.exists():
|
|
223
|
+
return
|
|
224
|
+
async with file_lock(self.lock_path):
|
|
225
|
+
directories = await open_directories(self.path)
|
|
226
|
+
for resource in await self._resources(directories):
|
|
227
|
+
path = self.path / resource.name
|
|
228
|
+
|
|
229
|
+
def expired(path: Path = path) -> bool:
|
|
230
|
+
if any((path / "pins").glob("*.json")):
|
|
231
|
+
return False
|
|
232
|
+
marker = path / "last-used"
|
|
233
|
+
modified = marker.stat().st_mtime if marker.exists() else path.stat().st_mtime
|
|
234
|
+
return time.time() - modified >= self.retention_seconds
|
|
235
|
+
|
|
236
|
+
if await settled_io(expired):
|
|
237
|
+
await directories.delete(resource)
|
|
238
|
+
|
|
239
|
+
async def _resources(self, directories: OwnedDirectories) -> list[OwnedDirectory]:
|
|
240
|
+
def read() -> list[OwnedDirectory]:
|
|
241
|
+
result: list[OwnedDirectory] = []
|
|
242
|
+
for path in directories.path.iterdir():
|
|
243
|
+
if path.name in {".diffbutler-root", ".diffbutler-repositories"}:
|
|
244
|
+
continue
|
|
245
|
+
try:
|
|
246
|
+
left, right = path.name.split("-")
|
|
247
|
+
resource = OwnedDirectory(directories.root_id, UUID(left), UUID(right))
|
|
248
|
+
except ValueError:
|
|
249
|
+
raise ValueError("Unrecognized managed repository resource") from None
|
|
250
|
+
if (
|
|
251
|
+
resource.name != path.name
|
|
252
|
+
or resource.session_id != resource.resource_id
|
|
253
|
+
or path.is_symlink()
|
|
254
|
+
or not path.is_dir()
|
|
255
|
+
):
|
|
256
|
+
raise ValueError("Invalid managed repository resource")
|
|
257
|
+
result.append(resource)
|
|
258
|
+
return result
|
|
259
|
+
|
|
260
|
+
return await settled_io(read)
|
diffbutler/core/reset.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Explicit removal of local application directories without loading configuration."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from diffbutler.config.diagnostics import state_path
|
|
8
|
+
from diffbutler.config.settings import ConfigError, config_path, data_path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def reset_paths() -> tuple[Path, ...]:
|
|
12
|
+
return tuple(
|
|
13
|
+
dict.fromkeys(
|
|
14
|
+
(config_path().parent.absolute(), data_path().absolute(), state_path().absolute())
|
|
15
|
+
)
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def remove_directory(path: Path) -> None:
|
|
20
|
+
"""Remove the directory tree, unlinking rather than following symbolic links."""
|
|
21
|
+
try:
|
|
22
|
+
if path.is_symlink() or path.is_file():
|
|
23
|
+
path.unlink()
|
|
24
|
+
else:
|
|
25
|
+
shutil.rmtree(path)
|
|
26
|
+
except FileNotFoundError:
|
|
27
|
+
pass
|
|
28
|
+
except OSError:
|
|
29
|
+
raise ConfigError(f"reset: could not remove {path}; check permissions and retry") from None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def reset_directories(paths: tuple[Path, ...]) -> None:
|
|
33
|
+
for path in dict.fromkeys(paths):
|
|
34
|
+
await asyncio.to_thread(remove_directory, path)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Persist resource ownership before filesystem preparation, under a worker claim."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal, Protocol
|
|
8
|
+
from uuid import UUID, uuid4
|
|
9
|
+
|
|
10
|
+
from diffbutler.domain.resources import OwnedDirectory
|
|
11
|
+
from diffbutler.reviewers.resources import settled_io
|
|
12
|
+
from diffbutler.store.base import ReviewStore
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def save_json(path: Path, data: dict[str, str]) -> None:
|
|
16
|
+
"""Publish a complete ownership/source record before creating dependent resources."""
|
|
17
|
+
|
|
18
|
+
def write() -> None:
|
|
19
|
+
temporary = path.with_name(f".{path.name}-{uuid4().hex}.tmp")
|
|
20
|
+
with temporary.open("x") as stream:
|
|
21
|
+
json.dump(data, stream)
|
|
22
|
+
stream.flush()
|
|
23
|
+
os.fsync(stream.fileno())
|
|
24
|
+
temporary.replace(path)
|
|
25
|
+
fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
26
|
+
try:
|
|
27
|
+
os.fsync(fd)
|
|
28
|
+
finally:
|
|
29
|
+
os.close(fd)
|
|
30
|
+
|
|
31
|
+
await settled_io(write)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DirectoryProvisioner(Protocol):
|
|
35
|
+
def reserve(self, session_id: UUID) -> OwnedDirectory: ...
|
|
36
|
+
async def create(self, resource: OwnedDirectory) -> Path: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def provision_directory(
|
|
40
|
+
store: ReviewStore,
|
|
41
|
+
directories: DirectoryProvisioner,
|
|
42
|
+
*,
|
|
43
|
+
token: UUID,
|
|
44
|
+
session_id: UUID,
|
|
45
|
+
purpose: Literal["artifact", "codex_home", "claude_home"] = "artifact",
|
|
46
|
+
) -> Path:
|
|
47
|
+
"""Failures retain the manifest; ordinary completion can clean absent resources."""
|
|
48
|
+
resource = replace(directories.reserve(session_id), purpose=purpose)
|
|
49
|
+
await store.register_resource(token, resource)
|
|
50
|
+
return await directories.create(resource)
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Immutable public review records. Explicit starts and worker claims never launch a process."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import math
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import cast
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
|
|
11
|
+
|
|
12
|
+
from diffbutler.domain.diff import AnchorValidation, DiffAnchor, DiffIndex
|
|
13
|
+
from diffbutler.domain.review import (
|
|
14
|
+
AnalysisIdentity,
|
|
15
|
+
ComparisonChanged,
|
|
16
|
+
ComparisonCheck,
|
|
17
|
+
ReviewPolicy,
|
|
18
|
+
ReviewResultRecord,
|
|
19
|
+
RunClaim,
|
|
20
|
+
RunRecord,
|
|
21
|
+
RunStatus,
|
|
22
|
+
SessionStatus,
|
|
23
|
+
Side,
|
|
24
|
+
)
|
|
25
|
+
from diffbutler.reviewers.base import ReviewOutput
|
|
26
|
+
from diffbutler.reviewers.extraction import OutputFormat
|
|
27
|
+
from diffbutler.reviewers.parsing import (
|
|
28
|
+
DraftReview,
|
|
29
|
+
ResponseFormat,
|
|
30
|
+
ReviewDocument,
|
|
31
|
+
Severity,
|
|
32
|
+
parse_review,
|
|
33
|
+
)
|
|
34
|
+
from diffbutler.store.base import ReviewStore
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ReviewSession(BaseModel):
|
|
38
|
+
model_config = ConfigDict(frozen=True)
|
|
39
|
+
|
|
40
|
+
id: UUID
|
|
41
|
+
task_id: UUID
|
|
42
|
+
reviewer_id: str
|
|
43
|
+
generation: int = Field(ge=1)
|
|
44
|
+
display_name: str
|
|
45
|
+
backend_session_id: str | None
|
|
46
|
+
status: SessionStatus
|
|
47
|
+
created_at: datetime
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AIRun(BaseModel):
|
|
51
|
+
model_config = ConfigDict(frozen=True)
|
|
52
|
+
|
|
53
|
+
id: UUID
|
|
54
|
+
task_id: UUID
|
|
55
|
+
session_id: UUID
|
|
56
|
+
reviewer_id: str
|
|
57
|
+
head_sha: str
|
|
58
|
+
base_sha: str
|
|
59
|
+
prompt_version: str
|
|
60
|
+
config_hash: str
|
|
61
|
+
analysis_key: str
|
|
62
|
+
attempt: int = Field(ge=1)
|
|
63
|
+
status: RunStatus
|
|
64
|
+
requested_at: datetime
|
|
65
|
+
started_at: datetime | None
|
|
66
|
+
finished_at: datetime | None
|
|
67
|
+
status_reason: str | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_ANCHORS = TypeAdapter(tuple[AnchorValidation, ...])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ReviewResult(BaseModel):
|
|
74
|
+
model_config = ConfigDict(frozen=True, ser_json_bytes="base64", val_json_bytes="base64")
|
|
75
|
+
|
|
76
|
+
run_id: UUID
|
|
77
|
+
exit_code: int
|
|
78
|
+
stdout: bytes | None
|
|
79
|
+
stderr: bytes | None
|
|
80
|
+
document: ReviewDocument | None
|
|
81
|
+
markdown: str | None = None
|
|
82
|
+
parse_tier: str | None
|
|
83
|
+
error: str | None
|
|
84
|
+
notes: tuple[str, ...]
|
|
85
|
+
selected_indices: tuple[int, ...]
|
|
86
|
+
min_confidence: float
|
|
87
|
+
findings_threshold: Severity
|
|
88
|
+
anchors: tuple[AnchorValidation, ...] | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _store_call[T](operation: Awaitable[T]) -> T:
|
|
92
|
+
"""Settle short store operations before propagating cancellation.
|
|
93
|
+
|
|
94
|
+
Comparison fetches stay cancellable; only database operations are shielded.
|
|
95
|
+
"""
|
|
96
|
+
task = asyncio.ensure_future(operation)
|
|
97
|
+
cancelled = False
|
|
98
|
+
try:
|
|
99
|
+
while not task.done():
|
|
100
|
+
try:
|
|
101
|
+
await asyncio.shield(task)
|
|
102
|
+
except asyncio.CancelledError:
|
|
103
|
+
cancelled = True
|
|
104
|
+
return task.result()
|
|
105
|
+
finally:
|
|
106
|
+
if cancelled:
|
|
107
|
+
# Retrieve an exception before cancellation wins, avoiding orphan tasks.
|
|
108
|
+
if task.done() and not task.cancelled():
|
|
109
|
+
task.exception()
|
|
110
|
+
raise asyncio.CancelledError
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ReviewService:
|
|
114
|
+
"""Internal scheduling seam; callers map explicit UI actions to requests.
|
|
115
|
+
|
|
116
|
+
The native executor selects the installed agent CLI permission profile. Worker
|
|
117
|
+
claims are durable and never expire automatically: process death must be
|
|
118
|
+
established before releasing ownership or finalizing a captured result.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
store: ReviewStore,
|
|
124
|
+
policy: ReviewPolicy,
|
|
125
|
+
compare_base: Callable[[RunRecord, str], Awaitable[bool]] | None = None,
|
|
126
|
+
) -> None:
|
|
127
|
+
self.store = store
|
|
128
|
+
self.policy = policy
|
|
129
|
+
self.compare_base = compare_base
|
|
130
|
+
|
|
131
|
+
async def _comparison(self, token: UUID) -> ComparisonCheck:
|
|
132
|
+
run, base = await _store_call(self.store.comparison_snapshot(token))
|
|
133
|
+
matches = base == run.base_sha
|
|
134
|
+
if not matches and run.status == RunStatus.RUNNING and self.compare_base is not None:
|
|
135
|
+
matches = await self.compare_base(run, base)
|
|
136
|
+
return ComparisonCheck(base, matches)
|
|
137
|
+
|
|
138
|
+
async def get_run(self, run_id: UUID) -> AIRun:
|
|
139
|
+
return AIRun.model_validate(await self.store.get_run(run_id), from_attributes=True)
|
|
140
|
+
|
|
141
|
+
async def get_session(self, session_id: UUID) -> ReviewSession:
|
|
142
|
+
"""Read durable conversation metadata; does not resume or start a process."""
|
|
143
|
+
return ReviewSession.model_validate(
|
|
144
|
+
await self.store.get_session(session_id), from_attributes=True
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
async def bind_backend_session(self, token: UUID, backend_session_id: str) -> ReviewSession:
|
|
148
|
+
"""Checkpoint a backend identity while the caller still owns its worker claim."""
|
|
149
|
+
return ReviewSession.model_validate(
|
|
150
|
+
await self.store.bind_backend_session(token, backend_session_id), from_attributes=True
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
async def suspend_session(self, session_id: UUID) -> ReviewSession:
|
|
154
|
+
"""Mark an idle conversation suspended; never stop a process or delete data."""
|
|
155
|
+
return ReviewSession.model_validate(
|
|
156
|
+
await self.store.suspend_session(session_id), from_attributes=True
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
async def request_review(self, identity: AnalysisIdentity) -> AIRun:
|
|
160
|
+
return AIRun.model_validate(
|
|
161
|
+
await self.store.request_review(identity, self.policy), from_attributes=True
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
async def claim_review(
|
|
165
|
+
self, run_id: UUID, *, capacity: int = 2, paused: bool = False
|
|
166
|
+
) -> RunClaim | None:
|
|
167
|
+
if capacity < 1:
|
|
168
|
+
raise ValueError("capacity must be positive")
|
|
169
|
+
if paused:
|
|
170
|
+
return None
|
|
171
|
+
return await self.store.claim_review(run_id, capacity=capacity, policy=self.policy)
|
|
172
|
+
|
|
173
|
+
async def claim_is_current(self, token: UUID) -> bool:
|
|
174
|
+
while True:
|
|
175
|
+
comparison = await self._comparison(token)
|
|
176
|
+
try:
|
|
177
|
+
return await _store_call(
|
|
178
|
+
self.store.claim_is_current(token, self.policy, comparison)
|
|
179
|
+
)
|
|
180
|
+
except ComparisonChanged:
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
async def cancel_review(self, run_id: UUID) -> AIRun:
|
|
184
|
+
return AIRun.model_validate(await self.store.cancel_review(run_id), from_attributes=True)
|
|
185
|
+
|
|
186
|
+
async def release_review(
|
|
187
|
+
self, token: UUID, *, reason: str | None = None, output: ReviewOutput | None = None
|
|
188
|
+
) -> AIRun:
|
|
189
|
+
"""Acknowledge worker exit and retain safe diagnostics and available output."""
|
|
190
|
+
failure = (
|
|
191
|
+
None
|
|
192
|
+
if reason is None
|
|
193
|
+
else ReviewResultRecord(
|
|
194
|
+
output.exit_code if output else -1,
|
|
195
|
+
output.stdout if output else None,
|
|
196
|
+
output.stderr if output else None,
|
|
197
|
+
None,
|
|
198
|
+
None,
|
|
199
|
+
reason,
|
|
200
|
+
(),
|
|
201
|
+
(),
|
|
202
|
+
0.5,
|
|
203
|
+
"minor",
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
return AIRun.model_validate(
|
|
207
|
+
await self.store.release_review(token, failure), from_attributes=True
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
async def get_result(self, run_id: UUID) -> ReviewResult | None:
|
|
211
|
+
result = await self.store.get_result(run_id)
|
|
212
|
+
if result is None:
|
|
213
|
+
return None
|
|
214
|
+
return ReviewResult(
|
|
215
|
+
run_id=run_id,
|
|
216
|
+
exit_code=result.exit_code,
|
|
217
|
+
stdout=result.stdout,
|
|
218
|
+
stderr=result.stderr,
|
|
219
|
+
document=(
|
|
220
|
+
ReviewDocument.model_validate_json(result.document_json)
|
|
221
|
+
if result.document_json is not None and result.parse_tier != "markdown"
|
|
222
|
+
else None
|
|
223
|
+
),
|
|
224
|
+
markdown=(
|
|
225
|
+
DraftReview.model_validate_json(result.document_json).markdown
|
|
226
|
+
if result.document_json is not None and result.parse_tier == "markdown"
|
|
227
|
+
else None
|
|
228
|
+
),
|
|
229
|
+
parse_tier=result.parse_tier,
|
|
230
|
+
error=result.error,
|
|
231
|
+
notes=result.notes,
|
|
232
|
+
selected_indices=result.selected_indices,
|
|
233
|
+
min_confidence=result.min_confidence,
|
|
234
|
+
findings_threshold=cast(Severity, result.findings_threshold),
|
|
235
|
+
anchors=_ANCHORS.validate_json(result.anchors_json)
|
|
236
|
+
if result.anchors_json is not None
|
|
237
|
+
else None,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
async def finalize_review(
|
|
241
|
+
self,
|
|
242
|
+
token: UUID,
|
|
243
|
+
output: ReviewOutput,
|
|
244
|
+
*,
|
|
245
|
+
diff_index: DiffIndex | None = None,
|
|
246
|
+
output_format: OutputFormat | None = None,
|
|
247
|
+
response_format: ResponseFormat = "json",
|
|
248
|
+
min_confidence: float = 0.5,
|
|
249
|
+
findings_threshold: Severity = "minor",
|
|
250
|
+
) -> AIRun:
|
|
251
|
+
"""Acknowledge verified worker exit and finalize already-extracted text.
|
|
252
|
+
|
|
253
|
+
A future executor must establish process-tree death before calling this.
|
|
254
|
+
Supply the original pinned context index to retain anchor validation.
|
|
255
|
+
Missing indexes are explicitly unvalidated. No forge is contacted.
|
|
256
|
+
"""
|
|
257
|
+
ranks = {"question": 0, "nit": 1, "minor": 2, "important": 3, "blocking": 4}
|
|
258
|
+
if not math.isfinite(min_confidence) or not 0 <= min_confidence <= 1:
|
|
259
|
+
raise ValueError("minimum confidence must be finite and between zero and one")
|
|
260
|
+
if findings_threshold not in ranks:
|
|
261
|
+
raise ValueError("unknown findings threshold")
|
|
262
|
+
parsed = parse_review(
|
|
263
|
+
output,
|
|
264
|
+
output_format=output_format,
|
|
265
|
+
response_format=response_format,
|
|
266
|
+
)
|
|
267
|
+
selected = (
|
|
268
|
+
tuple(
|
|
269
|
+
index
|
|
270
|
+
for index, finding in enumerate(parsed.document.findings)
|
|
271
|
+
if finding.confidence >= min_confidence
|
|
272
|
+
and ranks[finding.severity] >= ranks[findings_threshold]
|
|
273
|
+
)
|
|
274
|
+
if parsed.document is not None
|
|
275
|
+
else ()
|
|
276
|
+
)
|
|
277
|
+
anchors = (
|
|
278
|
+
tuple(
|
|
279
|
+
diff_index.validate(
|
|
280
|
+
DiffAnchor(
|
|
281
|
+
path=finding.path,
|
|
282
|
+
line=finding.line,
|
|
283
|
+
start_line=finding.start_line,
|
|
284
|
+
side=Side.BASE if finding.side == "LEFT" else Side.HEAD,
|
|
285
|
+
base_sha=diff_index.base_sha,
|
|
286
|
+
head_sha=diff_index.head_sha,
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
for finding in parsed.document.findings
|
|
290
|
+
)
|
|
291
|
+
if parsed.document is not None and diff_index is not None
|
|
292
|
+
else None
|
|
293
|
+
)
|
|
294
|
+
result = ReviewResultRecord(
|
|
295
|
+
output.exit_code,
|
|
296
|
+
output.stdout,
|
|
297
|
+
output.stderr,
|
|
298
|
+
parsed.draft.model_dump_json()
|
|
299
|
+
if parsed.draft is not None
|
|
300
|
+
else parsed.document.model_dump_json()
|
|
301
|
+
if parsed.document is not None
|
|
302
|
+
else None,
|
|
303
|
+
parsed.tier,
|
|
304
|
+
parsed.error,
|
|
305
|
+
parsed.notes,
|
|
306
|
+
selected,
|
|
307
|
+
min_confidence,
|
|
308
|
+
findings_threshold,
|
|
309
|
+
_ANCHORS.dump_json(anchors).decode() if anchors is not None else None,
|
|
310
|
+
diff_index.base_sha if diff_index is not None else None,
|
|
311
|
+
diff_index.head_sha if diff_index is not None else None,
|
|
312
|
+
)
|
|
313
|
+
while True:
|
|
314
|
+
comparison = await self._comparison(token)
|
|
315
|
+
try:
|
|
316
|
+
return AIRun.model_validate(
|
|
317
|
+
await self.store.finalize_review(
|
|
318
|
+
token,
|
|
319
|
+
result,
|
|
320
|
+
backend_session_id=parsed.output.backend_session_id,
|
|
321
|
+
no_findings=(
|
|
322
|
+
parsed.draft.no_findings
|
|
323
|
+
if parsed.draft is not None
|
|
324
|
+
else parsed.document is not None and not parsed.document.findings
|
|
325
|
+
),
|
|
326
|
+
comparison=comparison,
|
|
327
|
+
),
|
|
328
|
+
from_attributes=True,
|
|
329
|
+
)
|
|
330
|
+
except ComparisonChanged:
|
|
331
|
+
continue
|