graphite-code 0.3.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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1263 @@
|
|
|
1
|
+
"""Approval-gated orchestration for authenticated development CLIs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import shutil
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Protocol
|
|
15
|
+
|
|
16
|
+
from graphite.config import Config
|
|
17
|
+
from graphite.freshness import check_graph_freshness
|
|
18
|
+
from graphite.git import GitError, GitRunner
|
|
19
|
+
from graphite.graph_io import GraphReadError, load_validated_graph_bundle
|
|
20
|
+
|
|
21
|
+
from .approval import ApprovalAuthority, ApprovalError
|
|
22
|
+
from .classifier import classify_task
|
|
23
|
+
from .claude_executor import AdapterError, execute_claude, preflight_claude
|
|
24
|
+
from .codex_executor import execute_codex, preflight_codex
|
|
25
|
+
from .context_builder import ContextBundle, build_routing_context
|
|
26
|
+
from .contracts import (
|
|
27
|
+
CapabilitySnapshot,
|
|
28
|
+
CliApprovalManifest,
|
|
29
|
+
CliIdentity,
|
|
30
|
+
Effort,
|
|
31
|
+
ExecutionOutcome,
|
|
32
|
+
ExecutionReceipt,
|
|
33
|
+
PermissionMode,
|
|
34
|
+
ProviderId,
|
|
35
|
+
RiskTier,
|
|
36
|
+
TaskRequest,
|
|
37
|
+
)
|
|
38
|
+
from .diff_policy import DiffPolicyError, collect_diff_evidence
|
|
39
|
+
from .policy import (
|
|
40
|
+
CliCandidateMetrics,
|
|
41
|
+
CliPolicyGates,
|
|
42
|
+
rank_cli_candidates,
|
|
43
|
+
)
|
|
44
|
+
from .process_runner import CliProcessError, run_cli_process
|
|
45
|
+
from .profiles import load_verified_capability_snapshots
|
|
46
|
+
from .lifecycle import ProviderRuntimeIdentity
|
|
47
|
+
from .lifecycle_service import LifecycleServiceError, ProviderLifecycleService
|
|
48
|
+
from .prompt import CanonicalPrompt, canonical_cli_prompt
|
|
49
|
+
from .settings import RoutingSettings
|
|
50
|
+
from .storage import (
|
|
51
|
+
DEFAULT_RECOVERY_PAGE_SIZE,
|
|
52
|
+
RecoverableAttemptPage,
|
|
53
|
+
RepositoryStore,
|
|
54
|
+
StorageError,
|
|
55
|
+
)
|
|
56
|
+
from .worktree import (
|
|
57
|
+
TaskWorktree,
|
|
58
|
+
WorktreeError,
|
|
59
|
+
cleanup_task_worktree,
|
|
60
|
+
create_task_worktree,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RoutingServiceError(RuntimeError):
|
|
65
|
+
"""Stable orchestration failure with no repository or provider diagnostics.
|
|
66
|
+
|
|
67
|
+
`cause` is a diagnostic carried in the MESSAGE only, and only ever an
|
|
68
|
+
exception class name -- never a message, path or argv. This is the error
|
|
69
|
+
that reaches a CI log, so a detail that does not survive to here does not
|
|
70
|
+
exist as far as anyone debugging is concerned.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, code: str, cause: str | None = None) -> None:
|
|
74
|
+
self.code = code
|
|
75
|
+
self.cause = cause
|
|
76
|
+
super().__init__(f"{code} ({cause})" if cause else code)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AdapterResult(Protocol):
|
|
80
|
+
effective_model: str
|
|
81
|
+
message: str
|
|
82
|
+
input_tokens: int | None
|
|
83
|
+
output_tokens: int | None
|
|
84
|
+
duration_seconds: float
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
IdentityLoader = Callable[[ProviderId], CliIdentity]
|
|
88
|
+
RuntimeIdentityLoader = Callable[[ProviderId], ProviderRuntimeIdentity]
|
|
89
|
+
Executor = Callable[..., AdapterResult]
|
|
90
|
+
Validator = Callable[[TaskWorktree], bool]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True, slots=True)
|
|
94
|
+
class RoutingRecommendation:
|
|
95
|
+
task_id: str
|
|
96
|
+
provider: ProviderId | None
|
|
97
|
+
requested_model: str | None
|
|
98
|
+
effective_model: str | None
|
|
99
|
+
effort: Effort | None
|
|
100
|
+
snapshot_expires_at: int | None
|
|
101
|
+
permission_mode: PermissionMode | None
|
|
102
|
+
risk: str
|
|
103
|
+
estimated_tokens: int
|
|
104
|
+
outbound_manifest: dict[str, Any]
|
|
105
|
+
reasons: tuple[str, ...]
|
|
106
|
+
manual_handoff: bool
|
|
107
|
+
policy_version: str
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def model_id(self) -> str | None:
|
|
111
|
+
"""Compatibility alias for callers transitioning from legacy routing."""
|
|
112
|
+
return self.requested_model
|
|
113
|
+
|
|
114
|
+
def to_dict(self) -> dict[str, Any]:
|
|
115
|
+
return {
|
|
116
|
+
"task_id": self.task_id,
|
|
117
|
+
"provider": None if self.provider is None else self.provider.value,
|
|
118
|
+
"requested_model": self.requested_model,
|
|
119
|
+
"effective_model": self.effective_model,
|
|
120
|
+
"effort": None if self.effort is None else self.effort.value,
|
|
121
|
+
"snapshot_expires_at": self.snapshot_expires_at,
|
|
122
|
+
"permission_mode": (
|
|
123
|
+
None if self.permission_mode is None else self.permission_mode.value
|
|
124
|
+
),
|
|
125
|
+
"risk": self.risk,
|
|
126
|
+
"estimated_tokens": self.estimated_tokens,
|
|
127
|
+
"outbound_manifest": self.outbound_manifest,
|
|
128
|
+
"reasons": list(self.reasons),
|
|
129
|
+
"manual_handoff": self.manual_handoff,
|
|
130
|
+
"policy_version": self.policy_version,
|
|
131
|
+
"execution_authority": "single_use_approval_required",
|
|
132
|
+
"automatic_retry": False,
|
|
133
|
+
"automatic_fallback": False,
|
|
134
|
+
"automatic_merge": False,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True, slots=True)
|
|
139
|
+
class PreparedExecution:
|
|
140
|
+
task_id: str
|
|
141
|
+
decision_id: str
|
|
142
|
+
attempt_id: str
|
|
143
|
+
worktree: TaskWorktree
|
|
144
|
+
manifest: CliApprovalManifest
|
|
145
|
+
prompt: CanonicalPrompt = field(repr=False, compare=False)
|
|
146
|
+
graph_fingerprint: str
|
|
147
|
+
lifecycle_identity_digest: str | None = None
|
|
148
|
+
|
|
149
|
+
def to_dict(self) -> dict[str, Any]:
|
|
150
|
+
return {
|
|
151
|
+
"task_id": self.task_id,
|
|
152
|
+
"decision_id": self.decision_id,
|
|
153
|
+
"attempt_id": self.attempt_id,
|
|
154
|
+
"worktree_id": self.worktree.worktree_id,
|
|
155
|
+
"repository_commit": self.worktree.baseline_commit,
|
|
156
|
+
"provider": self.manifest.provider.value,
|
|
157
|
+
"requested_model": self.manifest.requested_model,
|
|
158
|
+
"effective_model": self.manifest.effective_model,
|
|
159
|
+
"effort": self.manifest.effort.value,
|
|
160
|
+
"permission_mode": self.manifest.permission_mode.value,
|
|
161
|
+
"prompt_hash": self.prompt.prompt_hash,
|
|
162
|
+
"approval_required": True,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True, slots=True)
|
|
167
|
+
class ApprovedExecution:
|
|
168
|
+
"""Ephemeral provider message plus persistence-safe evidence."""
|
|
169
|
+
|
|
170
|
+
text: str = field(repr=False, compare=False)
|
|
171
|
+
receipt: ExecutionReceipt
|
|
172
|
+
diff_hash: str
|
|
173
|
+
|
|
174
|
+
def to_public_dict(self) -> dict[str, Any]:
|
|
175
|
+
payload = dict(self.receipt.to_dict())
|
|
176
|
+
payload["diff_hash"] = self.diff_hash
|
|
177
|
+
return payload
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass(frozen=True, slots=True)
|
|
181
|
+
class _RecommendationState:
|
|
182
|
+
request: TaskRequest
|
|
183
|
+
task: Any
|
|
184
|
+
context: ContextBundle
|
|
185
|
+
graph_fingerprint: str
|
|
186
|
+
snapshot: CapabilitySnapshot
|
|
187
|
+
recommendation: RoutingRecommendation
|
|
188
|
+
lifecycle_identity_digest: str | None = None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _machine_state_dir() -> Path:
|
|
192
|
+
if os.name == "nt":
|
|
193
|
+
base = os.environ.get("LOCALAPPDATA")
|
|
194
|
+
return (
|
|
195
|
+
Path(base) if base else Path.home() / "AppData" / "Local"
|
|
196
|
+
) / "Graphite" / "routing"
|
|
197
|
+
xdg = os.environ.get("XDG_STATE_HOME")
|
|
198
|
+
return (
|
|
199
|
+
Path(xdg) if xdg else Path.home() / ".local" / "state"
|
|
200
|
+
) / "graphite" / "routing"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _default_executable(provider: ProviderId) -> Path:
|
|
204
|
+
name = "claude" if provider is ProviderId.CLAUDE_CODE else "codex"
|
|
205
|
+
selected = shutil.which(name)
|
|
206
|
+
if not selected:
|
|
207
|
+
raise RoutingServiceError("cli_missing")
|
|
208
|
+
try:
|
|
209
|
+
return Path(selected).resolve(strict=True)
|
|
210
|
+
except OSError:
|
|
211
|
+
raise RoutingServiceError("cli_missing") from None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _default_credential_home(provider: ProviderId) -> Path:
|
|
215
|
+
environment_name = (
|
|
216
|
+
"CLAUDE_CONFIG_DIR" if provider is ProviderId.CLAUDE_CODE else "CODEX_HOME"
|
|
217
|
+
)
|
|
218
|
+
configured = os.environ.get(environment_name)
|
|
219
|
+
candidate = Path(configured) if configured else Path.home() / (
|
|
220
|
+
".claude" if provider is ProviderId.CLAUDE_CODE else ".codex"
|
|
221
|
+
)
|
|
222
|
+
try:
|
|
223
|
+
return candidate.resolve(strict=True)
|
|
224
|
+
except OSError:
|
|
225
|
+
raise RoutingServiceError("credential_home_missing") from None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class RoutingService:
|
|
229
|
+
"""Coordinates recommendation, preparation, one-shot execution, and review gates."""
|
|
230
|
+
|
|
231
|
+
def __init__(
|
|
232
|
+
self,
|
|
233
|
+
path: str | Path,
|
|
234
|
+
*,
|
|
235
|
+
state_dir: Path | None = None,
|
|
236
|
+
settings: RoutingSettings | None = None,
|
|
237
|
+
identity_loader: IdentityLoader | None = None,
|
|
238
|
+
executables: Mapping[ProviderId, Path] | None = None,
|
|
239
|
+
credential_homes: Mapping[ProviderId, Path] | None = None,
|
|
240
|
+
executors: Mapping[ProviderId, Executor] | None = None,
|
|
241
|
+
validator: Validator | None = None,
|
|
242
|
+
validation_commands: tuple[tuple[str, ...], ...] = (),
|
|
243
|
+
lifecycle_service: ProviderLifecycleService | None = None,
|
|
244
|
+
lifecycle_boundaries: Mapping[ProviderId, str] | None = None,
|
|
245
|
+
runtime_identity_loader: RuntimeIdentityLoader | None = None,
|
|
246
|
+
) -> None:
|
|
247
|
+
try:
|
|
248
|
+
self.root = Path(path).resolve(strict=True)
|
|
249
|
+
except OSError:
|
|
250
|
+
raise RoutingServiceError("repository_root_invalid") from None
|
|
251
|
+
if not self.root.is_dir():
|
|
252
|
+
raise RoutingServiceError("repository_root_invalid")
|
|
253
|
+
self.settings = settings or RoutingSettings.from_env()
|
|
254
|
+
self.store = RepositoryStore(self.root)
|
|
255
|
+
self.state_dir = (state_dir or _machine_state_dir()).resolve(strict=False)
|
|
256
|
+
self._executables = dict(executables or {})
|
|
257
|
+
self._credential_homes = dict(credential_homes or {})
|
|
258
|
+
self._identity_loader = identity_loader or self._preflight
|
|
259
|
+
self._executors: dict[ProviderId, Executor] = {
|
|
260
|
+
ProviderId.CLAUDE_CODE: execute_claude,
|
|
261
|
+
ProviderId.CODEX: execute_codex,
|
|
262
|
+
}
|
|
263
|
+
if executors:
|
|
264
|
+
self._executors.update(executors)
|
|
265
|
+
if not isinstance(validation_commands, tuple) or any(
|
|
266
|
+
not isinstance(command, tuple)
|
|
267
|
+
or not command
|
|
268
|
+
or any(not isinstance(value, str) or not value for value in command)
|
|
269
|
+
for command in validation_commands
|
|
270
|
+
):
|
|
271
|
+
raise RoutingServiceError("validation_command_invalid")
|
|
272
|
+
self._validation_commands = validation_commands
|
|
273
|
+
self._validator = validator or self._validate
|
|
274
|
+
self._lifecycle_service = lifecycle_service
|
|
275
|
+
self._lifecycle_boundaries = dict(lifecycle_boundaries or {})
|
|
276
|
+
self._runtime_identity_loader = runtime_identity_loader
|
|
277
|
+
if (lifecycle_service is None) is not (runtime_identity_loader is None) or (
|
|
278
|
+
lifecycle_service is not None and not self._lifecycle_boundaries
|
|
279
|
+
):
|
|
280
|
+
raise RoutingServiceError("lifecycle_configuration_invalid")
|
|
281
|
+
self._recommendations: dict[str, _RecommendationState] = {}
|
|
282
|
+
self._prepared: dict[str, PreparedExecution] = {}
|
|
283
|
+
self._snapshots: dict[str, CapabilitySnapshot] = {}
|
|
284
|
+
self._review_primary: dict[str, tuple[str, str]] = {}
|
|
285
|
+
|
|
286
|
+
def _executable(self, provider: ProviderId) -> Path:
|
|
287
|
+
return self._executables.get(provider) or _default_executable(provider)
|
|
288
|
+
|
|
289
|
+
def _credential_home(self, provider: ProviderId) -> Path:
|
|
290
|
+
return self._credential_homes.get(provider) or _default_credential_home(provider)
|
|
291
|
+
|
|
292
|
+
def _preflight(self, provider: ProviderId) -> CliIdentity:
|
|
293
|
+
kwargs = {
|
|
294
|
+
"executable": self._executable(provider),
|
|
295
|
+
"workspace": self.root,
|
|
296
|
+
"credential_home": self._credential_home(provider),
|
|
297
|
+
}
|
|
298
|
+
if provider is ProviderId.CLAUDE_CODE:
|
|
299
|
+
return preflight_claude(**kwargs)
|
|
300
|
+
return preflight_codex(**kwargs)
|
|
301
|
+
|
|
302
|
+
@staticmethod
|
|
303
|
+
def _graph_fingerprint(bundle: object) -> str:
|
|
304
|
+
return hashlib.sha256(
|
|
305
|
+
json.dumps(bundle, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
306
|
+
).hexdigest()
|
|
307
|
+
|
|
308
|
+
def recommend(
|
|
309
|
+
self, *, objective: str, targets: tuple[str, ...]
|
|
310
|
+
) -> RoutingRecommendation:
|
|
311
|
+
request = TaskRequest(
|
|
312
|
+
objective=objective,
|
|
313
|
+
repository_root=self.root,
|
|
314
|
+
targets=tuple(targets),
|
|
315
|
+
max_input_tokens=self.settings.max_input_tokens,
|
|
316
|
+
max_output_tokens=self.settings.max_output_tokens,
|
|
317
|
+
data_policy="source_allowed",
|
|
318
|
+
)
|
|
319
|
+
cfg = Config.from_env()
|
|
320
|
+
freshness = check_graph_freshness(self.root, cfg)
|
|
321
|
+
if freshness.get("stale", True):
|
|
322
|
+
return self._handoff("unclassified", "unknown", "graph_stale")
|
|
323
|
+
try:
|
|
324
|
+
self.store.initialize()
|
|
325
|
+
bundle, graph = load_validated_graph_bundle(
|
|
326
|
+
self.root / cfg.output_dir / "graph.json", root=self.root
|
|
327
|
+
)
|
|
328
|
+
task = classify_task(request, graph)
|
|
329
|
+
context = build_routing_context(request, graph, self.settings)
|
|
330
|
+
now = int(time.time())
|
|
331
|
+
active_lifecycle_digests: frozenset[str] | None = None
|
|
332
|
+
if self._lifecycle_service is not None:
|
|
333
|
+
active: set[str] = set()
|
|
334
|
+
for boundary in self._lifecycle_boundaries.values():
|
|
335
|
+
try:
|
|
336
|
+
active.add(
|
|
337
|
+
self._lifecycle_service.active_identity_digest(boundary)
|
|
338
|
+
)
|
|
339
|
+
except LifecycleServiceError:
|
|
340
|
+
continue
|
|
341
|
+
active_lifecycle_digests = frozenset(active)
|
|
342
|
+
snapshots = load_verified_capability_snapshots(
|
|
343
|
+
self.store,
|
|
344
|
+
now=now,
|
|
345
|
+
active_lifecycle_identity_digests=active_lifecycle_digests,
|
|
346
|
+
)
|
|
347
|
+
except (GraphReadError, StorageError, OSError, ValueError) as exc:
|
|
348
|
+
return self._handoff(
|
|
349
|
+
"unclassified", "unknown", getattr(exc, "code", "routing_evidence_blocked")
|
|
350
|
+
)
|
|
351
|
+
identities: list[CliIdentity] = []
|
|
352
|
+
providers: list[ProviderId] = []
|
|
353
|
+
for provider in sorted({item.profile.provider for item in snapshots}, key=str):
|
|
354
|
+
try:
|
|
355
|
+
identity = self._identity_loader(provider)
|
|
356
|
+
except (AdapterError, RoutingServiceError, OSError, ValueError):
|
|
357
|
+
continue
|
|
358
|
+
identities.append(identity)
|
|
359
|
+
providers.append(provider)
|
|
360
|
+
context_tokens = max(1, (context.manifest.total_bytes + 3) // 4)
|
|
361
|
+
candidates = tuple(
|
|
362
|
+
CliCandidateMetrics(
|
|
363
|
+
capability_snapshot_digest=snapshot.digest,
|
|
364
|
+
effort=snapshot.profile.supported_efforts[0],
|
|
365
|
+
repository_success_millis=None,
|
|
366
|
+
global_success_millis=500,
|
|
367
|
+
sample_count=0,
|
|
368
|
+
expected_input_tokens=context_tokens,
|
|
369
|
+
expected_output_tokens=request.max_output_tokens,
|
|
370
|
+
expected_latency_ms=30_000,
|
|
371
|
+
quota_scarcity_millis=0,
|
|
372
|
+
)
|
|
373
|
+
for snapshot in snapshots
|
|
374
|
+
)
|
|
375
|
+
ranked = rank_cli_candidates(
|
|
376
|
+
task,
|
|
377
|
+
snapshots,
|
|
378
|
+
candidates,
|
|
379
|
+
CliPolicyGates(
|
|
380
|
+
authenticated_providers=tuple(providers),
|
|
381
|
+
current_cli_identities=tuple(identities),
|
|
382
|
+
permission_mode=PermissionMode.WORKSPACE_WRITE,
|
|
383
|
+
context_tokens=context_tokens,
|
|
384
|
+
budget_tokens=min(
|
|
385
|
+
self.settings.repository_quota_tokens,
|
|
386
|
+
request.max_input_tokens + request.max_output_tokens,
|
|
387
|
+
),
|
|
388
|
+
now=now,
|
|
389
|
+
),
|
|
390
|
+
)
|
|
391
|
+
selected = ranked.selected
|
|
392
|
+
snapshot = None if selected is None else next(
|
|
393
|
+
item for item in snapshots if item.digest == selected.capability_snapshot_digest
|
|
394
|
+
)
|
|
395
|
+
recommendation = RoutingRecommendation(
|
|
396
|
+
task_id=task.task_id,
|
|
397
|
+
provider=None if selected is None else selected.provider,
|
|
398
|
+
requested_model=None if selected is None else selected.requested_model,
|
|
399
|
+
effective_model=None if selected is None else selected.effective_model,
|
|
400
|
+
effort=None if selected is None else selected.effort,
|
|
401
|
+
snapshot_expires_at=None if snapshot is None else snapshot.expires_at,
|
|
402
|
+
permission_mode=(
|
|
403
|
+
None if snapshot is None else snapshot.profile.permission_mode
|
|
404
|
+
),
|
|
405
|
+
risk=task.risk.value,
|
|
406
|
+
estimated_tokens=context_tokens + request.max_output_tokens,
|
|
407
|
+
outbound_manifest=context.manifest.to_dict(),
|
|
408
|
+
reasons=ranked.reasons,
|
|
409
|
+
manual_handoff=ranked.manual_handoff,
|
|
410
|
+
policy_version=ranked.policy_version,
|
|
411
|
+
)
|
|
412
|
+
if snapshot is not None:
|
|
413
|
+
lifecycle_digest = None
|
|
414
|
+
if self._lifecycle_service is not None:
|
|
415
|
+
lifecycle_digest = self.store.lifecycle_identity_binding(
|
|
416
|
+
authority_kind="capability_snapshot", authority_id=snapshot.digest
|
|
417
|
+
)
|
|
418
|
+
state = _RecommendationState(
|
|
419
|
+
request,
|
|
420
|
+
task,
|
|
421
|
+
context,
|
|
422
|
+
self._graph_fingerprint(bundle),
|
|
423
|
+
snapshot,
|
|
424
|
+
recommendation,
|
|
425
|
+
lifecycle_digest,
|
|
426
|
+
)
|
|
427
|
+
self._recommendations[task.task_id] = state
|
|
428
|
+
self._snapshots[snapshot.digest] = snapshot
|
|
429
|
+
return recommendation
|
|
430
|
+
|
|
431
|
+
@staticmethod
|
|
432
|
+
def _handoff(task_id: str, risk: str, reason: str) -> RoutingRecommendation:
|
|
433
|
+
return RoutingRecommendation(
|
|
434
|
+
task_id,
|
|
435
|
+
None,
|
|
436
|
+
None,
|
|
437
|
+
None,
|
|
438
|
+
None,
|
|
439
|
+
None,
|
|
440
|
+
None,
|
|
441
|
+
risk,
|
|
442
|
+
0,
|
|
443
|
+
{"items": [], "total_bytes": 0},
|
|
444
|
+
(str(reason),),
|
|
445
|
+
True,
|
|
446
|
+
"3",
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def _head_commit(self) -> str:
|
|
450
|
+
try:
|
|
451
|
+
result = GitRunner(self.root).run(
|
|
452
|
+
["rev-parse", "HEAD"], timeout_seconds=5.0, max_stdout_bytes=256
|
|
453
|
+
)
|
|
454
|
+
value = result.stdout.decode("ascii").strip()
|
|
455
|
+
except (GitError, UnicodeDecodeError, OSError):
|
|
456
|
+
raise RoutingServiceError("repository_commit_unavailable") from None
|
|
457
|
+
if result.returncode != 0 or len(value) not in {40, 64}:
|
|
458
|
+
raise RoutingServiceError("repository_commit_unavailable")
|
|
459
|
+
return value
|
|
460
|
+
|
|
461
|
+
def prepare(self, recommendation: RoutingRecommendation) -> PreparedExecution:
|
|
462
|
+
if not isinstance(recommendation, RoutingRecommendation):
|
|
463
|
+
raise RoutingServiceError("recommendation_invalid")
|
|
464
|
+
if recommendation.manual_handoff or recommendation.provider is None:
|
|
465
|
+
raise RoutingServiceError("manual_handoff_required")
|
|
466
|
+
if any(
|
|
467
|
+
prepared.task_id == recommendation.task_id
|
|
468
|
+
for prepared in self._prepared.values()
|
|
469
|
+
):
|
|
470
|
+
raise RoutingServiceError("transition_replay")
|
|
471
|
+
state = self._recommendations.pop(recommendation.task_id, None)
|
|
472
|
+
if state is None or state.recommendation != recommendation:
|
|
473
|
+
raise RoutingServiceError("recommendation_expired")
|
|
474
|
+
now = int(time.time())
|
|
475
|
+
commit = self._head_commit()
|
|
476
|
+
worktree_id = "worktree-" + secrets.token_hex(12)
|
|
477
|
+
worktree = create_task_worktree(
|
|
478
|
+
source_root=self.root,
|
|
479
|
+
state_root=self.state_dir / "worktrees",
|
|
480
|
+
task_id=worktree_id,
|
|
481
|
+
approved_commit=commit,
|
|
482
|
+
)
|
|
483
|
+
decision_id = "decision-" + secrets.token_hex(12)
|
|
484
|
+
attempt_id = "attempt-" + secrets.token_hex(12)
|
|
485
|
+
approval_id = "approval-" + secrets.token_hex(12)
|
|
486
|
+
prompt = canonical_cli_prompt(
|
|
487
|
+
objective=state.request.objective, context=state.context
|
|
488
|
+
)
|
|
489
|
+
snapshot = state.snapshot
|
|
490
|
+
manifest = CliApprovalManifest(
|
|
491
|
+
approval_id=approval_id,
|
|
492
|
+
task_id=state.task.task_id,
|
|
493
|
+
decision_id=decision_id,
|
|
494
|
+
provider=snapshot.profile.provider,
|
|
495
|
+
requested_model=snapshot.profile.requested_model,
|
|
496
|
+
effective_model=snapshot.profile.effective_model,
|
|
497
|
+
effort=snapshot.profile.supported_efforts[0],
|
|
498
|
+
cli_executable_sha256=snapshot.identity.executable_sha256,
|
|
499
|
+
cli_version=snapshot.identity.cli_version,
|
|
500
|
+
adapter_protocol_version=snapshot.identity.adapter_protocol_version,
|
|
501
|
+
capability_snapshot_digest=snapshot.digest,
|
|
502
|
+
graph_fingerprint=state.graph_fingerprint,
|
|
503
|
+
context_manifest_hash=state.context.manifest.manifest_hash,
|
|
504
|
+
repository_commit=commit,
|
|
505
|
+
worktree_id=worktree_id,
|
|
506
|
+
permission_mode=PermissionMode.WORKSPACE_WRITE,
|
|
507
|
+
max_input_tokens=state.request.max_input_tokens,
|
|
508
|
+
max_output_tokens=state.request.max_output_tokens,
|
|
509
|
+
policy_version=recommendation.policy_version,
|
|
510
|
+
issued_at=now,
|
|
511
|
+
expires_at=now + self.settings.approval_ttl_seconds,
|
|
512
|
+
nonce=secrets.token_hex(24),
|
|
513
|
+
)
|
|
514
|
+
self.store.record_task(
|
|
515
|
+
state.task.task_id,
|
|
516
|
+
state.task.category.value,
|
|
517
|
+
state.task.risk.value,
|
|
518
|
+
hashlib.sha256(state.request.objective.encode("utf-8")).hexdigest(),
|
|
519
|
+
now,
|
|
520
|
+
)
|
|
521
|
+
self.store.record_decision(
|
|
522
|
+
decision_id,
|
|
523
|
+
state.task.task_id,
|
|
524
|
+
manifest.requested_model,
|
|
525
|
+
manifest.effort.value,
|
|
526
|
+
recommendation.policy_version,
|
|
527
|
+
"cli-1",
|
|
528
|
+
now,
|
|
529
|
+
)
|
|
530
|
+
self.store.create_task_worktree_record(
|
|
531
|
+
worktree_id=worktree_id,
|
|
532
|
+
task_id=state.task.task_id,
|
|
533
|
+
baseline_commit=commit,
|
|
534
|
+
canonical_root_hash=hashlib.sha256(str(worktree.root).encode()).hexdigest(),
|
|
535
|
+
created_at=now,
|
|
536
|
+
)
|
|
537
|
+
prepared = PreparedExecution(
|
|
538
|
+
state.task.task_id,
|
|
539
|
+
decision_id,
|
|
540
|
+
attempt_id,
|
|
541
|
+
worktree,
|
|
542
|
+
manifest,
|
|
543
|
+
prompt,
|
|
544
|
+
state.graph_fingerprint,
|
|
545
|
+
state.lifecycle_identity_digest,
|
|
546
|
+
)
|
|
547
|
+
self._prepared[attempt_id] = prepared
|
|
548
|
+
return prepared
|
|
549
|
+
|
|
550
|
+
@staticmethod
|
|
551
|
+
def _manifest_hash(manifest: CliApprovalManifest) -> str:
|
|
552
|
+
return hashlib.sha256(
|
|
553
|
+
json.dumps(
|
|
554
|
+
manifest.to_dict(), sort_keys=True, separators=(",", ":")
|
|
555
|
+
).encode("utf-8")
|
|
556
|
+
).hexdigest()
|
|
557
|
+
|
|
558
|
+
def _validate(self, worktree: TaskWorktree) -> bool:
|
|
559
|
+
runner = GitRunner(worktree.root)
|
|
560
|
+
executable = runner.executable.resolve(strict=True)
|
|
561
|
+
argv = (
|
|
562
|
+
str(executable),
|
|
563
|
+
"--no-optional-locks",
|
|
564
|
+
"-c",
|
|
565
|
+
"core.fsmonitor=false",
|
|
566
|
+
"-c",
|
|
567
|
+
f"safe.directory={worktree.root}",
|
|
568
|
+
"diff",
|
|
569
|
+
"--check",
|
|
570
|
+
worktree.baseline_commit,
|
|
571
|
+
"--",
|
|
572
|
+
)
|
|
573
|
+
try:
|
|
574
|
+
run_cli_process(
|
|
575
|
+
argv=argv,
|
|
576
|
+
cwd=worktree.root,
|
|
577
|
+
stdin=b"",
|
|
578
|
+
provider=ProviderId.CODEX,
|
|
579
|
+
credential_home=None,
|
|
580
|
+
timeout_seconds=60.0,
|
|
581
|
+
max_input_bytes=1,
|
|
582
|
+
max_output_bytes=256 * 1024,
|
|
583
|
+
)
|
|
584
|
+
except (CliProcessError, GitError, OSError):
|
|
585
|
+
return False
|
|
586
|
+
for command in self._validation_commands:
|
|
587
|
+
try:
|
|
588
|
+
run_cli_process(
|
|
589
|
+
argv=command,
|
|
590
|
+
cwd=worktree.root,
|
|
591
|
+
stdin=b"",
|
|
592
|
+
provider=ProviderId.CODEX,
|
|
593
|
+
credential_home=None,
|
|
594
|
+
timeout_seconds=300.0,
|
|
595
|
+
max_input_bytes=1,
|
|
596
|
+
max_output_bytes=1024 * 1024,
|
|
597
|
+
)
|
|
598
|
+
except CliProcessError:
|
|
599
|
+
return False
|
|
600
|
+
return True
|
|
601
|
+
|
|
602
|
+
def run_approved(
|
|
603
|
+
self, prepared: PreparedExecution, *, approval_granted: bool
|
|
604
|
+
) -> ApprovedExecution:
|
|
605
|
+
if approval_granted is not True:
|
|
606
|
+
raise RoutingServiceError("approval_required")
|
|
607
|
+
current = self._prepared.get(prepared.attempt_id)
|
|
608
|
+
if current is None or current != prepared:
|
|
609
|
+
raise RoutingServiceError("transition_replay")
|
|
610
|
+
manifest = prepared.manifest
|
|
611
|
+
snapshot = self._snapshots.get(manifest.capability_snapshot_digest)
|
|
612
|
+
if snapshot is None or snapshot.digest != manifest.capability_snapshot_digest:
|
|
613
|
+
raise RoutingServiceError("capability_snapshot_missing")
|
|
614
|
+
try:
|
|
615
|
+
current_identity = self._identity_loader(manifest.provider)
|
|
616
|
+
except (AdapterError, RoutingServiceError, OSError, ValueError):
|
|
617
|
+
raise RoutingServiceError("cli_preflight_failed") from None
|
|
618
|
+
if current_identity != snapshot.identity:
|
|
619
|
+
raise RoutingServiceError("cli_identity_changed")
|
|
620
|
+
live_runtime_identity: ProviderRuntimeIdentity | None = None
|
|
621
|
+
if self._lifecycle_service is not None:
|
|
622
|
+
boundary = self._lifecycle_boundaries.get(manifest.provider)
|
|
623
|
+
if boundary is None or prepared.lifecycle_identity_digest is None or self._runtime_identity_loader is None:
|
|
624
|
+
raise RoutingServiceError("lifecycle_authority_missing")
|
|
625
|
+
try:
|
|
626
|
+
live_runtime_identity = self._runtime_identity_loader(manifest.provider)
|
|
627
|
+
self._lifecycle_service.require_snapshot_authority(
|
|
628
|
+
boundary_digest=boundary,
|
|
629
|
+
lifecycle_identity_digest=prepared.lifecycle_identity_digest,
|
|
630
|
+
capability_snapshot_digest=manifest.capability_snapshot_digest,
|
|
631
|
+
live_identity=live_runtime_identity,
|
|
632
|
+
)
|
|
633
|
+
except (LifecycleServiceError, ValueError):
|
|
634
|
+
raise RoutingServiceError("lifecycle_identity_changed") from None
|
|
635
|
+
self._prepared.pop(prepared.attempt_id, None)
|
|
636
|
+
now = int(time.time())
|
|
637
|
+
authority = ApprovalAuthority(
|
|
638
|
+
self.store,
|
|
639
|
+
key_path=self.state_dir / "approval.key",
|
|
640
|
+
quota_path=self.state_dir / "quota.sqlite3",
|
|
641
|
+
)
|
|
642
|
+
if prepared.lifecycle_identity_digest is None:
|
|
643
|
+
signed = authority.issue(manifest)
|
|
644
|
+
else:
|
|
645
|
+
signed = authority.issue(
|
|
646
|
+
manifest,
|
|
647
|
+
lifecycle_identity_digest=prepared.lifecycle_identity_digest,
|
|
648
|
+
capability_snapshot_digest=manifest.capability_snapshot_digest,
|
|
649
|
+
bound_at=now,
|
|
650
|
+
)
|
|
651
|
+
manifest_hash = self._manifest_hash(manifest)
|
|
652
|
+
self.store.create_cli_execution_attempt_record(
|
|
653
|
+
attempt_id=prepared.attempt_id,
|
|
654
|
+
approval_id=manifest.approval_id,
|
|
655
|
+
task_id=manifest.task_id,
|
|
656
|
+
decision_id=manifest.decision_id,
|
|
657
|
+
worktree_id=manifest.worktree_id,
|
|
658
|
+
provider=manifest.provider.value,
|
|
659
|
+
requested_model=manifest.requested_model,
|
|
660
|
+
effective_model=manifest.effective_model,
|
|
661
|
+
effort=manifest.effort.value,
|
|
662
|
+
cli_version=manifest.cli_version,
|
|
663
|
+
executable_sha256=manifest.cli_executable_sha256,
|
|
664
|
+
capability_snapshot_digest=manifest.capability_snapshot_digest,
|
|
665
|
+
manifest_hash=manifest_hash,
|
|
666
|
+
expected_prompt_hash=prepared.prompt.prompt_hash,
|
|
667
|
+
reserved_tokens=manifest.max_input_tokens + manifest.max_output_tokens,
|
|
668
|
+
created_at=now,
|
|
669
|
+
)
|
|
670
|
+
if prepared.lifecycle_identity_digest is not None:
|
|
671
|
+
try:
|
|
672
|
+
self.store.save_lifecycle_attempt_binding(
|
|
673
|
+
attempt_id=prepared.attempt_id,
|
|
674
|
+
lifecycle_identity_digest=prepared.lifecycle_identity_digest,
|
|
675
|
+
bound_at=now,
|
|
676
|
+
)
|
|
677
|
+
except (StorageError, ValueError):
|
|
678
|
+
self._fail(prepared, "lifecycle_binding_failed", quarantine=True)
|
|
679
|
+
raise RoutingServiceError("lifecycle_binding_failed") from None
|
|
680
|
+
try:
|
|
681
|
+
if self._lifecycle_service is not None:
|
|
682
|
+
assert live_runtime_identity is not None
|
|
683
|
+
self._lifecycle_service.require_execution_authority(
|
|
684
|
+
boundary_digest=self._lifecycle_boundaries[manifest.provider],
|
|
685
|
+
lifecycle_identity_digest=prepared.lifecycle_identity_digest,
|
|
686
|
+
capability_snapshot_digest=manifest.capability_snapshot_digest,
|
|
687
|
+
approval_id=manifest.approval_id,
|
|
688
|
+
live_identity=live_runtime_identity,
|
|
689
|
+
)
|
|
690
|
+
authority.consume(
|
|
691
|
+
signed,
|
|
692
|
+
manifest,
|
|
693
|
+
repository_quota_tokens=self.settings.repository_quota_tokens,
|
|
694
|
+
machine_quota_tokens=self.settings.machine_quota_tokens,
|
|
695
|
+
lifecycle_identity_digest=prepared.lifecycle_identity_digest,
|
|
696
|
+
capability_snapshot_digest=(
|
|
697
|
+
manifest.capability_snapshot_digest
|
|
698
|
+
if prepared.lifecycle_identity_digest is not None
|
|
699
|
+
else None
|
|
700
|
+
),
|
|
701
|
+
)
|
|
702
|
+
executor = self._executors[manifest.provider]
|
|
703
|
+
result = executor(
|
|
704
|
+
executable=self._executable(manifest.provider),
|
|
705
|
+
workspace=prepared.worktree.root,
|
|
706
|
+
credential_home=self._credential_home(manifest.provider),
|
|
707
|
+
prompt=prepared.prompt.body,
|
|
708
|
+
requested_model=manifest.requested_model,
|
|
709
|
+
expected_effective_model=manifest.effective_model,
|
|
710
|
+
effort=manifest.effort,
|
|
711
|
+
permission_mode=manifest.permission_mode,
|
|
712
|
+
)
|
|
713
|
+
except ApprovalError as exc:
|
|
714
|
+
self._fail(prepared, exc.code, quarantine=True)
|
|
715
|
+
raise RoutingServiceError(exc.code) from None
|
|
716
|
+
except (AdapterError, KeyError) as exc:
|
|
717
|
+
code = getattr(exc, "code", "executor_unavailable")
|
|
718
|
+
self._fail(prepared, code, quarantine=True)
|
|
719
|
+
raise RoutingServiceError(code) from None
|
|
720
|
+
except Exception:
|
|
721
|
+
self._fail(prepared, "executor_failed", quarantine=True)
|
|
722
|
+
raise RoutingServiceError("executor_failed") from None
|
|
723
|
+
if (
|
|
724
|
+
not isinstance(result.message, str)
|
|
725
|
+
or not result.message
|
|
726
|
+
or len(result.message) > 1_048_576
|
|
727
|
+
or result.effective_model != manifest.effective_model
|
|
728
|
+
or isinstance(result.input_tokens, bool)
|
|
729
|
+
or not isinstance(result.input_tokens, int)
|
|
730
|
+
or not 0 <= result.input_tokens <= manifest.max_input_tokens
|
|
731
|
+
or isinstance(result.output_tokens, bool)
|
|
732
|
+
or not isinstance(result.output_tokens, int)
|
|
733
|
+
or not 0 <= result.output_tokens <= manifest.max_output_tokens
|
|
734
|
+
or isinstance(result.duration_seconds, bool)
|
|
735
|
+
or not isinstance(result.duration_seconds, (int, float))
|
|
736
|
+
or not math.isfinite(result.duration_seconds)
|
|
737
|
+
or result.duration_seconds < 0
|
|
738
|
+
):
|
|
739
|
+
self._fail(prepared, "executor_result_invalid", quarantine=True)
|
|
740
|
+
raise RoutingServiceError("executor_result_invalid")
|
|
741
|
+
try:
|
|
742
|
+
evidence = collect_diff_evidence(
|
|
743
|
+
prepared.worktree,
|
|
744
|
+
max_files=self.settings.max_changed_files,
|
|
745
|
+
max_bytes=self.settings.max_changed_bytes,
|
|
746
|
+
)
|
|
747
|
+
except (DiffPolicyError, WorktreeError) as exc:
|
|
748
|
+
code = getattr(exc, "code", "diff_blocked")
|
|
749
|
+
self._fail(prepared, code, quarantine=True)
|
|
750
|
+
# Hand the diagnostic across explicitly. Both hops raise `from None`,
|
|
751
|
+
# so anything not passed by hand dies here and the log shows a bare
|
|
752
|
+
# code -- which is precisely what graphite#37's one sighting shows.
|
|
753
|
+
# `_fail` still records the unwidened `code`: the taxonomy is stable,
|
|
754
|
+
# the message is where the detail goes.
|
|
755
|
+
raise RoutingServiceError(code, getattr(exc, "cause", None)) from None
|
|
756
|
+
if prepared.attempt_id in self._review_primary and evidence.changed_files != 0:
|
|
757
|
+
self._fail(prepared, "review_mutation", quarantine=True)
|
|
758
|
+
raise RoutingServiceError("review_mutation")
|
|
759
|
+
try:
|
|
760
|
+
validation_outcome = (
|
|
761
|
+
"passed" if self._validator(prepared.worktree) else "failed"
|
|
762
|
+
)
|
|
763
|
+
except Exception:
|
|
764
|
+
self._fail(prepared, "validation_failed", quarantine=True)
|
|
765
|
+
raise RoutingServiceError("validation_failed") from None
|
|
766
|
+
completed_at = int(time.time())
|
|
767
|
+
self.store.record_validation_result(
|
|
768
|
+
attempt_id=prepared.attempt_id,
|
|
769
|
+
diff_hash=evidence.diff_sha256,
|
|
770
|
+
changed_file_count=evidence.changed_files,
|
|
771
|
+
changed_byte_count=evidence.changed_bytes,
|
|
772
|
+
outcome=validation_outcome,
|
|
773
|
+
recorded_at=completed_at,
|
|
774
|
+
)
|
|
775
|
+
input_tokens = result.input_tokens
|
|
776
|
+
output_tokens = result.output_tokens
|
|
777
|
+
if input_tokens is None or output_tokens is None:
|
|
778
|
+
self._fail(prepared, "usage_unavailable", quarantine=True)
|
|
779
|
+
raise RoutingServiceError("usage_unavailable")
|
|
780
|
+
receipt = ExecutionReceipt(
|
|
781
|
+
execution_id="execution-" + secrets.token_hex(12),
|
|
782
|
+
approval_id=manifest.approval_id,
|
|
783
|
+
model_id=manifest.requested_model,
|
|
784
|
+
effort=manifest.effort,
|
|
785
|
+
outcome=ExecutionOutcome.SUCCEEDED,
|
|
786
|
+
input_tokens=input_tokens,
|
|
787
|
+
output_tokens=output_tokens,
|
|
788
|
+
latency_ms=max(0, int(result.duration_seconds * 1_000)),
|
|
789
|
+
prompt_hash=prepared.prompt.prompt_hash,
|
|
790
|
+
response_hash=hashlib.sha256(result.message.encode("utf-8")).hexdigest(),
|
|
791
|
+
failure_reason=None,
|
|
792
|
+
provider=manifest.provider,
|
|
793
|
+
effective_model=result.effective_model,
|
|
794
|
+
cli_version=manifest.cli_version,
|
|
795
|
+
changed_file_count=evidence.changed_files,
|
|
796
|
+
changed_byte_count=evidence.changed_bytes,
|
|
797
|
+
validation_outcome=validation_outcome,
|
|
798
|
+
)
|
|
799
|
+
self.store.finalize_cli_execution(
|
|
800
|
+
attempt_id=prepared.attempt_id,
|
|
801
|
+
receipt=receipt,
|
|
802
|
+
graph_fingerprint=prepared.graph_fingerprint,
|
|
803
|
+
completed_at=completed_at,
|
|
804
|
+
)
|
|
805
|
+
self.store.transition_task_worktree(
|
|
806
|
+
manifest.worktree_id,
|
|
807
|
+
expected_status="prepared",
|
|
808
|
+
new_status="executed",
|
|
809
|
+
updated_at=completed_at,
|
|
810
|
+
)
|
|
811
|
+
return ApprovedExecution(result.message, receipt, evidence.diff_sha256)
|
|
812
|
+
|
|
813
|
+
def prepare_review(self, task_id: str) -> PreparedExecution:
|
|
814
|
+
"""Prepare a separate read-only other-provider review of a primary diff."""
|
|
815
|
+
primary_worktree, record = self._load_task_worktree(task_id)
|
|
816
|
+
if record["status"] != "executed":
|
|
817
|
+
raise RoutingServiceError("review_primary_not_executed")
|
|
818
|
+
primary = self.store.worktree_validation_record(primary_worktree.worktree_id)
|
|
819
|
+
if primary is None or primary["outcome"] != "passed":
|
|
820
|
+
raise RoutingServiceError("review_primary_not_validated")
|
|
821
|
+
if primary["risk"] != RiskTier.HIGH.value:
|
|
822
|
+
raise RoutingServiceError("review_not_high_risk")
|
|
823
|
+
now = int(time.time())
|
|
824
|
+
snapshots = load_verified_capability_snapshots(self.store, now=now)
|
|
825
|
+
primary_provider = ProviderId(str(primary["provider"]))
|
|
826
|
+
eligible: list[CapabilitySnapshot] = []
|
|
827
|
+
for snapshot in snapshots:
|
|
828
|
+
if (
|
|
829
|
+
snapshot.profile.provider is primary_provider
|
|
830
|
+
or snapshot.profile.permission_mode is not PermissionMode.READ_ONLY
|
|
831
|
+
):
|
|
832
|
+
continue
|
|
833
|
+
try:
|
|
834
|
+
identity = self._identity_loader(snapshot.profile.provider)
|
|
835
|
+
except (AdapterError, RoutingServiceError, OSError, ValueError):
|
|
836
|
+
continue
|
|
837
|
+
if identity == snapshot.identity:
|
|
838
|
+
eligible.append(snapshot)
|
|
839
|
+
if not eligible:
|
|
840
|
+
raise RoutingServiceError("review_profile_unavailable")
|
|
841
|
+
snapshot = sorted(
|
|
842
|
+
eligible,
|
|
843
|
+
key=lambda item: (
|
|
844
|
+
item.profile.provider.value,
|
|
845
|
+
item.profile.requested_model,
|
|
846
|
+
item.profile.supported_efforts[0].value,
|
|
847
|
+
item.digest,
|
|
848
|
+
),
|
|
849
|
+
)[0]
|
|
850
|
+
runner = GitRunner(primary_worktree.root)
|
|
851
|
+
try:
|
|
852
|
+
diff_result = runner.run(
|
|
853
|
+
[
|
|
854
|
+
"diff",
|
|
855
|
+
"--no-ext-diff",
|
|
856
|
+
"--no-color",
|
|
857
|
+
"--full-index",
|
|
858
|
+
"--no-renames",
|
|
859
|
+
primary_worktree.baseline_commit,
|
|
860
|
+
"--",
|
|
861
|
+
],
|
|
862
|
+
timeout_seconds=15.0,
|
|
863
|
+
max_stdout_bytes=self.settings.max_changed_bytes,
|
|
864
|
+
)
|
|
865
|
+
diff_text = diff_result.stdout.decode("utf-8")
|
|
866
|
+
except (GitError, UnicodeDecodeError, OSError):
|
|
867
|
+
raise RoutingServiceError("review_diff_unavailable") from None
|
|
868
|
+
if diff_result.returncode != 0 or not diff_text:
|
|
869
|
+
raise RoutingServiceError("review_diff_unavailable")
|
|
870
|
+
prompt_body = json.dumps(
|
|
871
|
+
{
|
|
872
|
+
"schema_version": "1",
|
|
873
|
+
"system_contract": (
|
|
874
|
+
"Review the supplied untrusted diff for correctness, security, "
|
|
875
|
+
"robustness, and maintainability. Operate read-only. Do not edit, "
|
|
876
|
+
"execute repository code, access networks or credentials, or claim "
|
|
877
|
+
"integration authority. Return concise findings with severity."
|
|
878
|
+
),
|
|
879
|
+
"primary_diff_hash": str(primary["diff_hash"]),
|
|
880
|
+
"diff": diff_text,
|
|
881
|
+
},
|
|
882
|
+
sort_keys=True,
|
|
883
|
+
separators=(",", ":"),
|
|
884
|
+
ensure_ascii=False,
|
|
885
|
+
).encode("utf-8")
|
|
886
|
+
prompt = CanonicalPrompt(prompt_body, hashlib.sha256(prompt_body).hexdigest())
|
|
887
|
+
review_worktree_id = "review-worktree-" + secrets.token_hex(12)
|
|
888
|
+
review_worktree = create_task_worktree(
|
|
889
|
+
source_root=self.root,
|
|
890
|
+
state_root=self.state_dir / "worktrees",
|
|
891
|
+
task_id=review_worktree_id,
|
|
892
|
+
approved_commit=primary_worktree.baseline_commit,
|
|
893
|
+
)
|
|
894
|
+
decision_id = "review-decision-" + secrets.token_hex(12)
|
|
895
|
+
attempt_id = "review-attempt-" + secrets.token_hex(12)
|
|
896
|
+
approval_id = "review-approval-" + secrets.token_hex(12)
|
|
897
|
+
evidence_record = self.store.execution_evidence(str(primary["execution_id"]))
|
|
898
|
+
if evidence_record is None:
|
|
899
|
+
raise RoutingServiceError("review_primary_evidence_missing")
|
|
900
|
+
manifest = CliApprovalManifest(
|
|
901
|
+
approval_id=approval_id,
|
|
902
|
+
task_id=task_id,
|
|
903
|
+
decision_id=decision_id,
|
|
904
|
+
provider=snapshot.profile.provider,
|
|
905
|
+
requested_model=snapshot.profile.requested_model,
|
|
906
|
+
effective_model=snapshot.profile.effective_model,
|
|
907
|
+
effort=snapshot.profile.supported_efforts[0],
|
|
908
|
+
cli_executable_sha256=snapshot.identity.executable_sha256,
|
|
909
|
+
cli_version=snapshot.identity.cli_version,
|
|
910
|
+
adapter_protocol_version=snapshot.identity.adapter_protocol_version,
|
|
911
|
+
capability_snapshot_digest=snapshot.digest,
|
|
912
|
+
graph_fingerprint=evidence_record["graph_fingerprint"],
|
|
913
|
+
context_manifest_hash=str(primary["diff_hash"]),
|
|
914
|
+
repository_commit=primary_worktree.baseline_commit,
|
|
915
|
+
worktree_id=review_worktree_id,
|
|
916
|
+
permission_mode=PermissionMode.READ_ONLY,
|
|
917
|
+
max_input_tokens=self.settings.max_input_tokens,
|
|
918
|
+
max_output_tokens=self.settings.max_output_tokens,
|
|
919
|
+
policy_version="review-1",
|
|
920
|
+
issued_at=now,
|
|
921
|
+
expires_at=now + self.settings.approval_ttl_seconds,
|
|
922
|
+
nonce=secrets.token_hex(24),
|
|
923
|
+
)
|
|
924
|
+
self.store.record_decision(
|
|
925
|
+
decision_id,
|
|
926
|
+
task_id,
|
|
927
|
+
manifest.requested_model,
|
|
928
|
+
manifest.effort.value,
|
|
929
|
+
"review-1",
|
|
930
|
+
"cli-1",
|
|
931
|
+
now,
|
|
932
|
+
)
|
|
933
|
+
self.store.create_task_worktree_record(
|
|
934
|
+
worktree_id=review_worktree_id,
|
|
935
|
+
task_id=task_id,
|
|
936
|
+
baseline_commit=primary_worktree.baseline_commit,
|
|
937
|
+
canonical_root_hash=hashlib.sha256(
|
|
938
|
+
str(review_worktree.root).encode()
|
|
939
|
+
).hexdigest(),
|
|
940
|
+
created_at=now,
|
|
941
|
+
)
|
|
942
|
+
prepared = PreparedExecution(
|
|
943
|
+
task_id,
|
|
944
|
+
decision_id,
|
|
945
|
+
attempt_id,
|
|
946
|
+
review_worktree,
|
|
947
|
+
manifest,
|
|
948
|
+
prompt,
|
|
949
|
+
evidence_record["graph_fingerprint"],
|
|
950
|
+
)
|
|
951
|
+
self._prepared[attempt_id] = prepared
|
|
952
|
+
self._snapshots[snapshot.digest] = snapshot
|
|
953
|
+
self._review_primary[attempt_id] = (
|
|
954
|
+
str(primary["attempt_id"]),
|
|
955
|
+
str(primary["diff_hash"]),
|
|
956
|
+
)
|
|
957
|
+
return prepared
|
|
958
|
+
|
|
959
|
+
def run_review_approved(
|
|
960
|
+
self, prepared: PreparedExecution, *, approval_granted: bool
|
|
961
|
+
) -> ApprovedExecution:
|
|
962
|
+
binding = self._review_primary.get(prepared.attempt_id)
|
|
963
|
+
if binding is None:
|
|
964
|
+
raise RoutingServiceError("review_binding_missing")
|
|
965
|
+
result = self.run_approved(prepared, approval_granted=approval_granted)
|
|
966
|
+
primary_attempt_id, primary_diff_hash = binding
|
|
967
|
+
self.store.create_review_link_record(
|
|
968
|
+
review_attempt_id=prepared.attempt_id,
|
|
969
|
+
primary_attempt_id=primary_attempt_id,
|
|
970
|
+
primary_diff_hash=primary_diff_hash,
|
|
971
|
+
created_at=int(time.time()),
|
|
972
|
+
)
|
|
973
|
+
self._review_primary.pop(prepared.attempt_id, None)
|
|
974
|
+
return result
|
|
975
|
+
|
|
976
|
+
def decline(self, prepared: PreparedExecution) -> dict[str, str]:
|
|
977
|
+
current = self._prepared.pop(prepared.attempt_id, None)
|
|
978
|
+
if current is None or current != prepared:
|
|
979
|
+
raise RoutingServiceError("transition_replay")
|
|
980
|
+
self.store.transition_task_worktree(
|
|
981
|
+
prepared.worktree.worktree_id,
|
|
982
|
+
expected_status="prepared",
|
|
983
|
+
new_status="quarantined",
|
|
984
|
+
updated_at=int(time.time()),
|
|
985
|
+
)
|
|
986
|
+
return {
|
|
987
|
+
"task_id": prepared.task_id,
|
|
988
|
+
"worktree_id": prepared.worktree.worktree_id,
|
|
989
|
+
"status": "quarantined",
|
|
990
|
+
"reason": "approval_declined",
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
def _fail(
|
|
994
|
+
self, prepared: PreparedExecution, reason: str, *, quarantine: bool
|
|
995
|
+
) -> None:
|
|
996
|
+
now = int(time.time())
|
|
997
|
+
try:
|
|
998
|
+
self.store.mark_cli_execution_attempt(
|
|
999
|
+
prepared.attempt_id,
|
|
1000
|
+
status="quarantined" if quarantine else "failed",
|
|
1001
|
+
failure_reason=reason,
|
|
1002
|
+
updated_at=now,
|
|
1003
|
+
)
|
|
1004
|
+
if quarantine:
|
|
1005
|
+
self.store.transition_task_worktree(
|
|
1006
|
+
prepared.worktree.worktree_id,
|
|
1007
|
+
expected_status="prepared",
|
|
1008
|
+
new_status="quarantined",
|
|
1009
|
+
updated_at=now,
|
|
1010
|
+
)
|
|
1011
|
+
except (StorageError, ValueError, OSError):
|
|
1012
|
+
raise RoutingServiceError("execution_persistence_failed") from None
|
|
1013
|
+
|
|
1014
|
+
def _load_task_worktree(self, task_id: str) -> tuple[TaskWorktree, dict[str, Any]]:
|
|
1015
|
+
self.store.initialize()
|
|
1016
|
+
record = self.store.latest_task_worktree_record(task_id)
|
|
1017
|
+
if record is None:
|
|
1018
|
+
raise RoutingServiceError("worktree_missing")
|
|
1019
|
+
worktree_id = str(record["worktree_id"])
|
|
1020
|
+
candidate = self.state_dir / "worktrees" / "tasks" / worktree_id
|
|
1021
|
+
try:
|
|
1022
|
+
root = candidate.resolve(strict=True)
|
|
1023
|
+
except OSError:
|
|
1024
|
+
raise RoutingServiceError("worktree_missing") from None
|
|
1025
|
+
if hashlib.sha256(str(root).encode()).hexdigest() != record["canonical_root_hash"]:
|
|
1026
|
+
raise RoutingServiceError("worktree_identity_drift")
|
|
1027
|
+
try:
|
|
1028
|
+
runner = GitRunner(root)
|
|
1029
|
+
common_result = runner.run(
|
|
1030
|
+
["rev-parse", "--git-common-dir"],
|
|
1031
|
+
timeout_seconds=5.0,
|
|
1032
|
+
max_stdout_bytes=4_096,
|
|
1033
|
+
)
|
|
1034
|
+
raw_common = common_result.stdout.decode("utf-8").strip()
|
|
1035
|
+
common_candidate = Path(raw_common)
|
|
1036
|
+
if not common_candidate.is_absolute():
|
|
1037
|
+
common_candidate = root / common_candidate
|
|
1038
|
+
common = common_candidate.resolve(strict=True)
|
|
1039
|
+
except (GitError, UnicodeDecodeError, OSError):
|
|
1040
|
+
raise RoutingServiceError("worktree_identity_drift") from None
|
|
1041
|
+
if common_result.returncode != 0 or common != (self.root / ".git").resolve(strict=True):
|
|
1042
|
+
raise RoutingServiceError("worktree_identity_drift")
|
|
1043
|
+
return (
|
|
1044
|
+
TaskWorktree(
|
|
1045
|
+
worktree_id,
|
|
1046
|
+
root,
|
|
1047
|
+
common,
|
|
1048
|
+
str(record["baseline_commit"]),
|
|
1049
|
+
str(record["status"]),
|
|
1050
|
+
),
|
|
1051
|
+
record,
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
def accept(self, task_id: str, *, authority_granted: bool) -> dict[str, str]:
|
|
1055
|
+
"""Create a detached, cherry-pickable integration commit; never merge it."""
|
|
1056
|
+
if authority_granted is not True:
|
|
1057
|
+
raise RoutingServiceError("accept_authority_required")
|
|
1058
|
+
worktree, record = self._load_task_worktree(task_id)
|
|
1059
|
+
if record["status"] == "accepted":
|
|
1060
|
+
raise RoutingServiceError("transition_replay")
|
|
1061
|
+
if record["status"] != "executed":
|
|
1062
|
+
raise RoutingServiceError("worktree_transition_invalid")
|
|
1063
|
+
validation = self.store.worktree_validation_record(worktree.worktree_id)
|
|
1064
|
+
if validation is None or validation["outcome"] != "passed":
|
|
1065
|
+
raise RoutingServiceError("validation_not_passed")
|
|
1066
|
+
evidence = collect_diff_evidence(
|
|
1067
|
+
TaskWorktree(
|
|
1068
|
+
worktree.worktree_id,
|
|
1069
|
+
worktree.root,
|
|
1070
|
+
worktree.git_common_dir,
|
|
1071
|
+
worktree.baseline_commit,
|
|
1072
|
+
"prepared",
|
|
1073
|
+
),
|
|
1074
|
+
max_files=self.settings.max_changed_files,
|
|
1075
|
+
max_bytes=self.settings.max_changed_bytes,
|
|
1076
|
+
)
|
|
1077
|
+
if evidence.diff_sha256 != validation["diff_hash"]:
|
|
1078
|
+
raise RoutingServiceError("diff_changed_after_validation")
|
|
1079
|
+
if evidence.changed_files == 0:
|
|
1080
|
+
raise RoutingServiceError("empty_diff")
|
|
1081
|
+
runner = GitRunner(worktree.root)
|
|
1082
|
+
try:
|
|
1083
|
+
add = runner.run(
|
|
1084
|
+
["add", "--all", "--"], timeout_seconds=15.0, max_stdout_bytes=4_096
|
|
1085
|
+
)
|
|
1086
|
+
commit = runner.run(
|
|
1087
|
+
[
|
|
1088
|
+
"-c",
|
|
1089
|
+
"user.name=Graphite Router",
|
|
1090
|
+
"-c",
|
|
1091
|
+
"user.email=graphite-router@localhost",
|
|
1092
|
+
"commit",
|
|
1093
|
+
"--no-gpg-sign",
|
|
1094
|
+
"-m",
|
|
1095
|
+
f"graphite: accepted task {task_id}",
|
|
1096
|
+
],
|
|
1097
|
+
timeout_seconds=30.0,
|
|
1098
|
+
max_stdout_bytes=256 * 1024,
|
|
1099
|
+
)
|
|
1100
|
+
head = runner.run(
|
|
1101
|
+
["rev-parse", "HEAD"], timeout_seconds=5.0, max_stdout_bytes=256
|
|
1102
|
+
)
|
|
1103
|
+
commit_id = head.stdout.decode("ascii").strip()
|
|
1104
|
+
except (GitError, UnicodeDecodeError, OSError):
|
|
1105
|
+
raise RoutingServiceError("integration_commit_failed") from None
|
|
1106
|
+
if add.returncode != 0 or commit.returncode != 0 or head.returncode != 0:
|
|
1107
|
+
raise RoutingServiceError("integration_commit_failed")
|
|
1108
|
+
self.store.transition_task_worktree(
|
|
1109
|
+
worktree.worktree_id,
|
|
1110
|
+
expected_status="executed",
|
|
1111
|
+
new_status="accepted",
|
|
1112
|
+
updated_at=int(time.time()),
|
|
1113
|
+
)
|
|
1114
|
+
self.store.record_outcome(
|
|
1115
|
+
"outcome-" + secrets.token_hex(12),
|
|
1116
|
+
str(validation["execution_id"]),
|
|
1117
|
+
"human",
|
|
1118
|
+
True,
|
|
1119
|
+
False,
|
|
1120
|
+
int(time.time()),
|
|
1121
|
+
)
|
|
1122
|
+
return {
|
|
1123
|
+
"task_id": task_id,
|
|
1124
|
+
"worktree_id": worktree.worktree_id,
|
|
1125
|
+
"status": "accepted",
|
|
1126
|
+
"commit_id": commit_id,
|
|
1127
|
+
"integration": "explicit_cherry_pick_required",
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
def reject(self, task_id: str, *, authority_granted: bool) -> dict[str, str]:
|
|
1131
|
+
if authority_granted is not True:
|
|
1132
|
+
raise RoutingServiceError("reject_authority_required")
|
|
1133
|
+
worktree, record = self._load_task_worktree(task_id)
|
|
1134
|
+
if record["status"] == "rejected":
|
|
1135
|
+
raise RoutingServiceError("transition_replay")
|
|
1136
|
+
if record["status"] != "executed":
|
|
1137
|
+
raise RoutingServiceError("worktree_transition_invalid")
|
|
1138
|
+
validation = self.store.worktree_validation_record(worktree.worktree_id)
|
|
1139
|
+
if validation is None or validation["execution_id"] is None:
|
|
1140
|
+
raise RoutingServiceError("execution_evidence_missing")
|
|
1141
|
+
self.store.transition_task_worktree(
|
|
1142
|
+
worktree.worktree_id,
|
|
1143
|
+
expected_status="executed",
|
|
1144
|
+
new_status="rejected",
|
|
1145
|
+
updated_at=int(time.time()),
|
|
1146
|
+
)
|
|
1147
|
+
self.store.record_outcome(
|
|
1148
|
+
"outcome-" + secrets.token_hex(12),
|
|
1149
|
+
str(validation["execution_id"]),
|
|
1150
|
+
"human",
|
|
1151
|
+
False,
|
|
1152
|
+
False,
|
|
1153
|
+
int(time.time()),
|
|
1154
|
+
)
|
|
1155
|
+
return {
|
|
1156
|
+
"task_id": task_id,
|
|
1157
|
+
"worktree_id": worktree.worktree_id,
|
|
1158
|
+
"status": "rejected",
|
|
1159
|
+
"cleanup": "separate_explicit_authority_required",
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
def cleanup(self, task_id: str, *, authority_granted: bool) -> dict[str, str]:
|
|
1163
|
+
if authority_granted is not True:
|
|
1164
|
+
raise RoutingServiceError("cleanup_authority_required")
|
|
1165
|
+
worktree, record = self._load_task_worktree(task_id)
|
|
1166
|
+
status = str(record["status"])
|
|
1167
|
+
if status == "cleaned":
|
|
1168
|
+
raise RoutingServiceError("transition_replay")
|
|
1169
|
+
if status not in {"accepted", "rejected", "quarantined"}:
|
|
1170
|
+
raise RoutingServiceError("worktree_transition_invalid")
|
|
1171
|
+
cleanup_task_worktree(
|
|
1172
|
+
worktree,
|
|
1173
|
+
state_root=self.state_dir / "worktrees",
|
|
1174
|
+
task_id=worktree.worktree_id,
|
|
1175
|
+
terminal_status=status,
|
|
1176
|
+
authority_granted=True,
|
|
1177
|
+
)
|
|
1178
|
+
self.store.transition_task_worktree(
|
|
1179
|
+
worktree.worktree_id,
|
|
1180
|
+
expected_status=status,
|
|
1181
|
+
new_status="cleaned",
|
|
1182
|
+
updated_at=int(time.time()),
|
|
1183
|
+
)
|
|
1184
|
+
return {
|
|
1185
|
+
"task_id": task_id,
|
|
1186
|
+
"worktree_id": worktree.worktree_id,
|
|
1187
|
+
"status": "cleaned",
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
def status(self) -> dict[str, Any]:
|
|
1191
|
+
try:
|
|
1192
|
+
self.store.initialize()
|
|
1193
|
+
integrity = self.store.integrity_check()
|
|
1194
|
+
snapshots = load_verified_capability_snapshots(
|
|
1195
|
+
self.store, now=int(time.time())
|
|
1196
|
+
)
|
|
1197
|
+
except (StorageError, OSError, ValueError):
|
|
1198
|
+
integrity = "unavailable"
|
|
1199
|
+
snapshots = ()
|
|
1200
|
+
return {
|
|
1201
|
+
"routing": "ready" if integrity == "ok" and snapshots else "not_ready",
|
|
1202
|
+
"storage": integrity,
|
|
1203
|
+
"verified_profiles": len(snapshots),
|
|
1204
|
+
"providers": sorted({item.profile.provider.value for item in snapshots}),
|
|
1205
|
+
"authority": "single_use_approval_required",
|
|
1206
|
+
"automatic_execution": False,
|
|
1207
|
+
"automatic_retry": False,
|
|
1208
|
+
"automatic_fallback": False,
|
|
1209
|
+
"automatic_merge": False,
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
def recoverable_attempts(
|
|
1213
|
+
self, *, limit: int = DEFAULT_RECOVERY_PAGE_SIZE, after: str | None = None
|
|
1214
|
+
) -> RecoverableAttemptPage:
|
|
1215
|
+
self.store.initialize()
|
|
1216
|
+
return self.store.recoverable_attempts(limit=limit, after=after)
|
|
1217
|
+
|
|
1218
|
+
def reconcile_execution(self, attempt_id: str) -> dict[str, Any]:
|
|
1219
|
+
self.store.initialize()
|
|
1220
|
+
receipt = self.store.reconcile_execution_attempt(
|
|
1221
|
+
attempt_id, completed_at=int(time.time())
|
|
1222
|
+
)
|
|
1223
|
+
return dict(receipt.to_dict())
|
|
1224
|
+
|
|
1225
|
+
def policy(
|
|
1226
|
+
self,
|
|
1227
|
+
*,
|
|
1228
|
+
promote: str | None = None,
|
|
1229
|
+
rollback: str | None = None,
|
|
1230
|
+
authority_granted: bool = False,
|
|
1231
|
+
) -> dict[str, Any]:
|
|
1232
|
+
if promote is not None and rollback is not None:
|
|
1233
|
+
raise RoutingServiceError("policy_action_conflict")
|
|
1234
|
+
if promote is not None or rollback is not None:
|
|
1235
|
+
self.store.initialize()
|
|
1236
|
+
selected = promote or rollback
|
|
1237
|
+
assert selected is not None
|
|
1238
|
+
try:
|
|
1239
|
+
self.store.activate_cli_policy(
|
|
1240
|
+
selected,
|
|
1241
|
+
action="promote" if promote else "rollback",
|
|
1242
|
+
authority_granted=authority_granted,
|
|
1243
|
+
created_at=int(time.time()),
|
|
1244
|
+
)
|
|
1245
|
+
except ValueError as exc:
|
|
1246
|
+
raise RoutingServiceError(str(exc)) from exc
|
|
1247
|
+
return {
|
|
1248
|
+
"policy_version": promote or rollback or "3",
|
|
1249
|
+
"requested_action": (
|
|
1250
|
+
"promote" if promote else "rollback" if rollback else "inspect"
|
|
1251
|
+
),
|
|
1252
|
+
"execution_authority": "single_use_approval_required",
|
|
1253
|
+
"automatic_execution": False,
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
def record_outcome(self, **values: Any) -> dict[str, Any]:
|
|
1257
|
+
if values.get("provenance") != "human":
|
|
1258
|
+
raise RoutingServiceError("supported_evidence_import_required")
|
|
1259
|
+
return {
|
|
1260
|
+
"recorded": True,
|
|
1261
|
+
"provenance": "human",
|
|
1262
|
+
"autonomy_admissible": False,
|
|
1263
|
+
}
|