diffbutler 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. diffbutler/__init__.py +1 -0
  2. diffbutler/__main__.py +6 -0
  3. diffbutler/config/__init__.py +1 -0
  4. diffbutler/config/default.toml +91 -0
  5. diffbutler/config/diagnostics.py +90 -0
  6. diffbutler/config/file.py +30 -0
  7. diffbutler/config/instance.py +55 -0
  8. diffbutler/config/settings.py +297 -0
  9. diffbutler/config/setup.py +92 -0
  10. diffbutler/config/transport.py +65 -0
  11. diffbutler/core/__init__.py +1 -0
  12. diffbutler/core/application.py +103 -0
  13. diffbutler/core/context.py +54 -0
  14. diffbutler/core/dashboard.py +393 -0
  15. diffbutler/core/doctor.py +200 -0
  16. diffbutler/core/lifecycle.py +70 -0
  17. diffbutler/core/managed.py +441 -0
  18. diffbutler/core/native.py +301 -0
  19. diffbutler/core/polling.py +118 -0
  20. diffbutler/core/process.py +64 -0
  21. diffbutler/core/repositories.py +260 -0
  22. diffbutler/core/reset.py +34 -0
  23. diffbutler/core/resources.py +50 -0
  24. diffbutler/core/review.py +331 -0
  25. diffbutler/core/setup.py +232 -0
  26. diffbutler/core/snapshot.py +70 -0
  27. diffbutler/core/sync.py +259 -0
  28. diffbutler/core/worker.py +205 -0
  29. diffbutler/core/workspaces.py +139 -0
  30. diffbutler/domain/__init__.py +1 -0
  31. diffbutler/domain/dashboard.py +63 -0
  32. diffbutler/domain/diff.py +285 -0
  33. diffbutler/domain/forge.py +123 -0
  34. diffbutler/domain/lifecycle.py +51 -0
  35. diffbutler/domain/polling.py +92 -0
  36. diffbutler/domain/process.py +29 -0
  37. diffbutler/domain/redaction.py +61 -0
  38. diffbutler/domain/resources.py +50 -0
  39. diffbutler/domain/review.py +167 -0
  40. diffbutler/domain/sync.py +196 -0
  41. diffbutler/forges/__init__.py +1 -0
  42. diffbutler/forges/base.py +39 -0
  43. diffbutler/forges/diagnostics.py +104 -0
  44. diffbutler/forges/github/__init__.py +1 -0
  45. diffbutler/forges/github/adapter.py +352 -0
  46. diffbutler/forges/github/auth.py +49 -0
  47. diffbutler/forges/github/http.py +326 -0
  48. diffbutler/forges/github/mapping.py +157 -0
  49. diffbutler/forges/github/setup.py +86 -0
  50. diffbutler/forges/registry.py +23 -0
  51. diffbutler/py.typed +0 -0
  52. diffbutler/reviewers/__init__.py +1 -0
  53. diffbutler/reviewers/base.py +101 -0
  54. diffbutler/reviewers/extraction.py +198 -0
  55. diffbutler/reviewers/native.py +238 -0
  56. diffbutler/reviewers/parsing.py +247 -0
  57. diffbutler/reviewers/presets.toml +12 -0
  58. diffbutler/reviewers/process.py +78 -0
  59. diffbutler/reviewers/prompt.py +148 -0
  60. diffbutler/reviewers/redaction.py +30 -0
  61. diffbutler/reviewers/resources.py +162 -0
  62. diffbutler/sources/__init__.py +1 -0
  63. diffbutler/sources/diagnostics.py +100 -0
  64. diffbutler/sources/fetch.py +285 -0
  65. diffbutler/sources/git.py +461 -0
  66. diffbutler/sources/remotes.py +82 -0
  67. diffbutler/store/__init__.py +1 -0
  68. diffbutler/store/base.py +88 -0
  69. diffbutler/store/dashboard.py +201 -0
  70. diffbutler/store/database.py +172 -0
  71. diffbutler/store/lifecycle.py +247 -0
  72. diffbutler/store/migrations/__init__.py +1 -0
  73. diffbutler/store/migrations/env.py +14 -0
  74. diffbutler/store/migrations/versions/0001_initial.py +147 -0
  75. diffbutler/store/migrations/versions/0002_monitoring.py +38 -0
  76. diffbutler/store/migrations/versions/0003_reviews.py +72 -0
  77. diffbutler/store/migrations/versions/0004_claims.py +22 -0
  78. diffbutler/store/migrations/versions/0005_results.py +27 -0
  79. diffbutler/store/migrations/versions/0006_anchors.py +17 -0
  80. diffbutler/store/migrations/versions/0007_lifecycle.py +36 -0
  81. diffbutler/store/migrations/versions/0008_processes.py +30 -0
  82. diffbutler/store/migrations/versions/0009_dashboard.py +43 -0
  83. diffbutler/store/migrations/versions/0010_run_reasons.py +17 -0
  84. diffbutler/store/migrations/versions/__init__.py +1 -0
  85. diffbutler/store/models.py +290 -0
  86. diffbutler/store/process.py +75 -0
  87. diffbutler/store/queue.py +289 -0
  88. diffbutler/store/review.py +583 -0
  89. diffbutler/ui/__init__.py +1 -0
  90. diffbutler/ui/cli/__init__.py +532 -0
  91. diffbutler/ui/cli/editor.py +33 -0
  92. diffbutler/ui/tui/__init__.py +1 -0
  93. diffbutler/ui/tui/_fake_agent.py +47 -0
  94. diffbutler/ui/tui/_foreground_exec.py +23 -0
  95. diffbutler/ui/tui/app.py +34 -0
  96. diffbutler/ui/tui/browse.py +119 -0
  97. diffbutler/ui/tui/browser.py +42 -0
  98. diffbutler/ui/tui/dashboard.py +926 -0
  99. diffbutler/ui/tui/foreground.py +103 -0
  100. diffbutler/ui/tui/handoff_probe.py +184 -0
  101. diffbutler-0.5.0.dist-info/METADATA +170 -0
  102. diffbutler-0.5.0.dist-info/RECORD +105 -0
  103. diffbutler-0.5.0.dist-info/WHEEL +4 -0
  104. diffbutler-0.5.0.dist-info/entry_points.txt +3 -0
  105. diffbutler-0.5.0.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,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