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,123 @@
|
|
|
1
|
+
"""Forge-neutral, immutable read results. No persistence or workflow side effects."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def identity_part(value: str) -> str:
|
|
10
|
+
if not re.fullmatch(r"[A-Za-z0-9_.-]+", value) or value in {".", ".."}:
|
|
11
|
+
raise ValueError("repository, user, and team names must be simple identity components")
|
|
12
|
+
return value
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Account:
|
|
17
|
+
forge: str
|
|
18
|
+
login: str
|
|
19
|
+
display_name: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class PRRef:
|
|
24
|
+
forge: str
|
|
25
|
+
namespace: str
|
|
26
|
+
repository: str
|
|
27
|
+
number: int
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
identity_part(self.namespace)
|
|
31
|
+
identity_part(self.repository)
|
|
32
|
+
if self.number < 1:
|
|
33
|
+
raise ValueError("PR number must be positive")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class PullRequest:
|
|
38
|
+
ref: PRRef
|
|
39
|
+
title: str
|
|
40
|
+
author: str
|
|
41
|
+
url: str
|
|
42
|
+
state: Literal["open", "closed", "merged"]
|
|
43
|
+
head_sha: str
|
|
44
|
+
base_sha: str
|
|
45
|
+
head_ref: str
|
|
46
|
+
base_ref: str
|
|
47
|
+
is_draft: bool
|
|
48
|
+
created_at: datetime
|
|
49
|
+
updated_at: datetime
|
|
50
|
+
requested_users: tuple[str, ...]
|
|
51
|
+
requested_teams: tuple[str, ...]
|
|
52
|
+
additions: int | None = None
|
|
53
|
+
deletions: int | None = None
|
|
54
|
+
files: int | None = None
|
|
55
|
+
labels: tuple[str, ...] = ()
|
|
56
|
+
ci: str = "unknown"
|
|
57
|
+
assignees: tuple[str, ...] = ()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class PRQuery:
|
|
62
|
+
repository: str | None = None
|
|
63
|
+
author: str | None = None
|
|
64
|
+
state: Literal["open", "closed", "merged", "all"] = "open"
|
|
65
|
+
text: str = ""
|
|
66
|
+
page_size: int = 30
|
|
67
|
+
|
|
68
|
+
def __post_init__(self) -> None:
|
|
69
|
+
if self.repository is not None:
|
|
70
|
+
parts = self.repository.split("/")
|
|
71
|
+
if len(parts) != 2:
|
|
72
|
+
raise ValueError("repository must be namespace/name")
|
|
73
|
+
for part in parts:
|
|
74
|
+
identity_part(part)
|
|
75
|
+
if self.author is not None:
|
|
76
|
+
identity_part(self.author)
|
|
77
|
+
if self.state not in {"open", "closed", "merged", "all"}:
|
|
78
|
+
raise ValueError("state must be open, closed, merged, or all")
|
|
79
|
+
if not 1 <= self.page_size <= 100:
|
|
80
|
+
raise ValueError("page_size must be between 1 and 100")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class PRPage:
|
|
85
|
+
items: tuple[PullRequest, ...]
|
|
86
|
+
next_cursor: str | None
|
|
87
|
+
complete: bool = True
|
|
88
|
+
warnings: tuple[str, ...] = ()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class RequestedPR:
|
|
93
|
+
pull_request: PullRequest
|
|
94
|
+
sources: tuple[str, ...]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class Discovery:
|
|
99
|
+
items: tuple[RequestedPR, ...]
|
|
100
|
+
complete: bool
|
|
101
|
+
warnings: tuple[str, ...] = ()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class TeamMembership:
|
|
106
|
+
teams: tuple[str, ...]
|
|
107
|
+
complete: bool
|
|
108
|
+
warnings: tuple[str, ...] = ()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class RateLimit:
|
|
113
|
+
resource: str
|
|
114
|
+
remaining: int
|
|
115
|
+
limit: int
|
|
116
|
+
reset_at: datetime
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class ForgeHealth:
|
|
121
|
+
account: Account
|
|
122
|
+
limits: tuple[RateLimit, ...]
|
|
123
|
+
teams: TeamMembership | None
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Immutable ownership and cleanup records, independent of processes and storage."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class ForegroundLease:
|
|
10
|
+
token: UUID
|
|
11
|
+
session_id: UUID
|
|
12
|
+
backend_session_id: str
|
|
13
|
+
head_sha: str
|
|
14
|
+
task_version: int
|
|
15
|
+
acquired_at: datetime
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class CleanupSession:
|
|
20
|
+
id: UUID
|
|
21
|
+
reviewer_id: str
|
|
22
|
+
backend_session_id: str | None
|
|
23
|
+
transcript_ref: str | None
|
|
24
|
+
resources: tuple[tuple[tuple[str, str], ...], ...]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class CleanupClaim:
|
|
29
|
+
token: UUID
|
|
30
|
+
job_id: UUID
|
|
31
|
+
sessions: tuple[CleanupSession, ...]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class HeldOperation:
|
|
36
|
+
token: UUID
|
|
37
|
+
kind: str
|
|
38
|
+
resource_id: UUID
|
|
39
|
+
acquired_at: datetime
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ForegroundExitUncertain(RuntimeError):
|
|
43
|
+
"""Ownership must stay held when child exit cannot be established."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class CleanupStatus:
|
|
48
|
+
succeeded: int = 0
|
|
49
|
+
pending: int = 0
|
|
50
|
+
running: int = 0
|
|
51
|
+
failed: int = 0
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Pure quota budgeting for read-only polling; timestamps are supplied by the caller."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from diffbutler.domain.forge import RateLimit
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class PollQuota:
|
|
10
|
+
host: str
|
|
11
|
+
login: str
|
|
12
|
+
quota: RateLimit
|
|
13
|
+
requests: int
|
|
14
|
+
observed_at: float
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def key(self) -> tuple[str, str, str]:
|
|
18
|
+
return self.host, self.login.casefold(), self.quota.resource
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class QuotaBudget:
|
|
22
|
+
"""One account/resource's conservative cost estimate and threshold hysteresis."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, minimum: float) -> None:
|
|
25
|
+
self.minimum = minimum
|
|
26
|
+
self.interval = minimum
|
|
27
|
+
self.sample: PollQuota | None = None
|
|
28
|
+
self.average_cost = 0.0
|
|
29
|
+
self.cost = 0.0
|
|
30
|
+
self.external_rate = 0.0
|
|
31
|
+
self.low = False
|
|
32
|
+
self.high_samples = 0
|
|
33
|
+
self.wait_for_reset = False
|
|
34
|
+
|
|
35
|
+
def update(self, sample: PollQuota, *, successful: bool, now: float) -> None:
|
|
36
|
+
previous = self.sample
|
|
37
|
+
quota = sample.quota
|
|
38
|
+
reset = quota.reset_at.timestamp()
|
|
39
|
+
if previous is not None and previous.quota.reset_at != quota.reset_at:
|
|
40
|
+
# Keep workload estimates, but never carry old-window depletion forward.
|
|
41
|
+
self.external_rate = 0
|
|
42
|
+
self.interval = self.minimum
|
|
43
|
+
self.low = False
|
|
44
|
+
self.high_samples = 0
|
|
45
|
+
elif previous is not None and sample.observed_at > previous.observed_at:
|
|
46
|
+
elapsed = sample.observed_at - previous.observed_at
|
|
47
|
+
external = max(0, previous.quota.remaining - quota.remaining - sample.requests)
|
|
48
|
+
self.external_rate = 0.3 * external / elapsed + 0.7 * self.external_rate
|
|
49
|
+
self.sample = sample
|
|
50
|
+
if not self.average_cost:
|
|
51
|
+
self.average_cost = max(1, sample.requests)
|
|
52
|
+
elif successful:
|
|
53
|
+
self.average_cost = 0.3 * sample.requests + 0.7 * self.average_cost
|
|
54
|
+
else:
|
|
55
|
+
# An incomplete refresh is not evidence that the queue became cheaper.
|
|
56
|
+
self.average_cost = max(self.average_cost, sample.requests)
|
|
57
|
+
estimated_cost = 1.25 * max(1, sample.requests, self.average_cost)
|
|
58
|
+
self.cost = estimated_cost if successful else max(self.cost, estimated_cost)
|
|
59
|
+
|
|
60
|
+
fraction = quota.remaining / quota.limit
|
|
61
|
+
if fraction < 0.2:
|
|
62
|
+
if not self.low:
|
|
63
|
+
self.interval *= 1.5
|
|
64
|
+
self.high_samples = 0
|
|
65
|
+
elif fraction > 0.8 and successful:
|
|
66
|
+
self.high_samples += 1
|
|
67
|
+
if self.high_samples >= 2:
|
|
68
|
+
self.interval *= 0.8
|
|
69
|
+
self.high_samples = 0
|
|
70
|
+
else:
|
|
71
|
+
self.high_samples = 0
|
|
72
|
+
self.low = fraction < 0.2
|
|
73
|
+
|
|
74
|
+
remaining_time = max(0, reset - now)
|
|
75
|
+
available = (
|
|
76
|
+
quota.remaining
|
|
77
|
+
- 0.1 * quota.limit
|
|
78
|
+
- self.external_rate * max(0, reset - sample.observed_at)
|
|
79
|
+
)
|
|
80
|
+
self.wait_for_reset = available < self.cost and remaining_time > 0
|
|
81
|
+
budget_interval = self.cost * remaining_time / available if available > 0 else 0
|
|
82
|
+
self.interval = max(self.minimum, self.interval, budget_interval)
|
|
83
|
+
|
|
84
|
+
def next_start(self, started: float, now: float) -> float:
|
|
85
|
+
assert self.sample is not None
|
|
86
|
+
reset = self.sample.quota.reset_at.timestamp() + 1
|
|
87
|
+
if now >= reset:
|
|
88
|
+
# Wake to get fresh headers, even if the previous interval was very long.
|
|
89
|
+
return started + self.minimum
|
|
90
|
+
if self.wait_for_reset:
|
|
91
|
+
return reset
|
|
92
|
+
return min(started + self.interval, reset)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Durable launch intent; a launcher PID alone never proves process-tree exit."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
OperationKind = Literal["background", "foreground", "cleanup"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class HostBoot:
|
|
12
|
+
host_id: UUID
|
|
13
|
+
boot_id: UUID
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ProcessLaunch:
|
|
18
|
+
token: UUID
|
|
19
|
+
kind: OperationKind
|
|
20
|
+
origin: HostBoot
|
|
21
|
+
launcher_pid: int | None = None
|
|
22
|
+
|
|
23
|
+
def __post_init__(self) -> None:
|
|
24
|
+
if self.launcher_pid is not None and self.launcher_pid < 1:
|
|
25
|
+
raise ValueError("Launcher PID must be positive")
|
|
26
|
+
|
|
27
|
+
def ended_by_reboot(self, current: HostBoot) -> bool:
|
|
28
|
+
# A different machine may still have the original process running.
|
|
29
|
+
return self.origin.host_id == current.host_id and self.origin.boot_id != current.boot_id
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Conservative credential heuristics, not a guarantee that source has no secrets."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
# Prefix matches are masked even for low-entropy example credentials. Assignment
|
|
9
|
+
# matches require entropy to avoid hiding ordinary configuration and prose.
|
|
10
|
+
_PREFIXED = re.compile(
|
|
11
|
+
rb"(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}"
|
|
12
|
+
rb"|(?:AKIA|ASIA)[A-Z0-9]{16}|sk-[A-Za-z0-9_-]{20,}"
|
|
13
|
+
rb"|xox[baprs]-[A-Za-z0-9-]{20,})"
|
|
14
|
+
)
|
|
15
|
+
_ASSIGNMENT = re.compile(
|
|
16
|
+
rb"(?i)(?<![A-Za-z0-9_])"
|
|
17
|
+
rb"[A-Za-z0-9_]*(?:token|password|passwd|secret|api[_-]?key|access[_-]?key)"
|
|
18
|
+
rb"[A-Za-z0-9_]*[\"']?[ \t]*[:=][ \t]*[\"']?"
|
|
19
|
+
rb"(?P<value>[A-Za-z0-9_./+=~-]{16,})"
|
|
20
|
+
)
|
|
21
|
+
_PRIVATE_KEY = re.compile(
|
|
22
|
+
rb"-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----"
|
|
23
|
+
rb"[\s\S]*?-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class RedactedSource:
|
|
29
|
+
content: bytes
|
|
30
|
+
count: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def redact_source(content: bytes) -> RedactedSource:
|
|
34
|
+
"""Mask matching spans without changing byte offsets or CR/LF positions.
|
|
35
|
+
|
|
36
|
+
Counts describe merged occurrences, not unique credentials. No original value,
|
|
37
|
+
hash, or source location is included in diagnostics. Works on arbitrary bytes.
|
|
38
|
+
"""
|
|
39
|
+
spans = [
|
|
40
|
+
match.span() for pattern in (_PREFIXED, _PRIVATE_KEY) for match in pattern.finditer(content)
|
|
41
|
+
]
|
|
42
|
+
for match in _ASSIGNMENT.finditer(content):
|
|
43
|
+
value = match.group("value")
|
|
44
|
+
entropy = -sum(
|
|
45
|
+
(count / len(value)) * math.log2(count / len(value))
|
|
46
|
+
for count in Counter(value).values()
|
|
47
|
+
)
|
|
48
|
+
if entropy >= 3.5:
|
|
49
|
+
spans.append(match.span("value"))
|
|
50
|
+
merged: list[tuple[int, int]] = []
|
|
51
|
+
for start, end in sorted(spans):
|
|
52
|
+
if merged and start < merged[-1][1]:
|
|
53
|
+
merged[-1] = (merged[-1][0], max(end, merged[-1][1]))
|
|
54
|
+
else:
|
|
55
|
+
merged.append((start, end))
|
|
56
|
+
result = bytearray(content)
|
|
57
|
+
for start, end in merged:
|
|
58
|
+
for index in range(start, end):
|
|
59
|
+
if result[index] not in (10, 13):
|
|
60
|
+
result[index] = ord("*")
|
|
61
|
+
return RedactedSource(bytes(result), len(merged))
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Manifest identities are independent of filesystem paths and backend IDs."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal, cast
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class OwnedDirectory:
|
|
10
|
+
root_id: UUID
|
|
11
|
+
session_id: UUID
|
|
12
|
+
resource_id: UUID
|
|
13
|
+
purpose: Literal["artifact", "codex_home", "claude_home"] = "artifact"
|
|
14
|
+
|
|
15
|
+
def manifest(self) -> dict[str, str]:
|
|
16
|
+
return {
|
|
17
|
+
"kind": "owned_directory_v1",
|
|
18
|
+
"root_id": str(self.root_id),
|
|
19
|
+
"session_id": str(self.session_id),
|
|
20
|
+
"resource_id": str(self.resource_id),
|
|
21
|
+
"purpose": self.purpose,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_manifest(cls, value: dict[str, str]) -> OwnedDirectory:
|
|
26
|
+
if (
|
|
27
|
+
set(value)
|
|
28
|
+
not in (
|
|
29
|
+
{"kind", "root_id", "session_id", "resource_id"},
|
|
30
|
+
{"kind", "root_id", "session_id", "resource_id", "purpose"},
|
|
31
|
+
)
|
|
32
|
+
or value.get("kind") != "owned_directory_v1"
|
|
33
|
+
):
|
|
34
|
+
raise ValueError("Unsupported resource manifest")
|
|
35
|
+
purpose = value.get("purpose", "artifact")
|
|
36
|
+
if purpose not in {"artifact", "codex_home", "claude_home"}:
|
|
37
|
+
raise ValueError("Unsupported resource purpose")
|
|
38
|
+
try:
|
|
39
|
+
return cls(
|
|
40
|
+
UUID(value["root_id"]),
|
|
41
|
+
UUID(value["session_id"]),
|
|
42
|
+
UUID(value["resource_id"]),
|
|
43
|
+
cast(Literal["artifact", "codex_home", "claude_home"], purpose),
|
|
44
|
+
)
|
|
45
|
+
except ValueError:
|
|
46
|
+
raise ValueError("Invalid resource identity") from None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def name(self) -> str:
|
|
50
|
+
return f"{self.session_id.hex}-{self.resource_id.hex}"
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Immutable identities and source descriptions for background analysis; no I/O."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from pathlib import PurePosixPath
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RunStatus(StrEnum):
|
|
13
|
+
QUEUED = "queued"
|
|
14
|
+
RUNNING = "running"
|
|
15
|
+
SUCCEEDED = "succeeded"
|
|
16
|
+
FAILED = "failed"
|
|
17
|
+
CANCELLED = "cancelled"
|
|
18
|
+
STALE = "stale"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SessionStatus(StrEnum):
|
|
22
|
+
READY = "ready"
|
|
23
|
+
SUSPENDED = "suspended"
|
|
24
|
+
CLEANED = "cleaned"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class SessionRecord:
|
|
29
|
+
id: UUID
|
|
30
|
+
task_id: UUID
|
|
31
|
+
reviewer_id: str
|
|
32
|
+
generation: int
|
|
33
|
+
display_name: str
|
|
34
|
+
backend_session_id: str | None
|
|
35
|
+
status: SessionStatus
|
|
36
|
+
created_at: datetime
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Side(StrEnum):
|
|
40
|
+
BASE = "base"
|
|
41
|
+
HEAD = "head"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class SourceFile:
|
|
46
|
+
"""A repository-relative file at one of the reviewed revisions."""
|
|
47
|
+
|
|
48
|
+
path: str
|
|
49
|
+
side: Side
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
path = PurePosixPath(self.path)
|
|
53
|
+
if (
|
|
54
|
+
not self.path
|
|
55
|
+
or path.is_absolute()
|
|
56
|
+
or any(part in {"", ".", ".."} for part in self.path.split("/"))
|
|
57
|
+
or "\\" in self.path
|
|
58
|
+
or "\x00" in self.path
|
|
59
|
+
):
|
|
60
|
+
raise ValueError("source path must be a normalized relative repository path")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class ChangedFile:
|
|
65
|
+
path: str
|
|
66
|
+
old_path: str | None = None
|
|
67
|
+
|
|
68
|
+
def __post_init__(self) -> None:
|
|
69
|
+
SourceFile(self.path, Side.HEAD)
|
|
70
|
+
if self.old_path is not None:
|
|
71
|
+
SourceFile(self.old_path, Side.BASE)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class AnalysisIdentity:
|
|
76
|
+
"""Task identity includes the account and cycle; retries share a key."""
|
|
77
|
+
|
|
78
|
+
task_id: UUID
|
|
79
|
+
head_sha: str
|
|
80
|
+
reviewer_id: str
|
|
81
|
+
prompt_version: str
|
|
82
|
+
config_hash: str
|
|
83
|
+
|
|
84
|
+
def __post_init__(self) -> None:
|
|
85
|
+
if not all((self.head_sha, self.reviewer_id, self.prompt_version, self.config_hash)):
|
|
86
|
+
raise ValueError("analysis identity components must be nonempty")
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def key(self) -> str:
|
|
90
|
+
payload = json.dumps(
|
|
91
|
+
[
|
|
92
|
+
str(self.task_id),
|
|
93
|
+
self.head_sha,
|
|
94
|
+
self.reviewer_id,
|
|
95
|
+
self.prompt_version,
|
|
96
|
+
self.config_hash,
|
|
97
|
+
],
|
|
98
|
+
separators=(",", ":"),
|
|
99
|
+
ensure_ascii=True,
|
|
100
|
+
)
|
|
101
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class ComparisonCheck:
|
|
106
|
+
base_sha: str
|
|
107
|
+
matches: bool
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ComparisonChanged(Exception):
|
|
111
|
+
"""The target moved while its comparison was being checked; recheck it."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ReviewConflict(ValueError):
|
|
115
|
+
"""The requested task or run no longer permits this action."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class RunRecord:
|
|
120
|
+
id: UUID
|
|
121
|
+
task_id: UUID
|
|
122
|
+
session_id: UUID
|
|
123
|
+
reviewer_id: str
|
|
124
|
+
head_sha: str
|
|
125
|
+
base_sha: str
|
|
126
|
+
prompt_version: str
|
|
127
|
+
config_hash: str
|
|
128
|
+
analysis_key: str
|
|
129
|
+
attempt: int
|
|
130
|
+
status: RunStatus
|
|
131
|
+
requested_at: datetime
|
|
132
|
+
started_at: datetime | None
|
|
133
|
+
finished_at: datetime | None
|
|
134
|
+
status_reason: str | None = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class RunClaim:
|
|
139
|
+
"""Exclusive worker ownership, retained until the worker acknowledges exit."""
|
|
140
|
+
|
|
141
|
+
token: UUID
|
|
142
|
+
run: RunRecord
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class ReviewPolicy:
|
|
147
|
+
reviewer_ids: frozenset[str]
|
|
148
|
+
disabled_repositories: frozenset[str] = frozenset()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class ReviewResultRecord:
|
|
153
|
+
"""Captured result and retained analysis; no adapter or ORM objects cross stores."""
|
|
154
|
+
|
|
155
|
+
exit_code: int
|
|
156
|
+
stdout: bytes | None
|
|
157
|
+
stderr: bytes | None
|
|
158
|
+
document_json: str | None
|
|
159
|
+
parse_tier: str | None
|
|
160
|
+
error: str | None
|
|
161
|
+
notes: tuple[str, ...]
|
|
162
|
+
selected_indices: tuple[int, ...]
|
|
163
|
+
min_confidence: float
|
|
164
|
+
findings_threshold: str
|
|
165
|
+
anchors_json: str | None = None
|
|
166
|
+
anchor_base_sha: str | None = None
|
|
167
|
+
anchor_head_sha: str | None = None
|